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 2656bfb5a1..8d2b1a2d7f 100644 --- a/packages/app/src/cli/models/extensions/specifications/ui_extension.ts +++ b/packages/app/src/cli/models/extensions/specifications/ui_extension.ts @@ -84,12 +84,17 @@ const uiExtensionSpec = createExtensionSpecification({ dependency, schema: UIExtensionSchema, buildConfig: {mode: 'ui'}, - getOutputRelativePath: (extension: ExtensionInstance) => `dist/${extension.handle}.js`, + getOutputRelativePath: (extension: ExtensionInstance) => `${extension.handle}.js`, clientSteps: [ { lifecycle: 'deploy', steps: [ - {id: 'bundle-ui', name: 'Bundle UI Extension', type: 'bundle_ui', config: {generatesAssetsManifest: true}}, + { + id: 'bundle-ui', + name: 'Bundle UI Extension', + type: 'bundle_ui', + config: {generatesAssetsManifest: true, bundleFolder: 'dist/'}, + }, { id: 'include-ui-extension-assets', name: 'Include UI Extension Assets', diff --git a/packages/app/src/cli/services/build/client-steps.ts b/packages/app/src/cli/services/build/client-steps.ts index d7d6b12f32..a5092f0bae 100644 --- a/packages/app/src/cli/services/build/client-steps.ts +++ b/packages/app/src/cli/services/build/client-steps.ts @@ -26,6 +26,7 @@ export interface BundleUIStep extends BaseStep { readonly type: 'bundle_ui' readonly config?: { readonly generatesAssetsManifest?: boolean + readonly bundleFolder?: string } } diff --git a/packages/app/src/cli/services/build/extension.ts b/packages/app/src/cli/services/build/extension.ts index de88599c0d..cdaf60bf48 100644 --- a/packages/app/src/cli/services/build/extension.ts +++ b/packages/app/src/cli/services/build/extension.ts @@ -67,8 +67,10 @@ export async function buildUIExtension(extension: ExtensionInstance, options: Ex env.APP_URL = options.appURL } + const buildDirectory = options.buildDirectory ?? '' + // Always build into the extension's local directory (e.g. ext/dist/handle.js) - const localOutputPath = joinPath(extension.directory, extension.outputRelativePath) + const localOutputPath = joinPath(extension.directory, buildDirectory, extension.outputRelativePath) const {main, assets} = extension.getBundleExtensionStdinContent() diff --git a/packages/app/src/cli/services/build/steps/bundle-ui-step.ts b/packages/app/src/cli/services/build/steps/bundle-ui-step.ts index 81174b8a4d..5ab34b58c2 100644 --- a/packages/app/src/cli/services/build/steps/bundle-ui-step.ts +++ b/packages/app/src/cli/services/build/steps/bundle-ui-step.ts @@ -2,7 +2,7 @@ import {createOrUpdateManifestFile} from './include-assets/generate-manifest.js' import {buildUIExtension} from '../extension.js' import {BuildManifest} from '../../../models/extensions/specifications/ui_extension.js' import {copyFile} from '@shopify/cli-kit/node/fs' -import {dirname} from '@shopify/cli-kit/node/path' +import {dirname, joinPath} from '@shopify/cli-kit/node/path' import type {BundleUIStep, BuildContext} from '../client-steps.js' interface ExtensionPointWithBuildManifest { @@ -20,9 +20,13 @@ interface ExtensionPointWithBuildManifest { */ export async function executeBundleUIStep(step: BundleUIStep, context: BuildContext): Promise { const config = context.extension.configuration + context.options.buildDirectory = step.config?.bundleFolder ?? undefined const localOutputPath = await buildUIExtension(context.extension, context.options) + const bundleOutputDir = step.config?.bundleFolder + ? joinPath(dirname(context.extension.outputPath), step.config.bundleFolder) + : dirname(context.extension.outputPath) // Copy the locally built files into the bundle - await copyFile(dirname(localOutputPath), dirname(context.extension.outputPath)) + await copyFile(dirname(localOutputPath), bundleOutputDir) if (!step.config?.generatesAssetsManifest) return @@ -32,7 +36,7 @@ export async function executeBundleUIStep(step: BundleUIStep, context: BuildCont (ep): ep is ExtensionPointWithBuildManifest => typeof ep === 'object' && ep.build_manifest, ) - const entries = extractBuiltAssetEntries(pointsWithManifest) + const entries = extractBuiltAssetEntries(pointsWithManifest, step.config?.bundleFolder) if (Object.keys(entries).length > 0) { await createOrUpdateManifestFile(context, entries) } @@ -42,7 +46,10 @@ export async function executeBundleUIStep(step: BundleUIStep, context: BuildCont * Extracts built asset filepaths from `build_manifest` on each extension point, * grouped by target. Returns a map of target → `{assetName: filepath}`. */ -function extractBuiltAssetEntries(extensionPoints: {target: string; build_manifest: BuildManifest}[]): { +function extractBuiltAssetEntries( + extensionPoints: {target: string; build_manifest: BuildManifest}[], + bundleFolder?: string, +): { [target: string]: {[assetName: string]: string} } { const entries: {[target: string]: {[assetName: string]: string}} = {} @@ -50,7 +57,7 @@ function extractBuiltAssetEntries(extensionPoints: {target: string; build_manife if (!buildManifest?.assets) continue const assets: {[name: string]: string} = {} for (const [name, asset] of Object.entries(buildManifest.assets)) { - if (asset?.filepath) assets[name] = asset.filepath + if (asset?.filepath) assets[name] = bundleFolder ? joinPath(bundleFolder, asset.filepath) : asset.filepath } if (Object.keys(assets).length > 0) entries[target] = assets } diff --git a/packages/app/src/cli/services/dev/extension/payload.test.ts b/packages/app/src/cli/services/dev/extension/payload.test.ts index eb9719a7e1..9c86192511 100644 --- a/packages/app/src/cli/services/dev/extension/payload.test.ts +++ b/packages/app/src/cli/services/dev/extension/payload.test.ts @@ -206,15 +206,8 @@ describe('getUIExtensionPayload', () => { }) }) - test('maps main and should_render from build_manifest', async () => { + test('maps main and should_render paths from manifest.json', async () => { await inTemporaryDirectory(async (tmpDir) => { - const buildManifest = { - assets: { - main: {module: './src/ExtensionPointA.js', filepath: 'test-ui-extension.js'}, - should_render: {module: './src/ShouldRender.js', filepath: 'test-ui-extension-conditions.js'}, - }, - } - const uiExtension = await testUIExtension({ directory: tmpDir, configuration: { @@ -224,18 +217,12 @@ describe('getUIExtensionPayload', () => { { target: 'CUSTOM_EXTENSION_POINT', module: './src/ExtensionPointA.js', - build_manifest: buildManifest, }, ], }, devUUID: 'devUUID', }) - // Create source files so lastUpdated resolves - await mkdir(joinPath(tmpDir, 'src')) - await writeFile(joinPath(tmpDir, 'src', 'ExtensionPointA.js'), '// main') - await writeFile(joinPath(tmpDir, 'src', 'ShouldRender.js'), '// should render') - await setupBuildOutput( uiExtension, tmpDir, @@ -268,6 +255,55 @@ describe('getUIExtensionPayload', () => { }) }) + test('maps main and should_render with bundleFolder prefix from manifest.json', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const uiExtension = await testUIExtension({ + directory: tmpDir, + configuration: { + name: 'test-ui-extension', + type: 'ui_extension', + extension_points: [ + { + target: 'CUSTOM_EXTENSION_POINT', + module: './src/ExtensionPointA.js', + }, + ], + }, + devUUID: 'devUUID', + }) + + await setupBuildOutput( + uiExtension, + tmpDir, + {CUSTOM_EXTENSION_POINT: {main: 'dist/test-ui-extension.js', should_render: 'dist/test-ui-extension-conditions.js'}}, + {}, + ) + + const got = await getUIExtensionPayload(uiExtension, tmpDir, { + ...createMockOptions(tmpDir, [uiExtension]), + currentDevelopmentPayload: {hidden: true, status: 'success'}, + }) + + expect(got.extensionPoints).toMatchObject([ + { + target: 'CUSTOM_EXTENSION_POINT', + assets: { + main: { + name: 'main', + url: 'http://tunnel-url.com/extensions/devUUID/assets/dist/test-ui-extension.js', + lastUpdated: expect.any(Number), + }, + should_render: { + name: 'should_render', + url: 'http://tunnel-url.com/extensions/devUUID/assets/dist/test-ui-extension-conditions.js', + lastUpdated: expect.any(Number), + }, + }, + }, + ]) + }) + }) + test('maps intents from manifest.json to asset payloads', async () => { await inTemporaryDirectory(async (tmpDir) => { const uiExtension = await testUIExtension({ diff --git a/packages/app/src/cli/services/dev/extension/payload.ts b/packages/app/src/cli/services/dev/extension/payload.ts index 990bf59be1..6d942d841b 100644 --- a/packages/app/src/cli/services/dev/extension/payload.ts +++ b/packages/app/src/cli/services/dev/extension/payload.ts @@ -19,6 +19,7 @@ interface AssetMapperContext { extensionPoint: DevNewExtensionPointSchema url: string extension: ExtensionInstance + manifestValue?: unknown } export async function getUIExtensionPayload( @@ -47,11 +48,14 @@ export async function getUIExtensionPayload( const defaultConfig = { assets: { - main: { - name: 'main', - url: `${url}/assets/${extension.outputFileName}`, - lastUpdated: (await fileLastUpdatedTimestamp(extensionOutputPath)) ?? 0, - }, + main: + isNewExtensionPointsSchema(extensionPoints) && extensionPoints[0]?.assets?.main + ? extensionPoints[0].assets.main + : { + name: 'main', + url: `${url}/assets/${extension.outputFileName}`, + lastUpdated: (await fileLastUpdatedTimestamp(extensionOutputPath)) ?? 0, + }, }, supportedFeatures: { runsOffline: extension.configuration.supported_features?.runs_offline ?? false, @@ -134,10 +138,11 @@ async function getExtensionPoints(extension: ExtensionInstance, url: string, bui return payload } - return { + const payloadWithAssets = { ...payload, ...(await mapManifestAssetsToPayload(manifestEntry, extensionPoint, url, extension)), } + return payloadWithAssets }), ) } @@ -206,6 +211,7 @@ async function intentsAssetMapper({ const intents = await Promise.all( extensionPoint.intents.map(async (intent) => ({ ...intent, + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion schema: await getAssetPayload('schema', intent.schema as string, url, extension), })), ) @@ -215,12 +221,29 @@ async function intentsAssetMapper({ type AssetMapper = (context: AssetMapperContext) => Promise> +/** + * Mapper for compiled built assets (main, should_render). + * Reads the filepath directly from manifest.json so the bundleFolder prefix is preserved. + */ +async function builtAssetMapper({ + identifier, + manifestValue, + url, + extension, +}: AssetMapperContext): Promise> { + if (typeof manifestValue !== 'string') return {} + const payload = await getAssetPayload(identifier, manifestValue, url, extension) + return {assets: {[payload.name]: payload}} +} + /** * Asset mappers registry - defines how each asset type should be handled. * Assets not in this registry use the defaultAssetMapper. */ const ASSET_MAPPERS: {[key: string]: AssetMapper | undefined} = { intents: intentsAssetMapper, + main: builtAssetMapper, + should_render: builtAssetMapper, } /** @@ -236,7 +259,13 @@ async function mapManifestAssetsToPayload( ): Promise> { const mappingResults = await Promise.all( Object.keys(manifestEntry).map(async (identifier) => { - const context: AssetMapperContext = {identifier, extensionPoint, url, extension} + const context: AssetMapperContext = { + identifier, + extensionPoint, + url, + extension, + manifestValue: manifestEntry[identifier], + } return ASSET_MAPPERS[identifier]?.(context) ?? defaultAssetMapper(context) }), )