Skip to content

Commit 5ffb466

Browse files
committed
Merge pull request #6385 from Shopify/create-types-for-multiple-files
Enable traversal of imported files in ui-extensions
1 parent 7d927e9 commit 5ffb466

File tree

4 files changed

+883
-75
lines changed

4 files changed

+883
-75
lines changed

.changeset/clean-squids-report.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@shopify/app': patch
3+
---
4+
5+
Add support for generating types for files imported by the main extension file
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
import {fileExists, findPathUp, readFileSync} from '@shopify/cli-kit/node/fs'
2+
import {dirname, joinPath, relativizePath, resolvePath} from '@shopify/cli-kit/node/path'
3+
import {AbortError} from '@shopify/cli-kit/node/error'
4+
import ts from 'typescript'
5+
import {createRequire} from 'module'
6+
7+
const require = createRequire(import.meta.url)
8+
9+
export function parseApiVersion(apiVersion: string): {year: number; month: number} | null {
10+
const [year, month] = apiVersion.split('-')
11+
if (!year || !month) {
12+
return null
13+
}
14+
return {year: parseInt(year, 10), month: parseInt(month, 10)}
15+
}
16+
17+
function loadTsConfig(startPath: string): {compilerOptions: ts.CompilerOptions; configPath: string | undefined} {
18+
const configPath = ts.findConfigFile(startPath, ts.sys.fileExists.bind(ts.sys), 'tsconfig.json')
19+
if (!configPath) {
20+
return {compilerOptions: {}, configPath: undefined}
21+
}
22+
23+
const configFile = ts.readConfigFile(configPath, ts.sys.readFile.bind(ts.sys))
24+
if (configFile.error) {
25+
return {compilerOptions: {}, configPath}
26+
}
27+
28+
const parsedConfig = ts.parseJsonConfigFileContent(configFile.config, ts.sys, dirname(configPath))
29+
30+
return {compilerOptions: parsedConfig.options, configPath}
31+
}
32+
33+
async function fallbackResolve(importPath: string, baseDir: string): Promise<string | null> {
34+
// Only handle relative imports in fallback
35+
if (!importPath.startsWith('./') && !importPath.startsWith('../')) {
36+
return null
37+
}
38+
39+
const resolvedPath = resolvePath(baseDir, importPath)
40+
const extensions = ['', '.js', '.jsx', '.ts', '.tsx']
41+
42+
// Try different extensions
43+
for (const ext of extensions) {
44+
const pathWithExt = resolvedPath + ext
45+
// eslint-disable-next-line no-await-in-loop
46+
if ((await fileExists(pathWithExt)) && !pathWithExt.includes('node_modules')) {
47+
return pathWithExt
48+
}
49+
}
50+
51+
// Try as directory with index files
52+
for (const ext of ['.js', '.jsx', '.ts', '.tsx']) {
53+
const indexPath = joinPath(resolvedPath, `index${ext}`)
54+
// eslint-disable-next-line no-await-in-loop
55+
if ((await fileExists(indexPath)) && !indexPath.includes('node_modules')) {
56+
return indexPath
57+
}
58+
}
59+
60+
return null
61+
}
62+
63+
async function parseAndResolveImports(filePath: string): Promise<string[]> {
64+
try {
65+
const content = readFileSync(filePath).toString()
66+
const resolvedPaths: string[] = []
67+
68+
// Load TypeScript configuration once
69+
const {compilerOptions} = loadTsConfig(filePath)
70+
71+
// Determine script kind based on file extension
72+
let scriptKind = ts.ScriptKind.JSX
73+
if (filePath.endsWith('.ts')) {
74+
scriptKind = ts.ScriptKind.TS
75+
} else if (filePath.endsWith('.tsx')) {
76+
scriptKind = ts.ScriptKind.TSX
77+
}
78+
79+
const sourceFile = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true, scriptKind)
80+
81+
const processedImports = new Set<string>()
82+
const importPaths: string[] = []
83+
84+
const visit = (node: ts.Node): void => {
85+
if (ts.isImportDeclaration(node) && node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier)) {
86+
importPaths.push(node.moduleSpecifier.text)
87+
} else if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword) {
88+
const firstArg = node.arguments[0]
89+
if (firstArg && ts.isStringLiteral(firstArg)) {
90+
importPaths.push(firstArg.text)
91+
}
92+
} else if (ts.isExportDeclaration(node) && node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier)) {
93+
importPaths.push(node.moduleSpecifier.text)
94+
}
95+
96+
ts.forEachChild(node, visit)
97+
}
98+
99+
visit(sourceFile)
100+
101+
for (const importPath of importPaths) {
102+
// Skip if already processed
103+
if (!importPath || processedImports.has(importPath)) {
104+
continue
105+
}
106+
107+
processedImports.add(importPath)
108+
109+
// Use TypeScript's module resolution to resolve potential "paths" configurations
110+
const resolvedModule = ts.resolveModuleName(importPath, filePath, compilerOptions, ts.sys)
111+
if (resolvedModule.resolvedModule?.resolvedFileName) {
112+
const resolvedPath = resolvedModule.resolvedModule.resolvedFileName
113+
114+
if (!resolvedPath.includes('node_modules')) {
115+
resolvedPaths.push(resolvedPath)
116+
}
117+
} else {
118+
// Fallback to manual resolution for edge cases
119+
// eslint-disable-next-line no-await-in-loop
120+
const fallbackPath = await fallbackResolve(importPath, dirname(filePath))
121+
if (fallbackPath) {
122+
resolvedPaths.push(fallbackPath)
123+
}
124+
}
125+
}
126+
127+
return resolvedPaths
128+
} catch (error) {
129+
// Re-throw AbortError as-is, wrap other errors
130+
if (error instanceof AbortError) {
131+
throw error
132+
}
133+
return []
134+
}
135+
}
136+
137+
export async function findAllImportedFiles(filePath: string, visited = new Set<string>()): Promise<string[]> {
138+
if (visited.has(filePath)) {
139+
return []
140+
}
141+
142+
visited.add(filePath)
143+
const resolvedPaths = await parseAndResolveImports(filePath)
144+
145+
const allFiles = [...resolvedPaths]
146+
147+
// Recursively find imports from the resolved files
148+
for (const resolvedPath of resolvedPaths) {
149+
// eslint-disable-next-line no-await-in-loop
150+
const nestedImports = await findAllImportedFiles(resolvedPath, visited)
151+
allFiles.push(...nestedImports)
152+
}
153+
154+
return [...new Set(allFiles)]
155+
}
156+
157+
export function createTypeDefinition(
158+
fullPath: string,
159+
typeFilePath: string,
160+
targets: string[],
161+
apiVersion: string,
162+
): string | null {
163+
try {
164+
// Validate that all targets can be resolved
165+
for (const target of targets) {
166+
try {
167+
require.resolve(`@shopify/ui-extensions/${target}`, {paths: [fullPath, typeFilePath]})
168+
} catch (_) {
169+
const {year, month} = parseApiVersion(apiVersion) ?? {year: 2025, month: 10}
170+
// Throw specific error for the target that failed, matching the original getSharedTypeDefinition behavior
171+
throw new AbortError(
172+
`Type reference for ${target} could not be found. You might be using the wrong @shopify/ui-extensions version.`,
173+
`Fix the error by ensuring you have the correct version of @shopify/ui-extensions, for example ~${year}.${month}.0, in your dependencies.`,
174+
)
175+
}
176+
}
177+
178+
const relativePath = relativizePath(fullPath, dirname(typeFilePath))
179+
180+
if (targets.length === 1) {
181+
const target = targets[0] ?? ''
182+
return `//@ts-ignore\ndeclare module './${relativePath}' {\n const shopify: import('@shopify/ui-extensions/${target}').Api;\n const globalThis: { shopify: typeof shopify };\n}\n`
183+
} else if (targets.length > 1) {
184+
const unionType = targets.map((target) => ` import('@shopify/ui-extensions/${target}').Api`).join(' |\n')
185+
return `//@ts-ignore\ndeclare module './${relativePath}' {\n const shopify: \n${unionType};\n const globalThis: { shopify: typeof shopify };\n}\n`
186+
}
187+
188+
return null
189+
} catch (error) {
190+
// Re-throw AbortError as-is, wrap other errors
191+
if (error instanceof AbortError) {
192+
throw error
193+
}
194+
const {year, month} = parseApiVersion(apiVersion) ?? {year: 2025, month: 10}
195+
throw new AbortError(
196+
`Type reference could not be found. You might be using the wrong @shopify/ui-extensions version.`,
197+
`Fix the error by ensuring you have the correct version of @shopify/ui-extensions, for example ~${year}.${month}.0, in your dependencies.`,
198+
)
199+
}
200+
}
201+
202+
export async function findNearestTsConfigDir(
203+
fromFile: string,
204+
extensionDirectory: string,
205+
): Promise<string | undefined> {
206+
const fromDirectory = dirname(fromFile)
207+
const tsconfigPath = await findPathUp('tsconfig.json', {cwd: fromDirectory, type: 'file'})
208+
209+
if (tsconfigPath) {
210+
// Normalize both paths for cross-platform comparison
211+
const normalizedTsconfigPath = resolvePath(tsconfigPath)
212+
const normalizedExtensionDirectory = resolvePath(extensionDirectory)
213+
214+
if (normalizedTsconfigPath.startsWith(normalizedExtensionDirectory)) {
215+
return dirname(tsconfigPath)
216+
}
217+
}
218+
}

0 commit comments

Comments
 (0)