diff --git a/packages/app/src/cli/models/extensions/specifications/app_config_hosted_app_home.test.ts b/packages/app/src/cli/models/extensions/specifications/app_config_hosted_app_home.test.ts index bb798939cc..a04ee84ea2 100644 --- a/packages/app/src/cli/models/extensions/specifications/app_config_hosted_app_home.test.ts +++ b/packages/app/src/cli/models/extensions/specifications/app_config_hosted_app_home.test.ts @@ -71,7 +71,6 @@ describe('hosted_app_home', () => { definition: {files: [{tomlKey: 'static_root'}]}, }, }) - expect(spec.buildConfig.stopOnError).toBe(true) }) test('config should be serializable to JSON', () => { diff --git a/packages/app/src/cli/models/extensions/specifications/app_config_hosted_app_home.ts b/packages/app/src/cli/models/extensions/specifications/app_config_hosted_app_home.ts index 41a82d889a..873d18d64b 100644 --- a/packages/app/src/cli/models/extensions/specifications/app_config_hosted_app_home.ts +++ b/packages/app/src/cli/models/extensions/specifications/app_config_hosted_app_home.ts @@ -29,7 +29,6 @@ const hostedAppHomeSpec = createConfigExtensionSpecification({ }, }, ], - stopOnError: true, }, schema: HostedAppHomeSchema, transformConfig: HostedAppHomeTransformConfig, diff --git a/packages/app/src/cli/services/build/build-steps.integration.test.ts b/packages/app/src/cli/services/build/build-steps.integration.test.ts index be51068f51..76e8a6229d 100644 --- a/packages/app/src/cli/services/build/build-steps.integration.test.ts +++ b/packages/app/src/cli/services/build/build-steps.integration.test.ts @@ -9,12 +9,12 @@ import {Writable} from 'stream' function buildOptions(): ExtensionBuildOptions { return { stdout: new Writable({ - write(chunk, encoding, callback) { + write(_chunk, _encoding, callback) { callback() }, }), stderr: new Writable({ - write(chunk, encoding, callback) { + write(_chunk, _encoding, callback) { callback() }, }), diff --git a/packages/app/src/cli/services/build/build-steps.ts b/packages/app/src/cli/services/build/build-steps.ts index 0c39562b31..ebb5a30239 100644 --- a/packages/app/src/cli/services/build/build-steps.ts +++ b/packages/app/src/cli/services/build/build-steps.ts @@ -25,6 +25,7 @@ export interface BuildStep { /** Step type (determines which executor handles it) */ readonly type: | 'copy_files' + | 'build_manifest' | 'build_theme' | 'bundle_theme' | 'bundle_ui' diff --git a/packages/app/src/cli/services/build/steps/build-manifest-step.test.ts b/packages/app/src/cli/services/build/steps/build-manifest-step.test.ts new file mode 100644 index 0000000000..c50e589d5b --- /dev/null +++ b/packages/app/src/cli/services/build/steps/build-manifest-step.test.ts @@ -0,0 +1,627 @@ +import {executeBuildManifestStep, ResolvedAsset, ResolvedAssets, PerItemManifest} from './build-manifest-step.js' +import {BuildStep, BuildContext} from '../build-steps.js' +import {ExtensionInstance} from '../../../models/extensions/extension-instance.js' +import {describe, expect, test, vi, beforeEach} from 'vitest' +import * as fs from '@shopify/cli-kit/node/fs' + +vi.mock('@shopify/cli-kit/node/fs') + +// Helpers to narrow the union return type +function asSingle(result: Awaited>) { + return result as {outputFile: string; assets: ResolvedAssets} +} +function asForEach(result: Awaited>) { + return result as {outputFile: string; manifests: PerItemManifest[]} +} + +describe('executeBuildManifestStep', () => { + let mockExtension: ExtensionInstance + let mockContext: BuildContext + let mockStdout: {write: ReturnType} + + beforeEach(() => { + mockStdout = {write: vi.fn()} + mockExtension = { + directory: '/test/extension', + outputPath: '/test/output/extension.js', + configuration: {}, + } as unknown as ExtensionInstance + + mockContext = { + extension: mockExtension, + options: { + stdout: mockStdout as any, + stderr: {write: vi.fn()} as any, + app: {} as any, + environment: 'production', + }, + stepResults: new Map(), + } + + vi.mocked(fs.mkdir).mockResolvedValue() + vi.mocked(fs.writeFile).mockResolvedValue() + }) + + describe('literal asset entries', () => { + test('writes a manifest JSON with literal filepath asset', async () => { + const step: BuildStep = { + id: 'write-manifest', + displayName: 'Write Manifest', + type: 'build_manifest', + config: {assets: {main: {filepath: 'index.html'}}}, + } + + const result = asSingle(await executeBuildManifestStep(step, mockContext)) + + expect(fs.writeFile).toHaveBeenCalledWith( + '/test/output/build-manifest.json', + JSON.stringify({assets: {main: {filepath: 'index.html'}}}, null, 2), + ) + expect(result.assets).toEqual({main: {filepath: 'index.html'}}) + expect(result.outputFile).toBe('/test/output/build-manifest.json') + }) + + test('includes static flag when provided', async () => { + const step: BuildStep = { + id: 'write-manifest', + displayName: 'Write Manifest', + type: 'build_manifest', + config: {assets: {tools: {filepath: 'tools.json', static: true}}}, + } + + await executeBuildManifestStep(step, mockContext) + + expect(fs.writeFile).toHaveBeenCalledWith( + '/test/output/build-manifest.json', + JSON.stringify({assets: {tools: {filepath: 'tools.json', static: true}}}, null, 2), + ) + }) + + test('includes module when provided', async () => { + mockContext = { + ...mockContext, + extension: {...mockExtension, configuration: {entry: './src/index.ts'}} as unknown as ExtensionInstance, + } + + const step: BuildStep = { + id: 'write-manifest', + displayName: 'Write Manifest', + type: 'build_manifest', + config: {assets: {main: {filepath: 'dist/index.js', module: {tomlKey: 'entry'}}}}, + } + + const result = asSingle(await executeBuildManifestStep(step, mockContext)) + + expect(result.assets).toEqual({main: {filepath: 'dist/index.js', module: './src/index.ts'}}) + }) + + test('uses custom outputFile when specified', async () => { + const step: BuildStep = { + id: 'write-manifest', + displayName: 'Write Manifest', + type: 'build_manifest', + config: {outputFile: 'manifest.json', assets: {main: {filepath: 'index.js'}}}, + } + + const result = asSingle(await executeBuildManifestStep(step, mockContext)) + + expect(result.outputFile).toBe('/test/output/manifest.json') + expect(fs.writeFile).toHaveBeenCalledWith('/test/output/manifest.json', expect.any(String)) + }) + }) + + describe('tomlKey asset entries (shorthand)', () => { + test('resolves filepath from tomlKey in extension configuration', async () => { + mockContext = { + ...mockContext, + extension: {...mockExtension, configuration: {static_root: 'public'}} as unknown as ExtensionInstance, + } + + const step: BuildStep = { + id: 'write-manifest', + displayName: 'Write Manifest', + type: 'build_manifest', + config: {assets: {main: {tomlKey: 'static_root'}}}, + } + + const result = asSingle(await executeBuildManifestStep(step, mockContext)) + + expect(result.assets).toEqual({main: {filepath: 'public'}}) + }) + + test('skips asset and logs when tomlKey is absent', async () => { + const step: BuildStep = { + id: 'write-manifest', + displayName: 'Write Manifest', + type: 'build_manifest', + config: {assets: {main: {tomlKey: 'missing_key'}}}, + } + + const result = asSingle(await executeBuildManifestStep(step, mockContext)) + + expect(result.assets).toEqual({}) + expect(mockStdout.write).toHaveBeenCalledWith( + expect.stringContaining("No value for tomlKey 'missing_key' in asset 'main'"), + ) + }) + + test('includes static flag from tomlKey entry', async () => { + mockContext = { + ...mockContext, + extension: {...mockExtension, configuration: {tools_file: 'tools.json'}} as unknown as ExtensionInstance, + } + + const step: BuildStep = { + id: 'write-manifest', + displayName: 'Write Manifest', + type: 'build_manifest', + config: {assets: {tools: {tomlKey: 'tools_file', static: true}}}, + } + + const result = asSingle(await executeBuildManifestStep(step, mockContext)) + + expect(result.assets).toEqual({tools: {filepath: 'tools.json', static: true}}) + }) + }) + + describe('composed filepath', () => { + test('builds filepath from tomlKey prefix + filename', async () => { + mockContext = { + ...mockContext, + extension: { + ...mockExtension, + configuration: {handle: 'my-ext', entry: './src/index.tsx'}, + } as unknown as ExtensionInstance, + } + + const step: BuildStep = { + id: 'write-manifest', + displayName: 'Write Manifest', + type: 'build_manifest', + config: { + assets: { + main: {filepath: {prefix: {tomlKey: 'handle'}, filename: '.js'}, module: {tomlKey: 'entry'}}, + }, + }, + } + + const result = asSingle(await executeBuildManifestStep(step, mockContext)) + + expect(result.assets.main).toEqual({filepath: 'my-ext.js', module: './src/index.tsx'}) + }) + + test('prepends path directory when provided', async () => { + mockContext = { + ...mockContext, + extension: {...mockExtension, configuration: {handle: 'my-ext'}} as unknown as ExtensionInstance, + } + + const step: BuildStep = { + id: 'write-manifest', + displayName: 'Write Manifest', + type: 'build_manifest', + config: { + assets: { + main: {filepath: {path: 'dist', prefix: {tomlKey: 'handle'}, filename: '.js'}}, + }, + }, + } + + const result = asSingle(await executeBuildManifestStep(step, mockContext)) + + expect(result.assets.main?.filepath).toBe('dist/my-ext.js') + }) + + test('skips asset and logs when prefix tomlKey is absent', async () => { + const step: BuildStep = { + id: 'write-manifest', + displayName: 'Write Manifest', + type: 'build_manifest', + config: { + assets: { + main: {filepath: {prefix: {tomlKey: 'handle'}, filename: '.js'}}, + }, + }, + } + + const result = asSingle(await executeBuildManifestStep(step, mockContext)) + + expect(result.assets).toEqual({}) + expect(mockStdout.write).toHaveBeenCalledWith(expect.stringContaining("Could not resolve filepath for asset 'main'")) + }) + + test('uses literal string prefix', async () => { + const step: BuildStep = { + id: 'write-manifest', + displayName: 'Write Manifest', + type: 'build_manifest', + config: { + assets: { + main: {filepath: {prefix: 'static', filename: '-bundle.js'}}, + }, + }, + } + + const result = asSingle(await executeBuildManifestStep(step, mockContext)) + + expect(result.assets.main?.filepath).toBe('static-bundle.js') + }) + }) + + describe('optional assets', () => { + test('silently skips optional asset when filepath cannot be resolved', async () => { + const step: BuildStep = { + id: 'write-manifest', + displayName: 'Write Manifest', + type: 'build_manifest', + config: { + assets: { + main: {filepath: 'index.js'}, + should_render: {filepath: {prefix: {tomlKey: 'handle'}, filename: '-conditions.js'}, optional: true}, + }, + }, + } + + const result = asSingle(await executeBuildManifestStep(step, mockContext)) + + expect(result.assets).toEqual({main: {filepath: 'index.js'}}) + expect(mockStdout.write).not.toHaveBeenCalledWith(expect.stringContaining('should_render')) + }) + + test('silently skips optional asset when module tomlKey is absent in item', async () => { + mockContext = { + ...mockContext, + extension: { + ...mockExtension, + configuration: { + handle: 'my-ext', + extension_points: [{target: 'checkout.render', module: './src/index.tsx'}], + }, + } as unknown as ExtensionInstance, + } + + const step: BuildStep = { + id: 'write-manifest', + displayName: 'Write Manifest', + type: 'build_manifest', + config: { + forEach: {tomlKey: 'extension_points', keyBy: 'target'}, + assets: { + main: { + filepath: {prefix: {tomlKey: 'handle'}, filename: '.js'}, + module: {tomlKey: 'module'}, + }, + should_render: { + filepath: {prefix: {tomlKey: 'handle'}, filename: '-conditions.js'}, + module: {tomlKey: 'should_render.module'}, + optional: true, + }, + }, + }, + } + + const result = asForEach(await executeBuildManifestStep(step, mockContext)) + + expect(result.manifests[0]!.build_manifest.assets).toEqual({ + main: {filepath: 'my-ext.js', module: './src/index.tsx'}, + }) + }) + }) + + describe('forEach — per-target iteration', () => { + test('produces one manifest per item in the iterated array', async () => { + mockContext = { + ...mockContext, + extension: { + ...mockExtension, + configuration: { + handle: 'my-ext', + extension_points: [ + {target: 'purchase.checkout.block.render', module: './src/checkout.tsx'}, + {target: 'admin.product-details.action.render', module: './src/admin.tsx'}, + ], + }, + } as unknown as ExtensionInstance, + } + + const step: BuildStep = { + id: 'write-manifest', + displayName: 'Write Manifest', + type: 'build_manifest', + config: { + forEach: {tomlKey: 'extension_points', keyBy: 'target'}, + assets: { + main: { + filepath: {prefix: {tomlKey: 'handle'}, filename: '.js'}, + module: {tomlKey: 'module'}, + }, + }, + }, + } + + const result = asForEach(await executeBuildManifestStep(step, mockContext)) + + expect(result.manifests).toHaveLength(2) + expect(result.manifests[0]).toEqual({ + target: 'purchase.checkout.block.render', + build_manifest: {assets: {main: {filepath: 'my-ext.js', module: './src/checkout.tsx'}}}, + }) + expect(result.manifests[1]).toEqual({ + target: 'admin.product-details.action.render', + build_manifest: {assets: {main: {filepath: 'my-ext.js', module: './src/admin.tsx'}}}, + }) + }) + + test('mirrors UIExtensionSchema shape with should_render and static tools', async () => { + mockContext = { + ...mockContext, + extension: { + ...mockExtension, + configuration: { + handle: 'my-ext', + extension_points: [ + { + target: 'purchase.checkout.block.render', + module: './src/checkout.tsx', + should_render: {module: './src/conditions.tsx'}, + tools: './src/tools.json', + }, + ], + }, + } as unknown as ExtensionInstance, + } + + const step: BuildStep = { + id: 'write-manifest', + displayName: 'Write Manifest', + type: 'build_manifest', + config: { + forEach: {tomlKey: 'extension_points', keyBy: 'target'}, + assets: { + main: { + filepath: {prefix: {tomlKey: 'handle'}, filename: '.js'}, + module: {tomlKey: 'module'}, + }, + should_render: { + filepath: {prefix: {tomlKey: 'handle'}, filename: '-conditions.js'}, + module: {tomlKey: 'should_render.module'}, + optional: true, + }, + tools: { + filepath: {prefix: {tomlKey: 'handle'}, filename: '-tools.json'}, + module: {tomlKey: 'tools'}, + static: true, + optional: true, + }, + }, + }, + } + + const result = asForEach(await executeBuildManifestStep(step, mockContext)) + + expect(result.manifests[0]!.build_manifest.assets).toEqual({ + main: {filepath: 'my-ext.js', module: './src/checkout.tsx'}, + should_render: {filepath: 'my-ext-conditions.js', module: './src/conditions.tsx'}, + tools: {filepath: 'my-ext-tools.json', module: './src/tools.json', static: true}, + }) + }) + + test('indexes into config-level nested arrays using the outer iteration index', async () => { + mockContext = { + ...mockContext, + extension: { + ...mockExtension, + configuration: { + extension_points: [ + {target: 'checkout.render', module: './src/checkout.tsx'}, + {target: 'admin.render', module: './src/admin.tsx'}, + ], + }, + } as unknown as ExtensionInstance, + } + + const step: BuildStep = { + id: 'write-manifest', + displayName: 'Write Manifest', + type: 'build_manifest', + config: { + forEach: {tomlKey: 'extension_points', keyBy: 'target'}, + assets: { + main: { + // Both prefix and module go through the config-level array → outer index used + filepath: {prefix: {tomlKey: 'extension_points.target'}, filename: '.js'}, + module: {tomlKey: 'extension_points.module'}, + }, + }, + }, + } + + const result = asForEach(await executeBuildManifestStep(step, mockContext)) + + expect(result.manifests[0]!.build_manifest.assets.main).toEqual({ + filepath: 'checkout.render.js', + module: './src/checkout.tsx', + }) + expect(result.manifests[1]!.build_manifest.assets.main).toEqual({ + filepath: 'admin.render.js', + module: './src/admin.tsx', + }) + }) + + test('expands assets when item tomlKey resolves to an inner array', async () => { + mockContext = { + ...mockContext, + extension: { + ...mockExtension, + configuration: { + handle: 'my-ext', + extension_points: [ + { + target: 'checkout.render', + module: './src/checkout.tsx', + should_render: [ + {module: './src/conditions-a.tsx'}, + {module: './src/conditions-b.tsx'}, + ], + }, + ], + }, + } as unknown as ExtensionInstance, + } + + const step: BuildStep = { + id: 'write-manifest', + displayName: 'Write Manifest', + type: 'build_manifest', + config: { + forEach: {tomlKey: 'extension_points', keyBy: 'target'}, + assets: { + main: { + filepath: {prefix: {tomlKey: 'handle'}, filename: '.js'}, + module: {tomlKey: 'module'}, + }, + should_render: { + filepath: {prefix: {tomlKey: 'handle'}, filename: '-conditions.js'}, + module: {tomlKey: 'should_render.module'}, + optional: true, + }, + }, + }, + } + + const result = asForEach(await executeBuildManifestStep(step, mockContext)) + + expect(result.manifests[0]!.build_manifest.assets).toEqual({ + main: {filepath: 'my-ext.js', module: './src/checkout.tsx'}, + should_render: [ + {filepath: 'my-ext-0-conditions.js', module: './src/conditions-a.tsx'}, + {filepath: 'my-ext-1-conditions.js', module: './src/conditions-b.tsx'}, + ], + }) + }) + + test('logs count and returns empty array when forEach tomlKey is not an array', async () => { + const step: BuildStep = { + id: 'write-manifest', + displayName: 'Write Manifest', + type: 'build_manifest', + config: { + forEach: {tomlKey: 'extension_points', keyBy: 'target'}, + assets: {main: {filepath: 'index.js'}}, + }, + } + + const result = asForEach(await executeBuildManifestStep(step, mockContext)) + + expect(result.manifests).toEqual([]) + expect(mockStdout.write).toHaveBeenCalledWith( + expect.stringContaining("No array found for forEach tomlKey 'extension_points'"), + ) + }) + + test('uses iteration index as prefix when prefix tomlKey resolves to an array', async () => { + mockContext = { + ...mockContext, + extension: { + ...mockExtension, + configuration: { + extension_points: [{target: 'a', module: './a.tsx'}, {target: 'b', module: './b.tsx'}], + }, + } as unknown as ExtensionInstance, + } + + const step: BuildStep = { + id: 'write-manifest', + displayName: 'Write Manifest', + type: 'build_manifest', + config: { + forEach: {tomlKey: 'extension_points', keyBy: 'target'}, + assets: { + main: { + // tomlKey resolves to the array itself → use index as prefix + filepath: {prefix: {tomlKey: 'extension_points'}, filename: '.js'}, + module: {tomlKey: 'module'}, + }, + }, + }, + } + + const result = asForEach(await executeBuildManifestStep(step, mockContext)) + + expect(result.manifests[0]!.build_manifest.assets.main?.filepath).toBe('0.js') + expect(result.manifests[1]!.build_manifest.assets.main?.filepath).toBe('1.js') + }) + + test('logs count in stdout on success', async () => { + mockContext = { + ...mockContext, + extension: { + ...mockExtension, + configuration: {extension_points: [{target: 'a', module: './a.tsx'}]}, + } as unknown as ExtensionInstance, + } + + const step: BuildStep = { + id: 'write-manifest', + displayName: 'Write Manifest', + type: 'build_manifest', + config: { + forEach: {tomlKey: 'extension_points', keyBy: 'target'}, + assets: {main: {filepath: 'index.js'}}, + }, + } + + await executeBuildManifestStep(step, mockContext) + + expect(mockStdout.write).toHaveBeenCalledWith('Build manifest written to build-manifest.json (1 entries)\n') + }) + }) + + describe('logging', () => { + test('logs manifest write to stdout', async () => { + const step: BuildStep = { + id: 'write-manifest', + displayName: 'Write Manifest', + type: 'build_manifest', + config: {assets: {main: {filepath: 'index.js'}}}, + } + + await executeBuildManifestStep(step, mockContext) + + expect(mockStdout.write).toHaveBeenCalledWith('Build manifest written to build-manifest.json\n') + }) + }) + + describe('output directory resolution', () => { + test('uses parent dir of outputPath when outputPath has a file extension', async () => { + const step: BuildStep = { + id: 'write-manifest', + displayName: 'Write Manifest', + type: 'build_manifest', + config: {assets: {main: {filepath: 'index.js'}}}, + } + + const result = asSingle(await executeBuildManifestStep(step, mockContext)) + + expect(result.outputFile).toBe('/test/output/build-manifest.json') + }) + + test('uses outputPath directly when it has no file extension', async () => { + mockContext = { + ...mockContext, + extension: {...mockExtension, outputPath: '/test/bundle-dir'} as unknown as ExtensionInstance, + } + + const step: BuildStep = { + id: 'write-manifest', + displayName: 'Write Manifest', + type: 'build_manifest', + config: {assets: {main: {filepath: 'index.js'}}}, + } + + const result = asSingle(await executeBuildManifestStep(step, mockContext)) + + expect(result.outputFile).toBe('/test/bundle-dir/build-manifest.json') + }) + }) +}) diff --git a/packages/app/src/cli/services/build/steps/build-manifest-step.ts b/packages/app/src/cli/services/build/steps/build-manifest-step.ts new file mode 100644 index 0000000000..9548f293f9 --- /dev/null +++ b/packages/app/src/cli/services/build/steps/build-manifest-step.ts @@ -0,0 +1,309 @@ +import {getNestedValue} from './utils.js' +import {writeFile, mkdir} from '@shopify/cli-kit/node/fs' +import {joinPath, dirname, extname} from '@shopify/cli-kit/node/path' +import {z} from 'zod' +import type {BuildStep, BuildContext} from '../build-steps.js' + +// ── Filepath ────────────────────────────────────────────────────────────── + +/** + * Prefix component of a composed filepath. + * + * - string: a literal value (e.g. "my-prefix") + * - {tomlKey}: resolved from the current forEach item first, falling back to + * the top-level extension config. If the resolved value is a scalar string + * it is used as-is. If it resolves to an array the current iteration index + * is used instead. + */ +const FilepathPrefixSchema = z.union([z.string(), z.object({tomlKey: z.string()})]) + +/** + * Composed filepath assembled as: `{path/}{prefix}{filename}` + * + * Example: path="dist", prefix={tomlKey:"handle"}, filename=".js" + * → "dist/my-ext.js" + */ +const ComposedFilepathSchema = z.object({ + path: z.string().optional(), + prefix: FilepathPrefixSchema, + filename: z.string(), +}) + +/** + * A filepath value. One of: + * - A literal string: "dist/index.js" + * - {tomlKey}: the whole filepath resolved from the extension config + * - A composed value built from path + prefix + filename + */ +const FilepathValueSchema = z.union([z.string(), z.object({tomlKey: z.string()}), ComposedFilepathSchema]) + +// ── Module ──────────────────────────────────────────────────────────────── + +/** + * A module path value resolved from a TOML key. + * In forEach context the key is looked up on the current item first, + * falling back to the top-level extension config. + */ +const ModuleValueSchema = z.object({tomlKey: z.string()}) + +// ── Asset entries ───────────────────────────────────────────────────────── + +/** + * Explicit asset entry with a composed or literal filepath. + * Set `optional: true` to silently skip the asset if any required field is absent. + */ +const ExplicitAssetEntrySchema = z.object({ + filepath: FilepathValueSchema, + module: ModuleValueSchema.optional(), + static: z.boolean().optional(), + optional: z.boolean().optional(), +}) + +/** + * Shorthand: resolve the entire filepath from a single tomlKey. + * Kept for backward compatibility with existing configs. + */ +const TomlKeyAssetEntrySchema = z.object({ + tomlKey: z.string(), + static: z.boolean().optional(), +}) + +const AssetEntrySchema = z.union([ExplicitAssetEntrySchema, TomlKeyAssetEntrySchema]) + +// ── forEach ─────────────────────────────────────────────────────────────── + +/** + * Iterates over a config array and produces one manifest per item. + * The output is an array of `{ [keyBy]: value, build_manifest: { assets } }` objects, + * mirroring the shape that UIExtensionSchema.transform attaches to each extension_point. + */ +const ForEachSchema = z.object({ + tomlKey: z.string(), // config key pointing to an array + keyBy: z.string(), // field in each item used to identify the manifest (e.g. 'target') +}) + +// ── Top-level config ────────────────────────────────────────────────────── + +const BuildManifestConfigSchema = z.object({ + /** Output filename relative to the extension output directory. @default 'build-manifest.json' */ + outputFile: z.string().default('build-manifest.json'), + + /** When set, iterates over the named config array and produces one manifest per item. */ + forEach: ForEachSchema.optional(), + + /** Map of asset identifier → asset configuration. */ + assets: z.record(z.string(), AssetEntrySchema), +}) + +// ── Types ───────────────────────────────────────────────────────────────── + +export type ResolvedAsset = {filepath: string; module?: string; static?: boolean} +export type ResolvedAssets = Record +export type PerItemManifest = {[key: string]: unknown; build_manifest: {assets: ResolvedAssets}} + +// ── Resolution helpers ──────────────────────────────────────────────────── + +function resolvePrefix( + prefix: z.infer, + config: Record, + item: Record | null, + index: number, +): string | undefined { + if (typeof prefix === 'string') return prefix + + // In forEach context try the current item first (no arrayIndex — inner arrays on + // the item are handled via expansion in resolveAssets, not indexed here). + // Fall back to the top-level config, using the outer index for config-level arrays. + if (item !== null) { + const value = getNestedValue(item, prefix.tomlKey) + if (typeof value === 'string') return value + } + + const value = getNestedValue(config, prefix.tomlKey, index) + if (typeof value === 'string') return value + if (Array.isArray(value)) return String(index) + return undefined +} + +function resolveFilepath( + filepath: z.infer, + config: Record, + item: Record | null, + index: number, + innerIndex?: number, +): string | undefined { + if (typeof filepath === 'string') return filepath + + // {tomlKey} shorthand — whole filepath from config + if ('tomlKey' in filepath && !('prefix' in filepath)) { + const value = getNestedValue(config, (filepath as {tomlKey: string}).tomlKey) + return typeof value === 'string' ? value : undefined + } + + // Composed: {path?, prefix, filename} + // When innerIndex is provided (inner-array expansion), it is inserted between + // the resolved prefix and the filename: {path/}{prefix}-{innerIndex}{filename} + const {path, prefix, filename} = filepath as z.infer + const resolvedPrefix = resolvePrefix(prefix, config, item, index) + if (resolvedPrefix === undefined) return undefined + const fullPrefix = innerIndex !== undefined ? `${resolvedPrefix}-${innerIndex}` : resolvedPrefix + return `${path ? `${path}/` : ''}${fullPrefix}${filename}` +} + +/** + * Resolves a module tomlKey. + * + * Returns: + * - `string` — a single resolved value + * - `string[]` — the item had a nested array for this path; the asset will be + * expanded into one entry per inner item by resolveAssets + * - `undefined` — could not resolve + */ +function resolveModule( + module: z.infer | undefined, + config: Record, + item: Record | null, + index: number, +): string | string[] | undefined { + if (module === undefined) return undefined + + // Try the current item without arrayIndex so that inner arrays are returned + // as-is (string[]) rather than indexed — expansion is handled by resolveAssets. + if (item !== null) { + const value = getNestedValue(item, module.tomlKey) + if (typeof value === 'string') return value + if (Array.isArray(value) && value.length > 0 && value.every((v) => typeof v === 'string')) + return value as string[] + } + + // Fall back to the top-level config, using the outer index for config-level arrays. + const value = getNestedValue(config, module.tomlKey, index) + return typeof value === 'string' ? value : undefined +} + + +function resolveAssets( + assetsDef: Record>, + config: Record, + item: Record | null, + index: number, + stdout: NodeJS.WritableStream, +): ResolvedAssets { + const resolved: ResolvedAssets = {} + + for (const [name, entry] of Object.entries(assetsDef)) { + // Backward-compat tomlKey shorthand (no filepath field) + if ('tomlKey' in entry && !('filepath' in entry)) { + const value = getNestedValue(config, (entry as {tomlKey: string}).tomlKey) + if (typeof value === 'string') { + resolved[name] = {filepath: value, ...(entry.static ? {static: entry.static} : {})} + } else { + stdout.write(`No value for tomlKey '${(entry as {tomlKey: string}).tomlKey}' in asset '${name}', skipping\n`) + } + continue + } + + // Explicit entry + const explicit = entry as z.infer + + const filepath = resolveFilepath(explicit.filepath, config, item, index) + if (filepath === undefined) { + if (!explicit.optional) stdout.write(`Could not resolve filepath for asset '${name}', skipping\n`) + continue + } + + const mod = resolveModule(explicit.module, config, item, index) + + if (Array.isArray(mod)) { + // Inner array: produce an array under the original key, mirroring the TOML structure. + // The inner index is inserted between the config-defined prefix and the filename. + resolved[name] = mod.map((innerMod, innerIndex) => ({ + filepath: resolveFilepath(explicit.filepath, config, item, index, innerIndex) ?? filepath, + module: innerMod, + ...(explicit.static ? {static: explicit.static} : {}), + })) + continue + } + + if (explicit.module !== undefined && mod === undefined) { + if (!explicit.optional) stdout.write(`Could not resolve module for asset '${name}', skipping\n`) + continue + } + + resolved[name] = { + filepath, + ...(mod !== undefined ? {module: mod} : {}), + ...(explicit.static ? {static: explicit.static} : {}), + } + } + + return resolved +} + +// ── Executor ────────────────────────────────────────────────────────────── + +/** + * Executes a build_manifest step. + * + * **Single mode** (no `forEach`): writes one manifest JSON with a flat `assets` map. + * + * **Per-target mode** (`forEach`): iterates the named config array and writes an array + * of `{ [keyBy]: value, build_manifest: { assets } }` objects — one per item. + * This mirrors the shape that `UIExtensionSchema.transform` produces on each + * `extension_point`, making it straightforward to feed the result back into the + * in-memory `extension.configuration.extension_points[].build_manifest`. + * + * Asset `filepath` values support three forms: + * - Literal string: `"dist/index.js"` + * - `{tomlKey}`: resolved from top-level extension config + * - Composed `{path?, prefix, filename}`: prefix from tomlKey, itemKey, or literal + * + * Assets marked `optional: true` are silently skipped when their filepath or module + * cannot be resolved. + */ +export async function executeBuildManifestStep( + step: BuildStep, + context: BuildContext, +): Promise<{outputFile: string; assets: ResolvedAssets} | {outputFile: string; manifests: PerItemManifest[]}> { + const config = BuildManifestConfigSchema.parse(step.config) + const {extension, options} = context + const outputDir = extname(extension.outputPath) ? dirname(extension.outputPath) : extension.outputPath + const outputFilePath = joinPath(outputDir, config.outputFile) + const extensionConfig = extension.configuration as Record + + let result: {outputFile: string; assets: Record} | {outputFile: string; manifests: PerItemManifest[]} + + if (config.forEach) { + const array = getNestedValue(extensionConfig, config.forEach.tomlKey) + + if (!Array.isArray(array)) { + options.stdout.write(`No array found for forEach tomlKey '${config.forEach.tomlKey}'\n`) + await writeManifest(outputFilePath, []) + result = {outputFile: outputFilePath, manifests: []} + } else { + const keyBy = config.forEach.keyBy + const manifests: PerItemManifest[] = array.flatMap((raw, index) => { + if (typeof raw !== 'object' || raw === null) return [] + const item = raw as Record + const assets = resolveAssets(config.assets, extensionConfig, item, index, options.stdout) + return [{[keyBy]: getNestedValue(item, keyBy), build_manifest: {assets}} as PerItemManifest] + }) + + await writeManifest(outputFilePath, manifests) + options.stdout.write(`Build manifest written to ${config.outputFile} (${manifests.length} entries)\n`) + result = {outputFile: outputFilePath, manifests} + } + } else { + const assets = resolveAssets(config.assets, extensionConfig, null, 0, options.stdout) + await writeManifest(outputFilePath, {assets}) + options.stdout.write(`Build manifest written to ${config.outputFile}\n`) + result = {outputFile: outputFilePath, assets} + } + + return result +} + +async function writeManifest(outputFilePath: string, content: unknown): Promise { + await mkdir(dirname(outputFilePath)) + await writeFile(outputFilePath, JSON.stringify(content, null, 2)) +} diff --git a/packages/app/src/cli/services/build/steps/copy-files-step.ts b/packages/app/src/cli/services/build/steps/copy-files-step.ts index 735e6be029..d236986746 100644 --- a/packages/app/src/cli/services/build/steps/copy-files-step.ts +++ b/packages/app/src/cli/services/build/steps/copy-files-step.ts @@ -1,3 +1,4 @@ +import {getNestedValue} from './utils.js' import {joinPath, dirname, extname, relativePath, basename} from '@shopify/cli-kit/node/path' import {glob, copyFile, copyDirectoryContents, fileExists, mkdir} from '@shopify/cli-kit/node/fs' import {z} from 'zod' @@ -234,39 +235,3 @@ async function copyByPattern( options.stdout.write(`Copied ${files.length} file(s) from ${sourceDir} to ${outputDir}\n`) return {filesCopied: files.length} } - -/** - * Resolves a dot-separated path from a config object. - * Handles TOML array-of-tables by plucking the next key across all elements. - */ -function getNestedValue(obj: {[key: string]: unknown}, path: string): unknown { - const parts = path.split('.') - let current: unknown = obj - - for (const part of parts) { - if (current === null || current === undefined) { - return undefined - } - - if (Array.isArray(current)) { - const plucked = current - .map((item) => { - if (typeof item === 'object' && item !== null && part in (item as object)) { - return (item as {[key: string]: unknown})[part] - } - return undefined - }) - .filter((item): item is NonNullable => item !== undefined) - current = plucked.length > 0 ? plucked : undefined - continue - } - - if (typeof current === 'object' && part in current) { - current = (current as {[key: string]: unknown})[part] - } else { - return undefined - } - } - - return current -} diff --git a/packages/app/src/cli/services/build/steps/index.ts b/packages/app/src/cli/services/build/steps/index.ts index 43b681f8a9..f8b787a91f 100644 --- a/packages/app/src/cli/services/build/steps/index.ts +++ b/packages/app/src/cli/services/build/steps/index.ts @@ -1,4 +1,5 @@ import {executeCopyFilesStep} from './copy-files-step.js' +import {executeBuildManifestStep} from './build-manifest-step.js' import {executeBuildThemeStep} from './build-theme-step.js' import {executeBundleThemeStep} from './bundle-theme-step.js' import {executeBundleUIStep} from './bundle-ui-step.js' @@ -21,6 +22,9 @@ export async function executeStepByType(step: BuildStep, context: BuildContext): case 'copy_files': return executeCopyFilesStep(step, context) + case 'build_manifest': + return executeBuildManifestStep(step, context) + case 'build_theme': return executeBuildThemeStep(step, context) diff --git a/packages/app/src/cli/services/build/steps/utils.ts b/packages/app/src/cli/services/build/steps/utils.ts new file mode 100644 index 0000000000..c886b4ae80 --- /dev/null +++ b/packages/app/src/cli/services/build/steps/utils.ts @@ -0,0 +1,45 @@ +/** + * Resolves a dot-separated path from a config object. + * + * When `arrayIndex` is provided, any array encountered mid-path is indexed + * into using that value rather than plucking the key across all elements. + * When omitted, the original plucking behaviour is preserved (used by + * copy_files and other callers that need all values from an array field). + */ +export function getNestedValue(obj: {[key: string]: unknown}, path: string, arrayIndex?: number): unknown { + const parts = path.split('.') + let current: unknown = obj + + for (const part of parts) { + if (current === null || current === undefined) { + return undefined + } + + if (Array.isArray(current)) { + if (arrayIndex !== undefined) { + const item = current[arrayIndex] + if (item == null || typeof item !== 'object') return undefined + current = (item as {[key: string]: unknown})[part] + } else { + const plucked = current + .map((item) => { + if (typeof item === 'object' && item !== null && part in (item as object)) { + return (item as {[key: string]: unknown})[part] + } + return undefined + }) + .filter((item): item is NonNullable => item !== undefined) + current = plucked.length > 0 ? plucked : undefined + } + continue + } + + if (typeof current === 'object' && part in current) { + current = (current as {[key: string]: unknown})[part] + } else { + return undefined + } + } + + return current +}