diff --git a/packages/app/src/cli/models/app/app.test.ts b/packages/app/src/cli/models/app/app.test.ts index 636a82fb113..450aad4ccb0 100644 --- a/packages/app/src/cli/models/app/app.test.ts +++ b/packages/app/src/cli/models/app/app.test.ts @@ -25,6 +25,7 @@ import { } from './app.test-data.js' import {ExtensionInstance} from '../extensions/extension-instance.js' import {FunctionConfigType} from '../extensions/specifications/function.js' +import {addTypeDefinition} from '../extensions/specifications/type-generation.js' import {WebhooksConfig} from '../extensions/specifications/types/app_config_webhook.js' import {EditorExtensionCollectionType} from '../extensions/specifications/editor_extension_collection.js' import {ApplicationURLs} from '../../services/dev/urls.js' @@ -960,6 +961,27 @@ describe('generateExtensionTypes', () => { await mkdir(ext1Dir) await mkdir(ext2Dir) + await mkdir(joinPath(tmpDir, 'node_modules', '@shopify', 'ui-extensions', 'admin.product-details.action.render')) + await mkdir(joinPath(tmpDir, 'node_modules', '@shopify', 'ui-extensions', 'admin.orders-details.block.render')) + await writeFile( + joinPath( + tmpDir, + 'node_modules', + '@shopify', + 'ui-extensions', + 'admin.product-details.action.render', + 'index.js', + ), + '// render target', + ) + await writeFile( + joinPath(tmpDir, 'node_modules', '@shopify', 'ui-extensions', 'admin.orders-details.block.render', 'index.js'), + '// orders target', + ) + await writeFile(joinPath(ext1Dir, 'ext1-module-1.jsx'), 'export {}') + await writeFile(joinPath(ext1Dir, 'ext1-module-2.jsx'), 'export {}') + await writeFile(joinPath(ext2Dir, 'ext2-module-1.jsx'), 'export {}') + await writeFile(joinPath(ext2Dir, 'ext2-module-2.jsx'), 'export {}') const uiExtension1 = await testUIExtension({type: 'ui_extension', handle: 'ext1', directory: ext1Dir}) const uiExtension2 = await testUIExtension({type: 'ui_extension', handle: 'ext2', directory: ext2Dir}) @@ -971,22 +993,32 @@ describe('generateExtensionTypes', () => { // Mock the extension contributions vi.spyOn(uiExtension1, 'contributeToSharedTypeFile').mockImplementation(async (typeDefinitionsByFile) => { - typeDefinitionsByFile.set( - joinPath(ext1Dir, 'shopify.d.ts'), - new Set([ - "declare module './ext1-module-1.jsx' { // mocked ext1 module 1 definition }", - "declare module './ext1-module-2.jsx' { // mocked ext1 module 2 definition }", - ]), - ) + addTypeDefinition(typeDefinitionsByFile, { + fullPath: joinPath(ext1Dir, 'ext1-module-1.jsx'), + typeFilePath: joinPath(ext1Dir, 'shopify.d.ts'), + targets: ['admin.product-details.action.render'], + apiVersion: '2025-10', + }) + addTypeDefinition(typeDefinitionsByFile, { + fullPath: joinPath(ext1Dir, 'ext1-module-2.jsx'), + typeFilePath: joinPath(ext1Dir, 'shopify.d.ts'), + targets: ['admin.product-details.action.render'], + apiVersion: '2025-10', + }) }) vi.spyOn(uiExtension2, 'contributeToSharedTypeFile').mockImplementation(async (typeDefinitionsByFile) => { - typeDefinitionsByFile.set( - joinPath(ext2Dir, 'shopify.d.ts'), - new Set([ - "declare module './ext2-module-1.jsx' { // mocked ext2 module 1 definition }", - "declare module './ext2-module-2.jsx' { // mocked ext2 module 2 definition }", - ]), - ) + addTypeDefinition(typeDefinitionsByFile, { + fullPath: joinPath(ext2Dir, 'ext2-module-1.jsx'), + typeFilePath: joinPath(ext2Dir, 'shopify.d.ts'), + targets: ['admin.orders-details.block.render'], + apiVersion: '2025-10', + }) + addTypeDefinition(typeDefinitionsByFile, { + fullPath: joinPath(ext2Dir, 'ext2-module-2.jsx'), + typeFilePath: joinPath(ext2Dir, 'shopify.d.ts'), + targets: ['admin.orders-details.block.render'], + apiVersion: '2025-10', + }) }) // When @@ -997,15 +1029,107 @@ describe('generateExtensionTypes', () => { const ext1FileContent = await readFile(ext1TypeFilePath) const normalizedExt1Content = ext1FileContent.toString().replace(/\\/g, '/') expect(normalizedExt1Content).toBe(`import '@shopify/ui-extensions';\n -declare module './ext1-module-1.jsx' { // mocked ext1 module 1 definition } -declare module './ext1-module-2.jsx' { // mocked ext1 module 2 definition }`) +//@ts-ignore +declare module './ext1-module-1.jsx' { + const shopify: import('@shopify/ui-extensions/admin.product-details.action.render').Api; + const globalThis: { shopify: typeof shopify }; +} + +//@ts-ignore +declare module './ext1-module-2.jsx' { + const shopify: import('@shopify/ui-extensions/admin.product-details.action.render').Api; + const globalThis: { shopify: typeof shopify }; +} +`) const ext2TypeFilePath = joinPath(ext2Dir, 'shopify.d.ts') const ext2FileContent = await readFile(ext2TypeFilePath) const normalizedExt2Content = ext2FileContent.toString().replace(/\\/g, '/') expect(normalizedExt2Content).toBe(`import '@shopify/ui-extensions';\n -declare module './ext2-module-1.jsx' { // mocked ext2 module 1 definition } -declare module './ext2-module-2.jsx' { // mocked ext2 module 2 definition }`) +//@ts-ignore +declare module './ext2-module-1.jsx' { + const shopify: import('@shopify/ui-extensions/admin.orders-details.block.render').Api; + const globalThis: { shopify: typeof shopify }; +} + +//@ts-ignore +declare module './ext2-module-2.jsx' { + const shopify: import('@shopify/ui-extensions/admin.orders-details.block.render').Api; + const globalThis: { shopify: typeof shopify }; +} +`) + }) + }) + + test('merges shared file targets across multiple UI extensions into one declaration', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const ext1Dir = joinPath(tmpDir, 'extensions', 'ext1') + const ext2Dir = joinPath(tmpDir, 'extensions', 'ext2') + const sharedDir = joinPath(tmpDir, 'shared') + + await mkdir(ext1Dir) + await mkdir(ext2Dir) + await mkdir(sharedDir) + await mkdir(joinPath(tmpDir, 'node_modules', '@shopify', 'ui-extensions', 'admin.product-details.action.render')) + await mkdir(joinPath(tmpDir, 'node_modules', '@shopify', 'ui-extensions', 'admin.orders-details.block.render')) + await writeFile( + joinPath( + tmpDir, + 'node_modules', + '@shopify', + 'ui-extensions', + 'admin.product-details.action.render', + 'index.js', + ), + '// render target', + ) + await writeFile( + joinPath(tmpDir, 'node_modules', '@shopify', 'ui-extensions', 'admin.orders-details.block.render', 'index.js'), + '// orders target', + ) + await writeFile(joinPath(sharedDir, 'utils.js'), 'export {}') + + const uiExtension1 = await testUIExtension({type: 'ui_extension', handle: 'ext1', directory: ext1Dir}) + const uiExtension2 = await testUIExtension({type: 'ui_extension', handle: 'ext2', directory: ext2Dir}) + + const app = testApp({ + directory: tmpDir, + allExtensions: [uiExtension1, uiExtension2], + }) + + vi.spyOn(uiExtension1, 'contributeToSharedTypeFile').mockImplementation(async (typeDefinitionsByFile) => { + addTypeDefinition(typeDefinitionsByFile, { + fullPath: joinPath(sharedDir, 'utils.js'), + typeFilePath: joinPath(tmpDir, 'shopify.d.ts'), + targets: ['admin.product-details.action.render'], + apiVersion: '2025-10', + }) + }) + + vi.spyOn(uiExtension2, 'contributeToSharedTypeFile').mockImplementation(async (typeDefinitionsByFile) => { + addTypeDefinition(typeDefinitionsByFile, { + fullPath: joinPath(sharedDir, 'utils.js'), + typeFilePath: joinPath(tmpDir, 'shopify.d.ts'), + targets: ['admin.orders-details.block.render'], + apiVersion: '2025-10', + }) + }) + + await app.generateExtensionTypes() + + const shopifyDtsPath = joinPath(tmpDir, 'shopify.d.ts') + const fileContent = await readFile(shopifyDtsPath) + const normalizedContent = fileContent.toString().replace(/\\/g, '/') + + expect(normalizedContent).toBe(`import '@shopify/ui-extensions';\n +//@ts-ignore +declare module './shared/utils.js' { + const shopify: + | import('@shopify/ui-extensions/admin.orders-details.block.render').Api + | import('@shopify/ui-extensions/admin.product-details.action.render').Api; + const globalThis: { shopify: typeof shopify }; +} +`) }) }) }) diff --git a/packages/app/src/cli/models/app/app.ts b/packages/app/src/cli/models/app/app.ts index 9166f25b206..d476cef2ca0 100644 --- a/packages/app/src/cli/models/app/app.ts +++ b/packages/app/src/cli/models/app/app.ts @@ -8,6 +8,7 @@ import {ExtensionSpecification, RemoteAwareExtensionSpecification} from '../exte import {AppConfigurationUsedByCli} from '../extensions/specifications/types/app_config.js' import {EditorExtensionCollectionType} from '../extensions/specifications/editor_extension_collection.js' import {UIExtensionSchema} from '../extensions/specifications/ui_extension.js' +import {renderTypeDefinitions, TypeDefinitionsByFile} from '../extensions/specifications/type-generation.js' import {CreateAppOptions, Flag} from '../../utilities/developer-platform-client.js' import {AppAccessSpecIdentifier} from '../extensions/specifications/app_config_app_access.js' import {WebhookSubscriptionSchema} from '../extensions/specifications/app_config_webhook_schemas/webhook_subscription_schema.js' @@ -558,29 +559,35 @@ export class App< } async generateExtensionTypes() { - const typeDefinitionsByFile = new Map>() + const typeDefinitionsByFile: TypeDefinitionsByFile = new Map() await Promise.all( - this.allExtensions.map((extension) => extension.contributeToSharedTypeFile(typeDefinitionsByFile)), + this.allExtensions.map((extension) => + extension.contributeToSharedTypeFile(typeDefinitionsByFile, this.directory), + ), ) - typeDefinitionsByFile.forEach((types, typeFilePath) => { - const exists = fileExistsSync(typeFilePath) - // No types to add, remove the file if it exists - if (types.size === 0) { - if (exists) { - removeFileSync(typeFilePath) + + await Promise.all( + Array.from(typeDefinitionsByFile.entries()).map(async ([typeFilePath, typeDefinitions]) => { + const types = await renderTypeDefinitions(typeDefinitions, typeFilePath) + const exists = fileExistsSync(typeFilePath) + // No types to add, remove the file if it exists + if (types.size === 0) { + if (exists) { + removeFileSync(typeFilePath) + } + return } - return - } - const originalContent = exists ? readFileSync(typeFilePath).toString() : '' - // We need this top-level import to work around the TS restriction of not allowing declaring modules with relative paths. - // This is needed to enable file-specific global type declarations. - const typeContent = [`import '@shopify/ui-extensions';\n`, ...Array.from(types)].join('\n') - if (originalContent === typeContent) { - return - } - writeFileSync(typeFilePath, typeContent) - }) + const originalContent = exists ? readFileSync(typeFilePath).toString() : '' + // We need this top-level import to work around the TS restriction of not allowing declaring modules with relative paths. + // This is needed to enable file-specific global type declarations. + const typeContent = [`import '@shopify/ui-extensions';\n`, ...Array.from(types)].join('\n') + if (originalContent === typeContent) { + return + } + writeFileSync(typeFilePath, typeContent) + }), + ) } get includeConfigOnDeploy() { diff --git a/packages/app/src/cli/models/extensions/extension-instance.ts b/packages/app/src/cli/models/extensions/extension-instance.ts index 1732badeae3..74c3f8f106a 100644 --- a/packages/app/src/cli/models/extensions/extension-instance.ts +++ b/packages/app/src/cli/models/extensions/extension-instance.ts @@ -1,5 +1,6 @@ import {BaseConfigType, MAX_EXTENSION_HANDLE_LENGTH, MAX_UID_LENGTH} from './schemas.js' import {FunctionConfigType} from './specifications/function.js' +import {TypeDefinitionsByFile} from './specifications/type-generation.js' import {ExtensionFeature, ExtensionSpecification} from './specification.js' import {SingleWebhookSubscriptionType} from './specifications/app_config_webhook_schemas/webhooks_schema.js' import {AppHomeSpecIdentifier} from './specifications/app_config_app_home.js' @@ -466,8 +467,8 @@ export class ExtensionInstance>) { - await this.specification.contributeToSharedTypeFile?.(this, typeDefinitionsByFile) + async contributeToSharedTypeFile(typeDefinitionsByFile: TypeDefinitionsByFile, appDirectory?: string) { + await this.specification.contributeToSharedTypeFile?.(this, typeDefinitionsByFile, appDirectory) } /** diff --git a/packages/app/src/cli/models/extensions/specification.ts b/packages/app/src/cli/models/extensions/specification.ts index ff794e743f3..660c7d8b06e 100644 --- a/packages/app/src/cli/models/extensions/specification.ts +++ b/packages/app/src/cli/models/extensions/specification.ts @@ -1,5 +1,6 @@ import {ZodSchemaType, BaseConfigType, BaseSchema} from './schemas.js' import {ExtensionInstance} from './extension-instance.js' +import {TypeDefinitionsByFile} from './specifications/type-generation.js' import {blocks} from '../../constants.js' import {Flag} from '../../utilities/developer-platform-client.js' @@ -123,7 +124,8 @@ export interface ExtensionSpecification, - typeDefinitionsByFile: Map>, + typeDefinitionsByFile: TypeDefinitionsByFile, + appDirectory?: string, ) => Promise /** diff --git a/packages/app/src/cli/models/extensions/specifications/type-generation.test.ts b/packages/app/src/cli/models/extensions/specifications/type-generation.test.ts index b26704b3d7c..43aa4e3c8a9 100644 --- a/packages/app/src/cli/models/extensions/specifications/type-generation.test.ts +++ b/packages/app/src/cli/models/extensions/specifications/type-generation.test.ts @@ -1,7 +1,210 @@ -import {createToolsTypeDefinition} from './type-generation.js' +import { + addTypeDefinition, + assertTargetsResolvable, + createToolsTypeDefinition, + findNearestTsConfigDir, + parseApiVersion, + renderTypeDefinitions, + TypeDefinitionsByFile, +} from './type-generation.js' import {AbortError} from '@shopify/cli-kit/node/error' +import {inTemporaryDirectory, mkdir, writeFile} from '@shopify/cli-kit/node/fs' +import {joinPath} from '@shopify/cli-kit/node/path' import {describe, expect, test} from 'vitest' +describe('parseApiVersion', () => { + test('returns parsed year and month for valid api versions', () => { + expect(parseApiVersion('2026-01')).toEqual({year: 2026, month: 1}) + }) + + test('returns null for invalid api versions', () => { + expect(parseApiVersion('2026')).toBeNull() + }) +}) + +describe('assertTargetsResolvable', () => { + test('throws using fallback api version when the api version is invalid', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const fullPath = joinPath(tmpDir, 'src', 'index.ts') + const typeFilePath = joinPath(tmpDir, 'shopify.d.ts') + + await mkdir(joinPath(tmpDir, 'src')) + await writeFile(fullPath, 'export {}') + + expect(() => + assertTargetsResolvable({ + fullPath, + typeFilePath, + targets: ['admin.unknown.action.render'], + apiVersion: 'invalid', + }), + ).toThrow( + new AbortError( + 'Type reference for admin.unknown.action.render could not be found. You might be using the wrong @shopify/ui-extensions version.', + 'Fix the error by ensuring you have the correct version of @shopify/ui-extensions, for example ~2025.10.0, in your dependencies.', + ), + ) + }) + }) +}) + +describe('shared type generation helpers', () => { + test('merges targets and prefers the newest valid api version', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const typeDefinitionsByFile: TypeDefinitionsByFile = new Map() + const fullPath = joinPath(tmpDir, 'shared', 'utils.ts') + const typeFilePath = joinPath(tmpDir, 'shopify.d.ts') + + await mkdir(joinPath(tmpDir, 'shared')) + await mkdir(joinPath(tmpDir, 'node_modules', '@shopify', 'ui-extensions', 'admin.product-details.action.render')) + await mkdir(joinPath(tmpDir, 'node_modules', '@shopify', 'ui-extensions', 'admin.orders-details.block.render')) + await writeFile(fullPath, 'export {}') + await writeFile( + joinPath( + tmpDir, + 'node_modules', + '@shopify', + 'ui-extensions', + 'admin.product-details.action.render', + 'index.js', + ), + '// product details target', + ) + await writeFile( + joinPath(tmpDir, 'node_modules', '@shopify', 'ui-extensions', 'admin.orders-details.block.render', 'index.js'), + '// order details target', + ) + + addTypeDefinition(typeDefinitionsByFile, { + fullPath, + typeFilePath, + targets: ['admin.product-details.action.render'], + apiVersion: '2025-07', + }) + + addTypeDefinition(typeDefinitionsByFile, { + fullPath, + typeFilePath, + targets: ['admin.orders-details.block.render'], + apiVersion: '2026-01', + toolsTypeDefinition: 'interface ShopifyTools {}', + }) + + addTypeDefinition(typeDefinitionsByFile, { + fullPath, + typeFilePath, + targets: ['admin.product-details.action.render'], + apiVersion: 'invalid', + }) + + const storedDefinition = typeDefinitionsByFile.get(typeFilePath)?.get(fullPath) as any + expect(storedDefinition.apiVersion).toBe('2026-01') + expect(Array.from(storedDefinition.targets)).toEqual([ + 'admin.product-details.action.render', + 'admin.orders-details.block.render', + ]) + expect(storedDefinition.toolsTypeDefinition).toBe('interface ShopifyTools {}') + + const renderedDefinitions = Array.from( + await renderTypeDefinitions(typeDefinitionsByFile.get(typeFilePath) ?? new Map(), typeFilePath), + ) + + expect(renderedDefinitions).toHaveLength(1) + expect(renderedDefinitions[0]).toContain("declare module './shared/utils.ts'") + expect(renderedDefinitions[0]).toContain('interface ShopifyTools {}') + expect(renderedDefinitions[0]).toContain( + "| import('@shopify/ui-extensions/admin.orders-details.block.render').Api", + ) + expect(renderedDefinitions[0]).toContain( + "| import('@shopify/ui-extensions/admin.product-details.action.render').Api", + ) + expect(renderedDefinitions[0]).toContain(') & { tools: ShopifyTools };') + }) + }) + + test('sorts rendered definitions and filters files without targets', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const typeDefinitionsByFile: TypeDefinitionsByFile = new Map() + const typeFilePath = joinPath(tmpDir, 'shopify.d.ts') + + await mkdir(joinPath(tmpDir, 'shared')) + await mkdir(joinPath(tmpDir, 'node_modules', '@shopify', 'ui-extensions', 'admin.product-details.action.render')) + await writeFile(joinPath(tmpDir, 'shared', 'a.ts'), 'export {}') + await writeFile(joinPath(tmpDir, 'shared', 'z.ts'), 'export {}') + await writeFile( + joinPath( + tmpDir, + 'node_modules', + '@shopify', + 'ui-extensions', + 'admin.product-details.action.render', + 'index.js', + ), + '// product details target', + ) + + addTypeDefinition(typeDefinitionsByFile, { + fullPath: joinPath(tmpDir, 'shared', 'z.ts'), + typeFilePath, + targets: ['admin.product-details.action.render'], + apiVersion: '2025-10', + }) + + addTypeDefinition(typeDefinitionsByFile, { + fullPath: joinPath(tmpDir, 'shared', 'ignored.ts'), + typeFilePath, + targets: [], + apiVersion: '2025-10', + }) + + addTypeDefinition(typeDefinitionsByFile, { + fullPath: joinPath(tmpDir, 'shared', 'a.ts'), + typeFilePath, + targets: ['admin.product-details.action.render'], + apiVersion: '2025-10', + }) + + const renderedDefinitions = Array.from( + await renderTypeDefinitions(typeDefinitionsByFile.get(typeFilePath) ?? new Map(), typeFilePath), + ) + + expect(renderedDefinitions).toHaveLength(2) + expect(renderedDefinitions[0]).toContain("declare module './shared/a.ts'") + expect(renderedDefinitions[1]).toContain("declare module './shared/z.ts'") + }) + }) +}) + +describe('findNearestTsConfigDir', () => { + test('returns the nearest tsconfig within the app directory', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const appDir = joinPath(tmpDir, 'app') + const sharedDir = joinPath(appDir, 'shared') + const sourceFile = joinPath(sharedDir, 'utils.ts') + + await mkdir(sharedDir) + await writeFile(joinPath(appDir, 'tsconfig.json'), '{}') + await writeFile(sourceFile, 'export {}') + + await expect(findNearestTsConfigDir(sourceFile, appDir)).resolves.toBe(appDir) + }) + }) + + test('does not return a tsconfig outside the app directory', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const appDir = joinPath(tmpDir, 'app') + const sharedDir = joinPath(appDir, 'shared') + const sourceFile = joinPath(sharedDir, 'utils.ts') + + await mkdir(sharedDir) + await writeFile(joinPath(tmpDir, 'tsconfig.json'), '{}') + await writeFile(sourceFile, 'export {}') + + await expect(findNearestTsConfigDir(sourceFile, appDir)).resolves.toBeUndefined() + }) + }) +}) + describe('createToolsTypeDefinition', () => { test('returns empty string when tools array is empty', async () => { // When diff --git a/packages/app/src/cli/models/extensions/specifications/type-generation.ts b/packages/app/src/cli/models/extensions/specifications/type-generation.ts index 2dc8b70743e..70e43f5ee50 100644 --- a/packages/app/src/cli/models/extensions/specifications/type-generation.ts +++ b/packages/app/src/cli/models/extensions/specifications/type-generation.ts @@ -1,5 +1,6 @@ +import {formatContent} from '../../../utilities/file-formatter.js' import {fileExists, findPathUp, readFileSync} from '@shopify/cli-kit/node/fs' -import {dirname, joinPath, relativizePath, resolvePath} from '@shopify/cli-kit/node/path' +import {dirname, isSubpath, joinPath, relativizePath, resolvePath} from '@shopify/cli-kit/node/path' import {AbortError} from '@shopify/cli-kit/node/error' import ts from 'typescript' import {compile} from 'json-schema-to-typescript' @@ -165,6 +166,14 @@ interface CreateTypeDefinitionOptions { toolsTypeDefinition?: string } +interface SharedTypeDefinition { + apiVersion: string + targets: Set + toolsTypeDefinition?: string +} + +export type TypeDefinitionsByFile = Map> + /** * Builds the shopify API type based on targets and optional tools type. * Returns null if no targets are provided. @@ -185,7 +194,7 @@ function buildShopifyType(targets: string[], toolsTypeDefinition?: string): stri return null } -export function createTypeDefinition({ +function createTypeDefinition({ fullPath, typeFilePath, targets, @@ -193,19 +202,7 @@ export function createTypeDefinition({ toolsTypeDefinition, }: CreateTypeDefinitionOptions): string | null { try { - // Validate that all targets can be resolved - for (const target of targets) { - try { - require.resolve(`@shopify/ui-extensions/${target}`, {paths: [fullPath, typeFilePath]}) - } catch (_) { - const {year, month} = parseApiVersion(apiVersion) ?? {year: 2025, month: 10} - // Throw specific error for the target that failed, matching the original getSharedTypeDefinition behavior - throw new AbortError( - `Type reference for ${target} could not be found. You might be using the wrong @shopify/ui-extensions version.`, - `Fix the error by ensuring you have the correct version of @shopify/ui-extensions, for example ~${year}.${month}.0, in your dependencies.`, - ) - } - } + assertTargetsResolvable({fullPath, typeFilePath, targets, apiVersion}) const relativePath = relativizePath(fullPath, dirname(typeFilePath)) @@ -236,24 +233,103 @@ export function createTypeDefinition({ } } -export async function findNearestTsConfigDir( - fromFile: string, - extensionDirectory: string, -): Promise { +export function assertTargetsResolvable({ + fullPath, + typeFilePath, + targets, + apiVersion, +}: Omit) { + for (const target of targets) { + try { + require.resolve(`@shopify/ui-extensions/${target}`, {paths: [fullPath, typeFilePath]}) + } catch (_) { + const {year, month} = parseApiVersion(apiVersion) ?? {year: 2025, month: 10} + throw new AbortError( + `Type reference for ${target} could not be found. You might be using the wrong @shopify/ui-extensions version.`, + `Fix the error by ensuring you have the correct version of @shopify/ui-extensions, for example ~${year}.${month}.0, in your dependencies.`, + ) + } + } +} + +export function addTypeDefinition( + typeDefinitionsByFile: TypeDefinitionsByFile, + {fullPath, typeFilePath, targets, apiVersion, toolsTypeDefinition}: CreateTypeDefinitionOptions, +) { + const currentTypeDefinitions = typeDefinitionsByFile.get(typeFilePath) ?? new Map() + const existingTypeDefinition = currentTypeDefinitions.get(fullPath) + + if (existingTypeDefinition) { + targets.forEach((target) => existingTypeDefinition.targets.add(target)) + existingTypeDefinition.toolsTypeDefinition ??= toolsTypeDefinition + if (isApiVersionNewer(apiVersion, existingTypeDefinition.apiVersion)) { + existingTypeDefinition.apiVersion = apiVersion + } + } else { + currentTypeDefinitions.set(fullPath, { + apiVersion, + targets: new Set(targets), + toolsTypeDefinition, + }) + } + + typeDefinitionsByFile.set(typeFilePath, currentTypeDefinitions) +} + +export async function renderTypeDefinitions( + typeDefinitionsByPath: Map, + typeFilePath: string, +): Promise> { + const sortedTypeDefinitions = Array.from(typeDefinitionsByPath.entries()).sort(([left], [right]) => + left.localeCompare(right), + ) + const renderedTypeDefinitions = await Promise.all( + sortedTypeDefinitions.map(async ([fullPath, definition]) => { + const typeDefinition = createTypeDefinition({ + fullPath, + typeFilePath, + targets: Array.from(definition.targets).sort(), + apiVersion: definition.apiVersion, + toolsTypeDefinition: definition.toolsTypeDefinition, + }) + + if (!typeDefinition) return undefined + return formatContent(typeDefinition, {parser: 'typescript', singleQuote: true}) + }), + ) + + return new Set(renderedTypeDefinitions.filter((typeDefinition): typeDefinition is string => Boolean(typeDefinition))) +} + +export async function findNearestTsConfigDir(fromFile: string, appDirectory: string): Promise { const fromDirectory = dirname(fromFile) const tsconfigPath = await findPathUp('tsconfig.json', {cwd: fromDirectory, type: 'file'}) if (tsconfigPath) { - // Normalize both paths for cross-platform comparison - const normalizedTsconfigPath = resolvePath(tsconfigPath) - const normalizedExtensionDirectory = resolvePath(extensionDirectory) + const normalizedTsconfigDirectory = resolvePath(dirname(tsconfigPath)) + const normalizedAppDirectory = resolvePath(appDirectory) - if (normalizedTsconfigPath.startsWith(normalizedExtensionDirectory)) { - return dirname(tsconfigPath) + if (isSubpath(normalizedAppDirectory, normalizedTsconfigDirectory)) { + return normalizedTsconfigDirectory } } } +function isApiVersionNewer(nextVersion: string, currentVersion: string) { + const next = parseApiVersion(nextVersion) + const current = parseApiVersion(currentVersion) + + if (!next || !current) { + return false + } + + if (next.year !== current.year) { + return next.year > current.year + } + + return next.month > current.month +} + interface ToolDefinition { name: string description: string diff --git a/packages/app/src/cli/models/extensions/specifications/ui_extension.test.ts b/packages/app/src/cli/models/extensions/specifications/ui_extension.test.ts index c2dfcccb1d7..9ef8df4a263 100644 --- a/packages/app/src/cli/models/extensions/specifications/ui_extension.test.ts +++ b/packages/app/src/cli/models/extensions/specifications/ui_extension.test.ts @@ -1,4 +1,5 @@ import {getShouldRenderTarget} from './ui_extension.js' +import {renderTypeDefinitions, TypeDefinitionsByFile} from './type-generation.js' import * as loadLocales from '../../../utilities/extensions/locales-configuration.js' import {ExtensionInstance} from '../extension-instance.js' import {loadLocalExtensionsSpecifications} from '../load-specifications.js' @@ -1385,9 +1386,13 @@ Please check the configuration in ${joinPath(tmpDir, 'shopify.extension.toml')}` } } + async function getRenderedTypeDefinitions(typeDefinitionsByFile: TypeDefinitionsByFile, typeFilePath: string) { + return Array.from(await renderTypeDefinitions(typeDefinitionsByFile.get(typeFilePath) ?? new Map(), typeFilePath)) + } + describe('contributeToSharedTypeFile', () => { test('sets the typeDefinitionsByFile map for both main and should-render modules when api version supports Remote DOM', async () => { - const typeDefinitionsByFile = new Map>() + const typeDefinitionsByFile: TypeDefinitionsByFile = new Map() await inTemporaryDirectory(async (tmpDir) => { const {extension} = await setupUIExtensionWithNodeModules({ @@ -1405,30 +1410,22 @@ Please check the configuration in ${joinPath(tmpDir, 'shopify.extension.toml')}` await extension.contributeToSharedTypeFile?.(typeDefinitionsByFile) const shopifyDtsPath = joinPath(tmpDir, 'shopify.d.ts') + const types = await getRenderedTypeDefinitions(typeDefinitionsByFile, shopifyDtsPath) // Then - expect(typeDefinitionsByFile).toStrictEqual( - new Map([ - [ - shopifyDtsPath, - new Set([ - `//@ts-ignore\ndeclare module './src/index.jsx' { + expect(types).toContain(`//@ts-ignore\ndeclare module './src/index.jsx' { const shopify: import('@shopify/ui-extensions/admin.product-details.action.render').Api; const globalThis: { shopify: typeof shopify }; -}\n`, - `//@ts-ignore\ndeclare module './src/condition/should-render.js' { +}\n`) + expect(types).toContain(`//@ts-ignore\ndeclare module './src/condition/should-render.js' { const shopify: import('@shopify/ui-extensions/admin.product-details.action.should-render').Api; const globalThis: { shopify: typeof shopify }; -}\n`, - ]), - ], - ]), - ) +}\n`) }) }) test('supports individual and shared tsconfig.json files when api version supports Remote DOM', async () => { - const typeDefinitionsByFile = new Map>() + const typeDefinitionsByFile: TypeDefinitionsByFile = new Map() await inTemporaryDirectory(async (tmpDir) => { const {extension, nodeModulesPath} = await setupUIExtensionWithNodeModules({ @@ -1468,37 +1465,29 @@ Please check the configuration in ${joinPath(tmpDir, 'shopify.extension.toml')}` await extension.contributeToSharedTypeFile?.(typeDefinitionsByFile) // Then - expect(typeDefinitionsByFile).toStrictEqual( - new Map([ - [ - joinPath(tmpDir, 'shopify.d.ts'), - new Set([ - `//@ts-ignore\ndeclare module './src/index.jsx' { + const rootTypes = await getRenderedTypeDefinitions(typeDefinitionsByFile, joinPath(tmpDir, 'shopify.d.ts')) + const shouldRenderTypes = await getRenderedTypeDefinitions( + typeDefinitionsByFile, + joinPath(tmpDir, 'src', 'condition', 'shopify.d.ts'), + ) + + expect(rootTypes).toContain(`//@ts-ignore\ndeclare module './src/index.jsx' { const shopify: import('@shopify/ui-extensions/admin.product-details.action.render').Api; const globalThis: { shopify: typeof shopify }; -}\n`, - `//@ts-ignore\ndeclare module './src/another-target-module.jsx' { +}\n`) + expect(rootTypes).toContain(`//@ts-ignore\ndeclare module './src/another-target-module.jsx' { const shopify: import('@shopify/ui-extensions/admin.orders-details.block.render').Api; const globalThis: { shopify: typeof shopify }; -}\n`, - ]), - ], - [ - joinPath(tmpDir, 'src', 'condition', 'shopify.d.ts'), - new Set([ - `//@ts-ignore\ndeclare module './should-render.js' { +}\n`) + expect(shouldRenderTypes).toContain(`//@ts-ignore\ndeclare module './should-render.js' { const shopify: import('@shopify/ui-extensions/admin.product-details.action.should-render').Api; const globalThis: { shopify: typeof shopify }; -}\n`, - ]), - ], - ]), - ) +}\n`) }) }) test("throws error when when api version supports Remote DOM and there's a tsconfig.json but type reference for target could not be found", async () => { - const typeDefinitionsByFile = new Map>() + const typeDefinitionsByFile: TypeDefinitionsByFile = new Map() await inTemporaryDirectory(async (tmpDir) => { const {extension} = await setupUIExtensionWithNodeModules({ @@ -1529,7 +1518,7 @@ Please check the configuration in ${joinPath(tmpDir, 'shopify.extension.toml')}` }) test('does not throw error when when api version supports Remote DOM but there is no tsconfig.json', async () => { - const typeDefinitionsByFile = new Map>() + const typeDefinitionsByFile: TypeDefinitionsByFile = new Map() await inTemporaryDirectory(async (tmpDir) => { const {extension} = await setupUIExtensionWithNodeModules({ @@ -1551,7 +1540,7 @@ Please check the configuration in ${joinPath(tmpDir, 'shopify.extension.toml')}` }) test('does not set the typeDefinitionsByFile map when api version does not support Remote DOM', async () => { - const typeDefinitionsByFile = new Map>() + const typeDefinitionsByFile: TypeDefinitionsByFile = new Map() await inTemporaryDirectory(async (tmpDir) => { const {extension} = await setupUIExtensionWithNodeModules({ tmpDir, @@ -1572,7 +1561,7 @@ Please check the configuration in ${joinPath(tmpDir, 'shopify.extension.toml')}` }) test('generates types for imported modules when extension has single target', async () => { - const typeDefinitionsByFile = new Map>() + const typeDefinitionsByFile: TypeDefinitionsByFile = new Map() await inTemporaryDirectory(async (tmpDir) => { const {extension} = await setupUIExtensionWithNodeModules({ @@ -1600,19 +1589,20 @@ Please check the configuration in ${joinPath(tmpDir, 'shopify.extension.toml')}` await extension.contributeToSharedTypeFile?.(typeDefinitionsByFile) const shopifyDtsPath = joinPath(tmpDir, 'shopify.d.ts') + const types = await getRenderedTypeDefinitions(typeDefinitionsByFile, shopifyDtsPath) // Then - should include types for imported modules when single target - expect(Array.from(typeDefinitionsByFile.get(shopifyDtsPath) ?? [])).toContain( + expect(types).toContain( `//@ts-ignore\ndeclare module './src/utils/helper.js' {\n const shopify: import('@shopify/ui-extensions/admin.product-details.action.render').Api;\n const globalThis: { shopify: typeof shopify };\n}\n`, ) - expect(Array.from(typeDefinitionsByFile.get(shopifyDtsPath) ?? [])).toContain( + expect(types).toContain( `//@ts-ignore\ndeclare module './src/components/Button.jsx' {\n const shopify: import('@shopify/ui-extensions/admin.product-details.action.render').Api;\n const globalThis: { shopify: typeof shopify };\n}\n`, ) }) }) test('generates union types for shared modules when extension has multiple targets', async () => { - const typeDefinitionsByFile = new Map>() + const typeDefinitionsByFile: TypeDefinitionsByFile = new Map() await inTemporaryDirectory(async (tmpDir) => { const {extension, nodeModulesPath} = await setupUIExtensionWithNodeModules({ @@ -1661,17 +1651,17 @@ Please check the configuration in ${joinPath(tmpDir, 'shopify.extension.toml')}` await extension.contributeToSharedTypeFile?.(typeDefinitionsByFile) const shopifyDtsPath = joinPath(tmpDir, 'shopify.d.ts') - const types = typeDefinitionsByFile.get(shopifyDtsPath) + const types = await getRenderedTypeDefinitions(typeDefinitionsByFile, shopifyDtsPath) // Then - should generate union type for shared module - expect(Array.from(types ?? [])).toContain( - `//@ts-ignore\ndeclare module './shared/utils.js' {\n const shopify:\n | import('@shopify/ui-extensions/admin.product-details.action.render').Api\n | import('@shopify/ui-extensions/admin.orders-details.block.render').Api;\n const globalThis: { shopify: typeof shopify };\n}\n`, + expect(types).toContain( + `//@ts-ignore\ndeclare module './shared/utils.js' {\n const shopify:\n | import('@shopify/ui-extensions/admin.orders-details.block.render').Api\n | import('@shopify/ui-extensions/admin.product-details.action.render').Api;\n const globalThis: { shopify: typeof shopify };\n}\n`, ) }) }) test('generates non-target-specific types for all files when extension has multiple targets from different surfaces', async () => { - const typeDefinitionsByFile = new Map>() + const typeDefinitionsByFile: TypeDefinitionsByFile = new Map() await inTemporaryDirectory(async (tmpDir) => { const {extension, nodeModulesPath} = await setupUIExtensionWithNodeModules({ @@ -1721,7 +1711,7 @@ Please check the configuration in ${joinPath(tmpDir, 'shopify.extension.toml')}` await extension.contributeToSharedTypeFile?.(typeDefinitionsByFile) const shopifyDtsPath = joinPath(tmpDir, 'shopify.d.ts') - const types = Array.from(typeDefinitionsByFile.get(shopifyDtsPath) ?? []) + const types = await getRenderedTypeDefinitions(typeDefinitionsByFile, shopifyDtsPath) // Then - should generate union types for shared files // when targets are from different surfaces (admin vs checkout) @@ -1732,7 +1722,7 @@ Please check the configuration in ${joinPath(tmpDir, 'shopify.extension.toml')}` }) test('handles TypeScript path mapping aliases when resolving imports', async () => { - const typeDefinitionsByFile = new Map>() + const typeDefinitionsByFile: TypeDefinitionsByFile = new Map() await inTemporaryDirectory(async (tmpDir) => { const {extension} = await setupUIExtensionWithNodeModules({ @@ -1773,19 +1763,20 @@ Please check the configuration in ${joinPath(tmpDir, 'shopify.extension.toml')}` await extension.contributeToSharedTypeFile?.(typeDefinitionsByFile) const shopifyDtsPath = joinPath(tmpDir, 'shopify.d.ts') + const types = await getRenderedTypeDefinitions(typeDefinitionsByFile, shopifyDtsPath) // Then - should resolve aliased imports and include types - expect(Array.from(typeDefinitionsByFile.get(shopifyDtsPath) ?? [])).toContain( + expect(types).toContain( `//@ts-ignore\ndeclare module './src/utils/helper.js' {\n const shopify: import('@shopify/ui-extensions/admin.product-details.action.render').Api;\n const globalThis: { shopify: typeof shopify };\n}\n`, ) - expect(Array.from(typeDefinitionsByFile.get(shopifyDtsPath) ?? [])).toContain( + expect(types).toContain( `//@ts-ignore\ndeclare module './src/components/Button.jsx' {\n const shopify: import('@shopify/ui-extensions/admin.product-details.action.render').Api;\n const globalThis: { shopify: typeof shopify };\n}\n`, ) }) }) test('generates shopify.d.ts in the extension directory when importing files outside extension directory', async () => { - const typeDefinitionsByFile = new Map>() + const typeDefinitionsByFile: TypeDefinitionsByFile = new Map() await inTemporaryDirectory(async (tmpDir) => { const extensionDir = joinPath(tmpDir, 'extensions', 'extension') @@ -1848,17 +1839,84 @@ Please check the configuration in ${joinPath(tmpDir, 'shopify.extension.toml')}` const extensionShopifyDtsPath = joinPath(extensionDir, 'shopify.d.ts') expect(typeDefinitionsByFile.has(extensionShopifyDtsPath)).toBe(true) - const extensionTypes = typeDefinitionsByFile.get(extensionShopifyDtsPath) - expect(Array.from(extensionTypes ?? [])).toContain( + const extensionTypes = await getRenderedTypeDefinitions(typeDefinitionsByFile, extensionShopifyDtsPath) + expect(extensionTypes).toContain( `//@ts-ignore\ndeclare module './src/index.jsx' {\n const shopify: import('@shopify/ui-extensions/admin.product-details.action.render').Api;\n const globalThis: { shopify: typeof shopify };\n}\n`, ) - expect(Array.from(extensionTypes ?? [])).not.toContain(expect.stringContaining('helpers/utils.ts')) + expect(extensionTypes).not.toContain(expect.stringContaining('helpers/utils.ts')) + }) + }) + + test('generates shopify.d.ts in the app directory for shared workspace files', async () => { + const typeDefinitionsByFile: TypeDefinitionsByFile = new Map() + + await inTemporaryDirectory(async (tmpDir) => { + const extensionDir = joinPath(tmpDir, 'extensions', 'extension') + const helpersDir = joinPath(tmpDir, 'shared') + const srcDir = joinPath(extensionDir, 'src') + + await mkdir(extensionDir) + await mkdir(helpersDir) + await mkdir(srcDir) + + await writeFile(joinPath(helpersDir, 'utils.ts'), 'export const helper = () => {};') + + const extensionContent = `import { helper } from '../../../shared/utils.ts';\n// Extension code` + await writeFile(joinPath(srcDir, 'index.jsx'), extensionContent) + + const nodeModulesPath = joinPath(tmpDir, 'node_modules', '@shopify', 'ui-extensions') + await mkdir(nodeModulesPath) + const targetPath = joinPath(nodeModulesPath, 'admin.product-details.action.render') + await mkdir(targetPath) + await writeFile(joinPath(targetPath, 'index.js'), '// Mock UI extension target') + + await writeFile(joinPath(tmpDir, 'tsconfig.json'), '{}') + await writeFile(joinPath(extensionDir, 'tsconfig.json'), '{}') + + const allSpecs = await loadLocalExtensionsSpecifications() + const specification = allSpecs.find((spec) => spec.identifier === 'ui_extension')! + + const extension = new ExtensionInstance({ + configuration: { + api_version: '2025-10', + extension_points: [ + { + target: 'admin.product-details.action.render', + module: `./src/index.jsx`, + build_manifest: { + assets: { + main: { + module: './src/index.jsx', + }, + }, + }, + }, + ], + name: 'Test UI Extension', + type: 'ui_extension', + metafields: [], + }, + configurationPath: joinPath(extensionDir, 'shopify.extension.toml'), + directory: extensionDir, + specification, + entryPath: joinPath(srcDir, 'index.jsx'), + }) + + await extension.contributeToSharedTypeFile?.(typeDefinitionsByFile, tmpDir) + + const rootShopifyDtsPath = joinPath(tmpDir, 'shopify.d.ts') + expect(typeDefinitionsByFile.has(rootShopifyDtsPath)).toBe(true) + + const rootTypes = await getRenderedTypeDefinitions(typeDefinitionsByFile, rootShopifyDtsPath) + expect(rootTypes).toContain( + `//@ts-ignore\ndeclare module './shared/utils.ts' {\n const shopify: import('@shopify/ui-extensions/admin.product-details.action.render').Api;\n const globalThis: { shopify: typeof shopify };\n}\n`, + ) }) }) test('generates type definitions for files imported from extension root directory', async () => { - const typeDefinitionsByFile = new Map>() + const typeDefinitionsByFile: TypeDefinitionsByFile = new Map() await inTemporaryDirectory(async (tmpDir) => { const extensionDir = tmpDir @@ -1920,20 +1978,20 @@ Please check the configuration in ${joinPath(tmpDir, 'shopify.extension.toml')}` await extension.contributeToSharedTypeFile?.(typeDefinitionsByFile) const shopifyDtsPath = joinPath(extensionDir, 'shopify.d.ts') - const types = typeDefinitionsByFile.get(shopifyDtsPath) + const types = await getRenderedTypeDefinitions(typeDefinitionsByFile, shopifyDtsPath) // Then - should include type definition for both the main file and the root-level shared file - expect(Array.from(types ?? [])).toContain( + expect(types).toContain( `//@ts-ignore\ndeclare module './src/extension.ts' {\n const shopify: import('@shopify/ui-extensions/admin.product-details.action.render').Api;\n const globalThis: { shopify: typeof shopify };\n}\n`, ) - expect(Array.from(types ?? [])).toContain( + expect(types).toContain( `//@ts-ignore\ndeclare module './shared_file.ts' {\n const shopify: import('@shopify/ui-extensions/admin.product-details.action.render').Api;\n const globalThis: { shopify: typeof shopify };\n}\n`, ) }) }) test('handles complex directory structure with root-level imports and nested files', async () => { - const typeDefinitionsByFile = new Map>() + const typeDefinitionsByFile: TypeDefinitionsByFile = new Map() await inTemporaryDirectory(async (tmpDir) => { const extensionDir = tmpDir @@ -2015,7 +2073,7 @@ Please check the configuration in ${joinPath(tmpDir, 'shopify.extension.toml')}` await extension.contributeToSharedTypeFile?.(typeDefinitionsByFile) const shopifyDtsPath = joinPath(extensionDir, 'shopify.d.ts') - const types = Array.from(typeDefinitionsByFile.get(shopifyDtsPath) ?? []) + const types = await getRenderedTypeDefinitions(typeDefinitionsByFile, shopifyDtsPath) // Then - should include type definitions for all files: // main file, component, and both root-level shared files @@ -2035,7 +2093,7 @@ Please check the configuration in ${joinPath(tmpDir, 'shopify.extension.toml')}` }) test('generates type definitions for chained imports: extension → component → root-level shared file', async () => { - const typeDefinitionsByFile = new Map>() + const typeDefinitionsByFile: TypeDefinitionsByFile = new Map() await inTemporaryDirectory(async (tmpDir) => { const extensionDir = tmpDir @@ -2119,7 +2177,7 @@ Please check the configuration in ${joinPath(tmpDir, 'shopify.extension.toml')}` await extension.contributeToSharedTypeFile?.(typeDefinitionsByFile) const shopifyDtsPath = joinPath(extensionDir, 'shopify.d.ts') - const types = Array.from(typeDefinitionsByFile.get(shopifyDtsPath) ?? []) + const types = await getRenderedTypeDefinitions(typeDefinitionsByFile, shopifyDtsPath) // Then - should include type definitions for all files in the chain: // 1. Main extension file @@ -2143,7 +2201,7 @@ Please check the configuration in ${joinPath(tmpDir, 'shopify.extension.toml')}` }) test('generates shopify.d.ts with ShopifyTools interface when tools file is present', async () => { - const typeDefinitionsByFile = new Map>() + const typeDefinitionsByFile: TypeDefinitionsByFile = new Map() await inTemporaryDirectory(async (tmpDir) => { const {extension} = await setupUIExtensionWithNodeModules({ @@ -2184,7 +2242,7 @@ Please check the configuration in ${joinPath(tmpDir, 'shopify.extension.toml')}` await extension.contributeToSharedTypeFile?.(typeDefinitionsByFile) const shopifyDtsPath = joinPath(tmpDir, 'shopify.d.ts') - const types = Array.from(typeDefinitionsByFile.get(shopifyDtsPath) ?? []) + const types = await getRenderedTypeDefinitions(typeDefinitionsByFile, shopifyDtsPath) // Then - should include ShopifyTools interface and tool type definitions expect(types).toHaveLength(1) @@ -2198,7 +2256,7 @@ Please check the configuration in ${joinPath(tmpDir, 'shopify.extension.toml')}` }) test('generates shopify.d.ts with multiple tools in ShopifyTools interface', async () => { - const typeDefinitionsByFile = new Map>() + const typeDefinitionsByFile: TypeDefinitionsByFile = new Map() await inTemporaryDirectory(async (tmpDir) => { const {extension} = await setupUIExtensionWithNodeModules({ @@ -2252,7 +2310,7 @@ Please check the configuration in ${joinPath(tmpDir, 'shopify.extension.toml')}` await extension.contributeToSharedTypeFile?.(typeDefinitionsByFile) const shopifyDtsPath = joinPath(tmpDir, 'shopify.d.ts') - const types = Array.from(typeDefinitionsByFile.get(shopifyDtsPath) ?? []) + const types = await getRenderedTypeDefinitions(typeDefinitionsByFile, shopifyDtsPath) // Then - should include type definitions for both tools expect(types).toHaveLength(1) @@ -2268,7 +2326,7 @@ Please check the configuration in ${joinPath(tmpDir, 'shopify.extension.toml')}` }) test('does not include ShopifyTools when tools file does not exist', async () => { - const typeDefinitionsByFile = new Map>() + const typeDefinitionsByFile: TypeDefinitionsByFile = new Map() await inTemporaryDirectory(async (tmpDir) => { const {extension} = await setupUIExtensionWithNodeModules({ @@ -2287,7 +2345,7 @@ Please check the configuration in ${joinPath(tmpDir, 'shopify.extension.toml')}` await extension.contributeToSharedTypeFile?.(typeDefinitionsByFile) const shopifyDtsPath = joinPath(tmpDir, 'shopify.d.ts') - const types = Array.from(typeDefinitionsByFile.get(shopifyDtsPath) ?? []) + const types = await getRenderedTypeDefinitions(typeDefinitionsByFile, shopifyDtsPath) // Then - should generate type definition without ShopifyTools expect(types).toHaveLength(1) @@ -2298,7 +2356,7 @@ Please check the configuration in ${joinPath(tmpDir, 'shopify.extension.toml')}` }) test('does not include ShopifyTools when tools file has invalid JSON', async () => { - const typeDefinitionsByFile = new Map>() + const typeDefinitionsByFile: TypeDefinitionsByFile = new Map() await inTemporaryDirectory(async (tmpDir) => { const {extension} = await setupUIExtensionWithNodeModules({ @@ -2320,7 +2378,7 @@ Please check the configuration in ${joinPath(tmpDir, 'shopify.extension.toml')}` await extension.contributeToSharedTypeFile?.(typeDefinitionsByFile) const shopifyDtsPath = joinPath(tmpDir, 'shopify.d.ts') - const types = Array.from(typeDefinitionsByFile.get(shopifyDtsPath) ?? []) + const types = await getRenderedTypeDefinitions(typeDefinitionsByFile, shopifyDtsPath) // Then - should generate type definition without ShopifyTools (graceful fallback) expect(types).toHaveLength(1) @@ -2330,7 +2388,7 @@ Please check the configuration in ${joinPath(tmpDir, 'shopify.extension.toml')}` }) test('does not include ShopifyTools when tools file has invalid schema', async () => { - const typeDefinitionsByFile = new Map>() + const typeDefinitionsByFile: TypeDefinitionsByFile = new Map() await inTemporaryDirectory(async (tmpDir) => { const {extension} = await setupUIExtensionWithNodeModules({ @@ -2358,7 +2416,7 @@ Please check the configuration in ${joinPath(tmpDir, 'shopify.extension.toml')}` await extension.contributeToSharedTypeFile?.(typeDefinitionsByFile) const shopifyDtsPath = joinPath(tmpDir, 'shopify.d.ts') - const types = Array.from(typeDefinitionsByFile.get(shopifyDtsPath) ?? []) + const types = await getRenderedTypeDefinitions(typeDefinitionsByFile, shopifyDtsPath) // Then - should generate type definition without ShopifyTools (graceful fallback) expect(types).toHaveLength(1) @@ -2368,7 +2426,7 @@ Please check the configuration in ${joinPath(tmpDir, 'shopify.extension.toml')}` }) test('generates ShopifyTools only for entry point file, not for imported files', async () => { - const typeDefinitionsByFile = new Map>() + const typeDefinitionsByFile: TypeDefinitionsByFile = new Map() await inTemporaryDirectory(async (tmpDir) => { const {extension} = await setupUIExtensionWithNodeModules({ @@ -2405,7 +2463,7 @@ Please check the configuration in ${joinPath(tmpDir, 'shopify.extension.toml')}` await extension.contributeToSharedTypeFile?.(typeDefinitionsByFile) const shopifyDtsPath = joinPath(tmpDir, 'shopify.d.ts') - const types = Array.from(typeDefinitionsByFile.get(shopifyDtsPath) ?? []) + const types = await getRenderedTypeDefinitions(typeDefinitionsByFile, shopifyDtsPath) // Then - should have 2 type definitions (entry point and helper) expect(types).toHaveLength(2) diff --git a/packages/app/src/cli/models/extensions/specifications/ui_extension.ts b/packages/app/src/cli/models/extensions/specifications/ui_extension.ts index f3e04cbeab9..4dfcc857e51 100644 --- a/packages/app/src/cli/models/extensions/specifications/ui_extension.ts +++ b/packages/app/src/cli/models/extensions/specifications/ui_extension.ts @@ -1,6 +1,7 @@ import { + addTypeDefinition, + assertTargetsResolvable, findAllImportedFiles, - createTypeDefinition, findNearestTsConfigDir, parseApiVersion, createToolsTypeDefinition, @@ -11,7 +12,6 @@ import {NewExtensionPointSchemaType, NewExtensionPointsSchema, BaseSchema, Metaf import {loadLocalesConfig} from '../../../utilities/extensions/locales-configuration.js' import {getExtensionPointTargetSurface} from '../../../services/dev/extension/utilities.js' import {ExtensionInstance} from '../extension-instance.js' -import {formatContent} from '../../../utilities/file-formatter.js' import {err, ok, Result} from '@shopify/cli-kit/node/result' import {copyFile, fileExists, readFile} from '@shopify/cli-kit/node/fs' import {joinPath, basename, dirname} from '@shopify/cli-kit/node/path' @@ -182,7 +182,7 @@ const uiExtensionSpec = createExtensionSpecification({ }) !== undefined ) }, - contributeToSharedTypeFile: async (extension, typeDefinitionsByFile) => { + contributeToSharedTypeFile: async (extension, typeDefinitionsByFile, appDirectory = extension.directory) => { if (!isRemoteDomExtension(extension.configuration)) { return } @@ -259,7 +259,7 @@ const uiExtensionSpec = createExtensionSpecification({ // Third pass: generate type definitions for all files for await (const [filePath, targets] of fileToTargetsMap.entries()) { - const tsConfigDir = await findNearestTsConfigDir(filePath, extension.directory) + const tsConfigDir = await findNearestTsConfigDir(filePath, appDirectory) if (!tsConfigDir) continue const typeFilePath = joinPath(tsConfigDir, 'shopify.d.ts') @@ -297,19 +297,20 @@ const uiExtensionSpec = createExtensionSpecification({ ) } } - let typeDefinition = createTypeDefinition({ + assertTargetsResolvable({ + fullPath: filePath, + typeFilePath, + targets: uniqueTargets, + apiVersion: configuration.api_version, + }) + + addTypeDefinition(typeDefinitionsByFile, { fullPath: filePath, typeFilePath, targets: uniqueTargets, apiVersion: configuration.api_version, toolsTypeDefinition, }) - if (typeDefinition) { - const currentTypes = typeDefinitionsByFile.get(typeFilePath) ?? new Set() - typeDefinition = await formatContent(typeDefinition, {parser: 'typescript', singleQuote: true}) - currentTypes.add(typeDefinition) - typeDefinitionsByFile.set(typeFilePath, currentTypes) - } } catch (error) { // Only throw if this is an entry point file (required) const isEntryPoint = configuration.extension_points.some(