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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@
"h3": "0.7.21",
"http-proxy-node16": "1.0.6",
"ignore": "6.0.2",
"json-schema-to-typescript": "15.0.4",
"prettier": "2.8.8",
"proper-lockfile": "4.1.2",
"react": "^18.2.0",
"react-dom": "18.3.1",
Expand All @@ -78,6 +80,7 @@
"@types/body-parser": "^1.19.2",
"@types/diff": "^5.0.3",
"@types/express": "^4.17.17",
"@types/prettier": "^2.7.3",
"@types/proper-lockfile": "4.1.4",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ import {fileExists, findPathUp, readFileSync} from '@shopify/cli-kit/node/fs'
import {dirname, 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'
import {pascalize} from '@shopify/cli-kit/common/string'
import {zod} from '@shopify/cli-kit/node/schema'
import {createRequire} from 'module'

const require = createRequire(import.meta.url)
Expand Down Expand Up @@ -159,6 +162,7 @@ export function createTypeDefinition(
typeFilePath: string,
targets: string[],
apiVersion: string,
toolsTypeDefinition?: string,
): string | null {
try {
// Validate that all targets can be resolved
Expand All @@ -177,12 +181,14 @@ export function createTypeDefinition(

const relativePath = relativizePath(fullPath, dirname(typeFilePath))

const toolsType = toolsTypeDefinition ? ` & { tools: ShopifyTools }` : ''
const toolsDefinition = toolsTypeDefinition ? `\n ${toolsTypeDefinition}` : ''
if (targets.length === 1) {
const target = targets[0] ?? ''
return `//@ts-ignore\ndeclare module './${relativePath}' {\n const shopify: import('@shopify/ui-extensions/${target}').Api;\n const globalThis: { shopify: typeof shopify };\n}\n`
return `//@ts-ignore\ndeclare module './${relativePath}' {${toolsDefinition}\n const shopify: import('@shopify/ui-extensions/${target}').Api${toolsType};\n const globalThis: { shopify: typeof shopify };\n}\n`
} else if (targets.length > 1) {
const unionType = targets.map((target) => ` import('@shopify/ui-extensions/${target}').Api`).join(' |\n')
return `//@ts-ignore\ndeclare module './${relativePath}' {\n const shopify: \n${unionType};\n const globalThis: { shopify: typeof shopify };\n}\n`
return `//@ts-ignore\ndeclare module './${relativePath}' {${toolsDefinition}\n const shopify: \n${unionType}${toolsType};\n const globalThis: { shopify: typeof shopify };\n}\n`
}

return null
Expand Down Expand Up @@ -216,3 +222,90 @@ export async function findNearestTsConfigDir(
}
}
}

interface ToolDefinition {
name: string
description: string
inputSchema: object
outputSchema?: object
}
const ToolDefinitionSchema: zod.ZodType<ToolDefinition> = zod.object({
name: zod.string(),
description: zod.string(),
inputSchema: zod.object({}).passthrough(),
outputSchema: zod.object({}).passthrough().optional(),
})

export const ToolsFileSchema = zod.array(ToolDefinitionSchema)

/**
* Generates TypeScript types for shopify.tools.register based on tool definitions
* @param tools - Array of tool definitions from tools.json
* @returns TypeScript declaration string
*/
export async function createToolsTypeDefinition(tools: ToolDefinition[]): Promise<string> {
if (tools.length === 0) return ''

const toolNames = new Set<string>()
const typePromises = tools.map(async (tool) => {
// Tool names must be unique within a tools file
if (toolNames.has(tool.name)) {
throw new AbortError(
`Tool name "${tool.name}" is defined multiple times. Tool names must be unique within a tools file.`,
)
}
toolNames.add(tool.name)

// Generate input type definition
const inputTypeName = pascalize(`${tool.name}Input`)
let inputType = tool.inputSchema
? await compile(tool.inputSchema, inputTypeName, {
bannerComment: '',
unknownAny: true,
})
: `type ${inputTypeName} = unknown`
// The json-schema-to-typescript library adds an export keyword to the type definition, we need to remove it
if (inputType.startsWith('export ')) {
inputType = inputType.slice(7)
}

// Generate output type definition
const outputTypeName = pascalize(`${tool.name}Output`)
let outputType = tool.outputSchema
? await compile(tool.outputSchema, outputTypeName, {
bannerComment: '',
unknownAny: true,
})
: `type ${outputTypeName} = unknown`
// The json-schema-to-typescript library adds an export keyword to the type definition, we need to remove it
if (outputType.startsWith('export ')) {
outputType = outputType.slice(7)
}

return {
name: tool.name,
description: tool.description,
inputType,
outputType,
inputTypeName,
outputTypeName,
}
})

const types = await Promise.all(typePromises)

const toolRegistrations = types
.map(({name, description, inputTypeName, outputTypeName}) => {
const formattedDescription = description
.replace(/\*\//g, '*\\/')
.split('\n')
.map((line) => ` * ${line}`)
.join('\n')
return `/**\n${formattedDescription}\n */\nregister(name: '${name}', handler: (input: ${inputTypeName}) => ${outputTypeName} | Promise<${outputTypeName}>);`
})
.join('\n')

return `${types
.map(({inputType, outputType}) => `${inputType}\n${outputType}`)
.join('\n')}\ninterface ShopifyTools {\n${toolRegistrations}\n}\n`
}
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ describe('ui_extension', async () => {
expect(got.extension_points).toStrictEqual([
{
target: 'EXTENSION::POINT::A',
tools: undefined,
module: './src/ExtensionPointA.js',
metafields: [{namespace: 'test', key: 'test'}],
default_placement_reference: undefined,
Expand Down Expand Up @@ -213,6 +214,7 @@ describe('ui_extension', async () => {
expect(got.extension_points).toStrictEqual([
{
target: 'EXTENSION::POINT::A',
tools: undefined,
module: './src/ExtensionPointA.js',
metafields: [],
default_placement_reference: 'PLACEMENT_REFERENCE1',
Expand Down Expand Up @@ -274,6 +276,7 @@ describe('ui_extension', async () => {
expect(got.extension_points).toStrictEqual([
{
target: 'EXTENSION::POINT::A',
tools: undefined,
module: './src/ExtensionPointA.js',
metafields: [],
urls: {},
Expand Down Expand Up @@ -335,6 +338,7 @@ describe('ui_extension', async () => {
expect(got.extension_points).toStrictEqual([
{
target: 'EXTENSION::POINT::A',
tools: undefined,
module: './src/ExtensionPointA.js',
metafields: [],
default_placement_reference: undefined,
Expand Down Expand Up @@ -399,6 +403,7 @@ describe('ui_extension', async () => {
expect(got.extension_points).toStrictEqual([
{
target: 'EXTENSION::POINT::A',
tools: undefined,
module: './src/ExtensionPointA.js',
metafields: [],
default_placement_reference: undefined,
Expand Down Expand Up @@ -465,6 +470,7 @@ describe('ui_extension', async () => {
expect(got.extension_points).toStrictEqual([
{
target: 'EXTENSION::POINT::A',
tools: undefined,
module: './src/ExtensionPointA.js',
metafields: [],
default_placement_reference: undefined,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,21 @@
import {findAllImportedFiles, createTypeDefinition, findNearestTsConfigDir, parseApiVersion} from './type-generation.js'
import {
findAllImportedFiles,
createTypeDefinition,
findNearestTsConfigDir,
parseApiVersion,
createToolsTypeDefinition,
ToolsFileSchema,
} from './type-generation.js'
import {Asset, AssetIdentifier, ExtensionFeature, createExtensionSpecification} from '../specification.js'
import {NewExtensionPointSchemaType, NewExtensionPointsSchema, BaseSchema, MetafieldSchema} from '../schemas.js'
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} from '@shopify/cli-kit/node/fs'
import {copyFile, fileExists, readFile} from '@shopify/cli-kit/node/fs'
import {joinPath, basename, dirname} from '@shopify/cli-kit/node/path'
import {outputContent, outputToken} from '@shopify/cli-kit/node/output'
import {outputContent, outputToken, outputWarn} from '@shopify/cli-kit/node/output'
import {zod} from '@shopify/cli-kit/node/schema'

const dependency = '@shopify/checkout-ui-extensions'
Expand Down Expand Up @@ -71,6 +79,7 @@ export const UIExtensionSchema = BaseSchema.extend({
}

return {
tools: targeting.tools,
target: targeting.target,
module: targeting.module,
metafields: targeting.metafields ?? config.metafields ?? [],
Expand Down Expand Up @@ -194,6 +203,7 @@ const uiExtensionSpec = createExtensionSpecification({

// Track all files and their associated targets
const fileToTargetsMap = new Map<string, string[]>()
const fileToToolsMap = new Map<string, string>()

// First pass: collect all entry point files and their targets
for await (const extensionPoint of configuration.extension_points) {
Expand All @@ -206,6 +216,10 @@ const uiExtensionSpec = createExtensionSpecification({
currentTargets.push(extensionPoint.target)
fileToTargetsMap.set(fullPath, currentTargets)

// Add tools module if present
if (extensionPoint.tools) {
fileToToolsMap.set(fullPath, extensionPoint.tools)
}
// Add should render module if present
if (extensionPoint.build_manifest.assets[AssetIdentifier.ShouldRender]?.module) {
const shouldRenderPath = joinPath(
Expand Down Expand Up @@ -266,9 +280,51 @@ const uiExtensionSpec = createExtensionSpecification({
const uniqueTargets = [...new Set(targets)]

try {
const typeDefinition = createTypeDefinition(filePath, typeFilePath, uniqueTargets, configuration.api_version)
const toolsDefinition = fileToToolsMap.get(filePath)
let toolsTypeDefinition = ''
if (toolsDefinition) {
try {
const toolsFilePath = joinPath(extension.directory, toolsDefinition)
if (await fileExists(toolsFilePath)) {
// Read and parse the tools JSON file
const toolsContent = await readFile(toolsFilePath)
const tools = ToolsFileSchema.safeParse(JSON.parse(toolsContent))
if (tools.success) {
// Generate tools type definition
toolsTypeDefinition = await createToolsTypeDefinition(tools.data)
} else {
outputWarn(
`Invalid tools definition in "${toolsDefinition}": ${tools.error.issues
.map((issue) => issue.message)
.join(', ')}`,
)
toolsTypeDefinition = ''
}
} else {
outputWarn(
`Tools file "${toolsDefinition}" not found. Make sure the path defined in the "tools" field in the extension point configuration is correct.`,
)
}
// eslint-disable-next-line no-catch-all/no-catch-all
} catch (error) {
outputWarn(
`Failed to create tools type definition for tools file "${toolsDefinition}": ${
error instanceof Error ? error.message : 'Unknown error'
}`,
)
toolsTypeDefinition = ''
}
}
let typeDefinition = createTypeDefinition(
filePath,
typeFilePath,
uniqueTargets,
configuration.api_version,
toolsTypeDefinition,
)
if (typeDefinition) {
const currentTypes = typeDefinitionsByFile.get(typeFilePath) ?? new Set<string>()
typeDefinition = await formatContent(typeDefinition, {parser: 'typescript'})
currentTypes.add(typeDefinition)
typeDefinitionsByFile.set(typeFilePath, currentTypes)
}
Expand Down
12 changes: 12 additions & 0 deletions packages/app/src/cli/utilities/file-formatter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import prettier from 'prettier'
import type {Options} from 'prettier'

/**
* Formats the contents of a file being generated.
* @param content - The content to format.
* @param options - The options to pass to the formatter.
* @returns The formatted content.
*/
export async function formatContent(content: string, options?: Options): Promise<string> {
return prettier.format(content, options)
}
Loading
Loading