diff --git a/packages/app/src/cli/models/extensions/extension-instance.test.ts b/packages/app/src/cli/models/extensions/extension-instance.test.ts index 3fea6b4a05..9cebe16732 100644 --- a/packages/app/src/cli/models/extensions/extension-instance.test.ts +++ b/packages/app/src/cli/models/extensions/extension-instance.test.ts @@ -172,6 +172,27 @@ describe('build', async () => { }) }) + test('bundle() is a no-op when the spec declares no bundle lifecycle group', async () => { + await inTemporaryDirectory(async (tmpDir) => { + // Given — tax_calculation only declares a 'build' lifecycle group. + const extensionInstance = await testTaxCalculationExtension(tmpDir) + const options: ExtensionBuildOptions = { + stdout: new Writable({write: (_chunk, _enc, cb) => cb()}), + stderr: new Writable({write: (_chunk, _enc, cb) => cb()}), + app: testApp(), + environment: 'production', + } + + const outputFilePath = joinPath(tmpDir, `dist/${extensionInstance.outputFileName}`) + + // When — bundle() runs only the bundle steps; with none declared this is a no-op. + await extensionInstance.bundle(options) + + // Then — no output file is produced (build() was not called). + await expect(readFile(outputFilePath)).rejects.toThrow() + }) + }) + test('does not copy shopify.extension.toml file when bundling theme extensions', async () => { await inTemporaryDirectory(async (tmpDir) => { // Given diff --git a/packages/app/src/cli/models/extensions/extension-instance.ts b/packages/app/src/cli/models/extensions/extension-instance.ts index e266cb54c0..af6cc3e113 100644 --- a/packages/app/src/cli/models/extensions/extension-instance.ts +++ b/packages/app/src/cli/models/extensions/extension-instance.ts @@ -8,7 +8,7 @@ import {Identifiers} from '../app/identifiers.js' import {DeveloperPlatformClient} from '../../utilities/developer-platform-client.js' import {AppConfiguration} from '../app/app.js' import {ApplicationURLs} from '../../services/dev/urls.js' -import {executeStep, BuildContext} from '../../services/build/client-steps.js' +import {executeStep, BuildContext, LifecyclePhase} from '../../services/build/client-steps.js' import {ok} from '@shopify/cli-kit/node/result' import {constantize, slugify} from '@shopify/cli-kit/common/string' import {hashString, nonRandomUUID} from '@shopify/cli-kit/node/crypto' @@ -114,7 +114,9 @@ export class ExtensionInstance group.lifecycle === 'deploy' && group.steps.length > 0) ?? false + this.specification.clientSteps?.some( + (group) => (group.lifecycle === 'build' || group.lifecycle === 'bundle') && group.steps.length > 0, + ) ?? false ) } @@ -316,7 +318,25 @@ export class ExtensionInstance { - const {clientSteps = []} = this.specification + await this.runLifecyclePhase('build', options) + } + + async bundle(options: ExtensionBuildOptions): Promise { + await this.runLifecyclePhase('bundle', options) + } + + async buildForBundle(options: ExtensionBuildOptions, bundleDirectory: string, outputId?: string) { + this.outputPath = this.getOutputPathForDirectory(bundleDirectory, outputId) + await this.build(options) + await this.bundle(options) + + const bundleInputPath = joinPath(bundleDirectory, this.getOutputFolderId(outputId)) + await this.keepBuiltSourcemapsLocally(bundleInputPath) + } + + private async runLifecyclePhase(phase: LifecyclePhase, options: ExtensionBuildOptions): Promise { + const steps = this.specification.clientSteps?.find((group) => group.lifecycle === phase)?.steps ?? [] + if (steps.length === 0) return const context: BuildContext = { extension: this, @@ -324,8 +344,6 @@ export class ExtensionInstance lifecycle.lifecycle === 'deploy')?.steps ?? [] - for (const step of steps) { // eslint-disable-next-line no-await-in-loop const result = await executeStep(step, context) @@ -333,14 +351,6 @@ export class ExtensionInstance { const testClientSteps: ClientSteps = [ { - lifecycle: 'deploy', + lifecycle: 'build', steps: [ { id: 'bundle-ui', diff --git a/packages/app/src/cli/models/extensions/specifications/admin.ts b/packages/app/src/cli/models/extensions/specifications/admin.ts index 7aad744d23..df8ae6042e 100644 --- a/packages/app/src/cli/models/extensions/specifications/admin.ts +++ b/packages/app/src/cli/models/extensions/specifications/admin.ts @@ -43,7 +43,7 @@ const adminSpecificationSpec = createExtensionSpecification({ }, clientSteps: [ { - lifecycle: 'deploy', + lifecycle: 'bundle', steps: [ { id: 'hosted_app_copy_files', diff --git a/packages/app/src/cli/models/extensions/specifications/admin_link.test.ts b/packages/app/src/cli/models/extensions/specifications/admin_link.test.ts index 0030fb7b26..b41872d7b5 100644 --- a/packages/app/src/cli/models/extensions/specifications/admin_link.test.ts +++ b/packages/app/src/cli/models/extensions/specifications/admin_link.test.ts @@ -52,7 +52,7 @@ describe('admin_link', async () => { const extension = await getTestAdminLink(tmpDir) const clientSteps = extension.specification.clientSteps! expect(clientSteps).toHaveLength(1) - expect(clientSteps[0]!.lifecycle).toBe('deploy') + expect(clientSteps[0]!.lifecycle).toBe('bundle') const steps = clientSteps[0]!.steps expect(steps).toHaveLength(1) diff --git a/packages/app/src/cli/models/extensions/specifications/admin_link.ts b/packages/app/src/cli/models/extensions/specifications/admin_link.ts index a375e12f55..34fed0219a 100644 --- a/packages/app/src/cli/models/extensions/specifications/admin_link.ts +++ b/packages/app/src/cli/models/extensions/specifications/admin_link.ts @@ -6,7 +6,7 @@ const adminLinkSpec = createContractBasedModuleSpecification({ experience: 'extension', clientSteps: [ { - lifecycle: 'deploy', + lifecycle: 'bundle', steps: [ { id: 'include-admin-link-assets', diff --git a/packages/app/src/cli/models/extensions/specifications/channel.ts b/packages/app/src/cli/models/extensions/specifications/channel.ts index 3973fa91d2..c38f24d478 100644 --- a/packages/app/src/cli/models/extensions/specifications/channel.ts +++ b/packages/app/src/cli/models/extensions/specifications/channel.ts @@ -9,7 +9,7 @@ const channelSpecificationSpec = createContractBasedModuleSpecification({ experience: 'extension', clientSteps: [ { - lifecycle: 'deploy', + lifecycle: 'bundle', steps: [ { id: 'copy-files', diff --git a/packages/app/src/cli/models/extensions/specifications/checkout_post_purchase.ts b/packages/app/src/cli/models/extensions/specifications/checkout_post_purchase.ts index fad34726ed..d8d3faaa47 100644 --- a/packages/app/src/cli/models/extensions/specifications/checkout_post_purchase.ts +++ b/packages/app/src/cli/models/extensions/specifications/checkout_post_purchase.ts @@ -20,7 +20,7 @@ const checkoutPostPurchaseSpec = createExtensionSpecification({ `dist/${extension.handle}.js`, clientSteps: [ { - lifecycle: 'deploy', + lifecycle: 'build', steps: [{id: 'bundle-ui', name: 'Bundle UI Extension', type: 'bundle_ui', config: {}}], }, ], diff --git a/packages/app/src/cli/models/extensions/specifications/checkout_ui_extension.ts b/packages/app/src/cli/models/extensions/specifications/checkout_ui_extension.ts index 239f164c9b..992975b163 100644 --- a/packages/app/src/cli/models/extensions/specifications/checkout_ui_extension.ts +++ b/packages/app/src/cli/models/extensions/specifications/checkout_ui_extension.ts @@ -26,7 +26,7 @@ const checkoutSpec = createExtensionSpecification({ getOutputRelativePath: (extension: ExtensionInstance) => `dist/${extension.handle}.js`, clientSteps: [ { - lifecycle: 'deploy', + lifecycle: 'build', steps: [{id: 'bundle-ui', name: 'Bundle UI Extension', type: 'bundle_ui', config: {}}], }, ], diff --git a/packages/app/src/cli/models/extensions/specifications/flow_template.ts b/packages/app/src/cli/models/extensions/specifications/flow_template.ts index 805f953c98..7c19bf27f1 100644 --- a/packages/app/src/cli/models/extensions/specifications/flow_template.ts +++ b/packages/app/src/cli/models/extensions/specifications/flow_template.ts @@ -51,7 +51,7 @@ const flowTemplateSpec = createExtensionSpecification({ appModuleFeatures: (_) => ['ui_preview'], clientSteps: [ { - lifecycle: 'deploy', + lifecycle: 'bundle', steps: [ { id: 'copy-files', diff --git a/packages/app/src/cli/models/extensions/specifications/function.ts b/packages/app/src/cli/models/extensions/specifications/function.ts index 2d73e819d0..7c96f15c7f 100644 --- a/packages/app/src/cli/models/extensions/specifications/function.ts +++ b/packages/app/src/cli/models/extensions/specifications/function.ts @@ -103,7 +103,7 @@ const functionSpec = createExtensionSpecification({ }, clientSteps: [ { - lifecycle: 'deploy', + lifecycle: 'build', steps: [{id: 'build-function', name: 'Build Function', type: 'build_function', config: {}}], }, ], diff --git a/packages/app/src/cli/models/extensions/specifications/order_attribution_config.ts b/packages/app/src/cli/models/extensions/specifications/order_attribution_config.ts index d2af493e27..2141fa197e 100644 --- a/packages/app/src/cli/models/extensions/specifications/order_attribution_config.ts +++ b/packages/app/src/cli/models/extensions/specifications/order_attribution_config.ts @@ -9,7 +9,7 @@ const orderAttributionConfigSpec = createContractBasedModuleSpecification({ experience: 'extension', clientSteps: [ { - lifecycle: 'deploy', + lifecycle: 'bundle', steps: [ { id: 'copy-files', diff --git a/packages/app/src/cli/models/extensions/specifications/pos_ui_extension.ts b/packages/app/src/cli/models/extensions/specifications/pos_ui_extension.ts index 3835d873ec..668ed6cd85 100644 --- a/packages/app/src/cli/models/extensions/specifications/pos_ui_extension.ts +++ b/packages/app/src/cli/models/extensions/specifications/pos_ui_extension.ts @@ -18,7 +18,7 @@ const posUISpec = createExtensionSpecification({ getOutputRelativePath: (extension: ExtensionInstance) => `dist/${extension.handle}.js`, clientSteps: [ { - lifecycle: 'deploy', + lifecycle: 'build', steps: [{id: 'bundle-ui', name: 'Bundle UI Extension', type: 'bundle_ui', config: {}}], }, ], diff --git a/packages/app/src/cli/models/extensions/specifications/product_subscription.ts b/packages/app/src/cli/models/extensions/specifications/product_subscription.ts index 77c95fde10..14edab2f64 100644 --- a/packages/app/src/cli/models/extensions/specifications/product_subscription.ts +++ b/packages/app/src/cli/models/extensions/specifications/product_subscription.ts @@ -19,7 +19,7 @@ const productSubscriptionSpec = createExtensionSpecification({ getOutputRelativePath: (extension: ExtensionInstance) => `dist/${extension.handle}.js`, clientSteps: [ { - lifecycle: 'deploy', + lifecycle: 'build', steps: [{id: 'bundle-ui', name: 'Bundle UI Extension', type: 'bundle_ui', config: {}}], }, ], diff --git a/packages/app/src/cli/models/extensions/specifications/tax_calculation.ts b/packages/app/src/cli/models/extensions/specifications/tax_calculation.ts index 1cc2caa351..3b75d35b9b 100644 --- a/packages/app/src/cli/models/extensions/specifications/tax_calculation.ts +++ b/packages/app/src/cli/models/extensions/specifications/tax_calculation.ts @@ -35,7 +35,7 @@ const spec = createExtensionSpecification({ joinPath('dist', `${extension.handle}.js`), clientSteps: [ { - lifecycle: 'deploy', + lifecycle: 'build', steps: [{id: 'create-tax-stub', name: 'Create Tax Stub', type: 'create_tax_stub', config: {}}], }, ], diff --git a/packages/app/src/cli/models/extensions/specifications/theme.ts b/packages/app/src/cli/models/extensions/specifications/theme.ts index 8e6d27d27e..2412d4a6ea 100644 --- a/packages/app/src/cli/models/extensions/specifications/theme.ts +++ b/packages/app/src/cli/models/extensions/specifications/theme.ts @@ -14,7 +14,7 @@ const themeSpec = createExtensionSpecification({ graphQLType: 'theme_app_extension', clientSteps: [ { - lifecycle: 'deploy', + lifecycle: 'build', steps: [ {id: 'build-theme', name: 'Build Theme Extension', type: 'build_theme', config: {}}, {id: 'bundle-theme', name: 'Bundle Theme Extension', type: 'bundle_theme', config: {}}, 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 e2a33fda99..f7feeb66e4 100644 --- a/packages/app/src/cli/models/extensions/specifications/ui_extension.ts +++ b/packages/app/src/cli/models/extensions/specifications/ui_extension.ts @@ -87,13 +87,24 @@ const uiExtensionSpec = createExtensionSpecification({ getOutputRelativePath: (extension: ExtensionInstance) => `${extension.handle}.js`, clientSteps: [ { - lifecycle: 'deploy', + lifecycle: 'build', steps: [ { id: 'bundle-ui', name: 'Bundle UI Extension', type: 'bundle_ui', - config: {generatesAssetsManifest: true, bundleFolder: 'dist/'}, + config: {bundleFolder: 'dist/'}, + }, + ], + }, + { + lifecycle: 'bundle', + steps: [ + { + id: 'generate-ui-assets-manifest', + name: 'Generate UI Assets Manifest', + type: 'generate_ui_assets_manifest', + config: {bundleFolder: 'dist/'}, }, { id: 'include-ui-extension-assets', diff --git a/packages/app/src/cli/models/extensions/specifications/web_pixel_extension.ts b/packages/app/src/cli/models/extensions/specifications/web_pixel_extension.ts index 500db61748..53b5ea9309 100644 --- a/packages/app/src/cli/models/extensions/specifications/web_pixel_extension.ts +++ b/packages/app/src/cli/models/extensions/specifications/web_pixel_extension.ts @@ -36,7 +36,7 @@ const webPixelSpec = createExtensionSpecification({ getOutputRelativePath: (extension: ExtensionInstance) => `dist/${extension.handle}.js`, clientSteps: [ { - lifecycle: 'deploy', + lifecycle: 'build', steps: [{id: 'bundle-ui', name: 'Bundle UI Extension', type: 'bundle_ui', config: {}}], }, ], diff --git a/packages/app/src/cli/services/build/client-steps.ts b/packages/app/src/cli/services/build/client-steps.ts index a5092f0bae..a446637f58 100644 --- a/packages/app/src/cli/services/build/client-steps.ts +++ b/packages/app/src/cli/services/build/client-steps.ts @@ -25,7 +25,14 @@ interface IncludeAssetsStep extends BaseStep { export interface BundleUIStep extends BaseStep { readonly type: 'bundle_ui' readonly config?: { - readonly generatesAssetsManifest?: boolean + readonly bundleFolder?: string + } +} + +/** Step with typed config specific to generate_ui_assets_manifest. */ +export interface GenerateUIAssetsManifestStep extends BaseStep { + readonly type: 'generate_ui_assets_manifest' + readonly config?: { readonly bundleFolder?: string } } @@ -43,14 +50,31 @@ interface NoConfigStep extends BaseStep { * This is a discriminated union on `type`: each step type carries its own * typed `config`, so TypeScript catches config typos at compile time. */ -export type LifecycleStep = IncludeAssetsStep | BundleUIStep | NoConfigStep +export type LifecycleStep = IncludeAssetsStep | BundleUIStep | GenerateUIAssetsManifestStep | NoConfigStep + +/** + * Lifecycle phases supported by the client-step pipeline. + * + * Phases compose additively. A spec lists only the steps that are *unique* to a + * phase; the runtime combines them per command: + * + * - `'build'` — local build (e.g. `shopify app build`). Steps required to + * produce the on-disk artifact. + * - `'bundle'` — extra steps needed when bundling for upload (driven by `dev` + * and `deploy`). Run *after* the build steps; never on their own. + * + * The `build` command runs `'build'` steps. The `dev`/`deploy` pipeline runs + * `'build'` then `'bundle'`. A spec only declares a `'bundle'` group when the + * bundle phase needs to do something the local build doesn't. + */ +export type LifecyclePhase = 'build' | 'bundle' /** * A group of steps scoped to a specific lifecycle phase. - * Allows executing only the steps relevant to a given lifecycle (e.g. 'deploy'). + * Allows executing only the steps relevant to a given lifecycle. */ interface ClientLifecycleGroup { - readonly lifecycle: 'deploy' + readonly lifecycle: LifecyclePhase readonly steps: ReadonlyArray } diff --git a/packages/app/src/cli/services/build/steps/bundle-ui-step.test.ts b/packages/app/src/cli/services/build/steps/bundle-ui-step.test.ts index 5f73580949..da95f3d295 100644 --- a/packages/app/src/cli/services/build/steps/bundle-ui-step.test.ts +++ b/packages/app/src/cli/services/build/steps/bundle-ui-step.test.ts @@ -32,7 +32,7 @@ describe('executeBundleUIStep', () => { id: 'bundle-ui', name: 'Bundle UI Extension', type: 'bundle_ui', - config: {generatesAssetsManifest: false}, + config: {}, } test('copies when local and bundle output directories differ', async () => { 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 dd65b01ce7..312f227c2a 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 @@ -1,25 +1,16 @@ -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, joinPath, resolvePath} from '@shopify/cli-kit/node/path' import type {BundleUIStep, BuildContext} from '../client-steps.js' -interface ExtensionPointWithBuildManifest { - target: string - build_manifest: BuildManifest -} - /** * Executes a bundle_ui build step. * * Bundles the UI extension using esbuild into the extension's local directory - * and copies the output to the bundle. When `generatesAssetsManifest` is true, - * writes built asset entries (from build_manifest) into manifest.json so - * downstream steps can merge on top. + * and copies the output to the bundle. Manifest emission lives in the separate + * `generate_ui_assets_manifest` step so it only runs in lifecycles that need it. */ 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 localOutputDir = dirname(localOutputPath) @@ -29,39 +20,4 @@ export async function executeBundleUIStep(step: BundleUIStep, context: BuildCont if (resolvePath(localOutputDir) !== resolvePath(bundleOutputDir)) { await copyFile(localOutputDir, bundleOutputDir) } - - if (!step.config?.generatesAssetsManifest) return - - if (!Array.isArray(config.extension_points)) return - - const pointsWithManifest = config.extension_points.filter( - (ep): ep is ExtensionPointWithBuildManifest => typeof ep === 'object' && ep.build_manifest, - ) - - const entries = extractBuiltAssetEntries(pointsWithManifest, step.config?.bundleFolder) - if (Object.keys(entries).length > 0) { - await createOrUpdateManifestFile(context, entries) - } -} - -/** - * 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}[], - bundleFolder?: string, -): { - [target: string]: {[assetName: string]: string} -} { - const entries: {[target: string]: {[assetName: string]: string}} = {} - for (const {target, build_manifest: buildManifest} of extensionPoints) { - if (!buildManifest?.assets) continue - const assets: {[name: string]: string} = {} - for (const [name, asset] of Object.entries(buildManifest.assets)) { - if (asset?.filepath) assets[name] = bundleFolder ? joinPath(bundleFolder, asset.filepath) : asset.filepath - } - if (Object.keys(assets).length > 0) entries[target] = assets - } - return entries } diff --git a/packages/app/src/cli/services/build/steps/generate-ui-assets-manifest-step.test.ts b/packages/app/src/cli/services/build/steps/generate-ui-assets-manifest-step.test.ts new file mode 100644 index 0000000000..e0b47f09f0 --- /dev/null +++ b/packages/app/src/cli/services/build/steps/generate-ui-assets-manifest-step.test.ts @@ -0,0 +1,107 @@ +import {executeGenerateUIAssetsManifestStep} from './generate-ui-assets-manifest-step.js' +import * as generateManifest from './include-assets/generate-manifest.js' +import {GenerateUIAssetsManifestStep, BuildContext} from '../client-steps.js' +import {ExtensionInstance} from '../../../models/extensions/extension-instance.js' +import {describe, expect, test, vi, beforeEach} from 'vitest' + +vi.mock('./include-assets/generate-manifest.js') + +const step: GenerateUIAssetsManifestStep = { + id: 'generate-ui-assets-manifest', + name: 'Generate UI Assets Manifest', + type: 'generate_ui_assets_manifest', + config: {bundleFolder: 'dist/'}, +} + +describe('executeGenerateUIAssetsManifestStep', () => { + let mockContext: BuildContext + + beforeEach(() => { + vi.mocked(generateManifest.createOrUpdateManifestFile).mockResolvedValue() + mockContext = { + extension: { + directory: '/test/extension', + outputPath: '/test/extension/dist/handle.js', + configuration: {}, + } as ExtensionInstance, + options: { + stdout: {write: vi.fn()} as any, + stderr: {write: vi.fn()} as any, + app: {} as any, + environment: 'production', + }, + stepResults: new Map(), + } + }) + + test('writes manifest entries derived from build_manifest assets, prefixed with bundleFolder', async () => { + // Given + mockContext.extension.configuration = { + extension_points: [ + { + target: 'admin.product-details.block.render', + build_manifest: { + assets: { + main: {filepath: 'main.js'}, + styles: {filepath: 'main.css'}, + }, + }, + }, + ], + } as any + + // When + await executeGenerateUIAssetsManifestStep(step, mockContext) + + // Then + expect(generateManifest.createOrUpdateManifestFile).toHaveBeenCalledWith(mockContext, { + 'admin.product-details.block.render': { + main: 'dist/main.js', + styles: 'dist/main.css', + }, + }) + }) + + test('omits the bundleFolder prefix when not configured', async () => { + // Given + const stepWithoutFolder: GenerateUIAssetsManifestStep = { + id: 'generate-ui-assets-manifest', + name: 'Generate UI Assets Manifest', + type: 'generate_ui_assets_manifest', + } + mockContext.extension.configuration = { + extension_points: [ + { + target: 'admin.x', + build_manifest: {assets: {main: {filepath: 'main.js'}}}, + }, + ], + } as any + + // When + await executeGenerateUIAssetsManifestStep(stepWithoutFolder, mockContext) + + // Then + expect(generateManifest.createOrUpdateManifestFile).toHaveBeenCalledWith(mockContext, { + 'admin.x': {main: 'main.js'}, + }) + }) + + test('does nothing when extension has no extension_points', async () => { + mockContext.extension.configuration = {} as any + + await executeGenerateUIAssetsManifestStep(step, mockContext) + + expect(generateManifest.createOrUpdateManifestFile).not.toHaveBeenCalled() + }) + + test('does nothing when no extension_point has a build_manifest with assets', async () => { + mockContext.extension.configuration = { + extension_points: [{target: 'admin.x'}], + } as any + + await executeGenerateUIAssetsManifestStep(step, mockContext) + + expect(generateManifest.createOrUpdateManifestFile).not.toHaveBeenCalled() + }) +}) diff --git a/packages/app/src/cli/services/build/steps/generate-ui-assets-manifest-step.ts b/packages/app/src/cli/services/build/steps/generate-ui-assets-manifest-step.ts new file mode 100644 index 0000000000..427a889e9b --- /dev/null +++ b/packages/app/src/cli/services/build/steps/generate-ui-assets-manifest-step.ts @@ -0,0 +1,57 @@ +import {createOrUpdateManifestFile} from './include-assets/generate-manifest.js' +import {BuildManifest} from '../../../models/extensions/specifications/ui_extension.js' +import {joinPath} from '@shopify/cli-kit/node/path' +import type {GenerateUIAssetsManifestStep, BuildContext} from '../client-steps.js' + +interface ExtensionPointWithBuildManifest { + target: string + build_manifest: BuildManifest +} + +/** + * Executes a generate_ui_assets_manifest build step. + * + * Reads `extension_points[].build_manifest.assets` from the extension + * configuration and writes the resulting target → asset map into manifest.json + * in the bundle output directory. Intended to run after a `bundle_ui` step in + * lifecycles that need a deployable manifest (deploy, dev) and to be omitted + * from the build-only lifecycle. + */ +export async function executeGenerateUIAssetsManifestStep( + step: GenerateUIAssetsManifestStep, + context: BuildContext, +): Promise { + const config = context.extension.configuration + if (!Array.isArray(config.extension_points)) return + + const pointsWithManifest = config.extension_points.filter( + (ep): ep is ExtensionPointWithBuildManifest => typeof ep === 'object' && ep.build_manifest, + ) + + const entries = extractBuiltAssetEntries(pointsWithManifest, step.config?.bundleFolder) + if (Object.keys(entries).length > 0) { + await createOrUpdateManifestFile(context, entries) + } +} + +/** + * 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}[], + bundleFolder?: string, +): { + [target: string]: {[assetName: string]: string} +} { + const entries: {[target: string]: {[assetName: string]: string}} = {} + for (const {target, build_manifest: buildManifest} of extensionPoints) { + if (!buildManifest?.assets) continue + const assets: {[name: string]: string} = {} + for (const [name, asset] of Object.entries(buildManifest.assets)) { + if (asset?.filepath) assets[name] = bundleFolder ? joinPath(bundleFolder, asset.filepath) : asset.filepath + } + if (Object.keys(assets).length > 0) entries[target] = assets + } + return entries +} diff --git a/packages/app/src/cli/services/build/steps/index.ts b/packages/app/src/cli/services/build/steps/index.ts index bf0a52f4f3..25241a2e7a 100644 --- a/packages/app/src/cli/services/build/steps/index.ts +++ b/packages/app/src/cli/services/build/steps/index.ts @@ -2,6 +2,7 @@ import {executeIncludeAssetsStep} from './include-assets-step.js' import {executeBuildThemeStep} from './build-theme-step.js' import {executeBundleThemeStep} from './bundle-theme-step.js' import {executeBundleUIStep} from './bundle-ui-step.js' +import {executeGenerateUIAssetsManifestStep} from './generate-ui-assets-manifest-step.js' import {executeBuildFunctionStep} from './build-function-step.js' import {executeCreateTaxStubStep} from './create-tax-stub-step.js' import type {LifecycleStep, BuildContext} from '../client-steps.js' @@ -29,6 +30,9 @@ export async function executeStepByType(step: LifecycleStep, context: BuildConte case 'bundle_ui': return executeBundleUIStep(step, context) + case 'generate_ui_assets_manifest': + return executeGenerateUIAssetsManifestStep(step, context) + case 'build_function': return executeBuildFunctionStep(step, context)