@shopify/cli-hydrogen 5.4.2 → 5.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/dist/commands/hydrogen/debug/cpu.js +98 -0
  2. package/dist/commands/hydrogen/dev.js +3 -4
  3. package/dist/commands/hydrogen/init.test.js +2 -2
  4. package/dist/generator-templates/starter/app/root.tsx +3 -1
  5. package/dist/generator-templates/starter/app/routes/account.orders._index.tsx +1 -1
  6. package/dist/generator-templates/starter/app/routes/account_.recover.tsx +7 -2
  7. package/dist/generator-templates/starter/app/routes/account_.register.tsx +3 -3
  8. package/dist/generator-templates/starter/app/routes/blogs.$blogHandle._index.tsx +1 -0
  9. package/dist/generator-templates/starter/package.json +4 -3
  10. package/dist/generator-templates/starter/storefrontapi.generated.d.ts +4 -4
  11. package/dist/hooks/init.js +4 -1
  12. package/dist/lib/cpu-profiler.js +92 -0
  13. package/dist/lib/mini-oxygen/node.js +5 -5
  14. package/dist/lib/mini-oxygen/workerd.js +21 -14
  15. package/dist/lib/onboarding/common.js +2 -2
  16. package/dist/lib/request-events.js +20 -16
  17. package/dist/lib/setups/i18n/domains.test.js +14 -0
  18. package/dist/lib/setups/i18n/index.js +3 -3
  19. package/dist/lib/setups/i18n/replacers.js +84 -64
  20. package/dist/lib/setups/i18n/replacers.test.js +242 -0
  21. package/dist/lib/setups/i18n/subdomains.test.js +14 -0
  22. package/dist/lib/setups/i18n/subfolders.test.js +14 -0
  23. package/dist/lib/setups/i18n/templates/domains.js +1 -1
  24. package/dist/lib/setups/i18n/templates/domains.ts +5 -2
  25. package/dist/lib/setups/i18n/templates/subdomains.ts +4 -1
  26. package/dist/lib/setups/i18n/templates/subfolders.js +1 -2
  27. package/dist/lib/setups/i18n/templates/subfolders.ts +7 -6
  28. package/dist/lib/setups/routes/generate.js +5 -2
  29. package/dist/lib/transpile/file.js +6 -0
  30. package/dist/lib/transpile/index.js +2 -0
  31. package/dist/lib/transpile/morph/classes.js +48 -0
  32. package/dist/lib/transpile/morph/functions.js +76 -0
  33. package/dist/lib/transpile/morph/index.js +67 -0
  34. package/dist/lib/transpile/morph/typedefs.js +133 -0
  35. package/dist/lib/transpile/morph/utils.js +15 -0
  36. package/dist/lib/{transpile-ts.js → transpile/project.js} +38 -59
  37. package/oclif.manifest.json +27 -1
  38. package/package.json +7 -7
@@ -16,16 +16,16 @@ const I18N_STRATEGY_NAME_MAP = {
16
16
  };
17
17
  const I18N_CHOICES = [...SETUP_I18N_STRATEGIES, "none"];
18
18
  async function setupI18nStrategy(strategy, options) {
19
- const isTs = options.serverEntryPoint?.endsWith(".ts") ?? false;
19
+ const isJs = options.serverEntryPoint?.endsWith(".js") ?? false;
20
20
  const templatePath = fileURLToPath(
21
- new URL(`./templates/${strategy}${isTs ? ".ts" : ".js"}`, import.meta.url)
21
+ new URL(`./templates/${strategy}.ts`, import.meta.url)
22
22
  );
23
23
  if (!await fileExists(templatePath)) {
24
24
  throw new Error("Unknown strategy");
25
25
  }
26
26
  const template = await readFile(templatePath);
27
27
  const formatConfig = await getCodeFormatOptions(options.rootDirectory);
28
- await replaceServerI18n(options, formatConfig, template);
28
+ await replaceServerI18n(options, formatConfig, template, isJs);
29
29
  await replaceRemixEnv(options, formatConfig, template);
30
30
  }
31
31
  async function renderI18nPrompt(options) {
@@ -1,10 +1,11 @@
1
1
  import { AbortError } from '@shopify/cli-kit/node/error';
2
- import { joinPath, relativePath } from '@shopify/cli-kit/node/path';
2
+ import { joinPath } from '@shopify/cli-kit/node/path';
3
3
  import { fileExists } from '@shopify/cli-kit/node/fs';
4
4
  import { replaceFileContent, findFileWithExtension } from '../../file.js';
5
5
  import { importLangAstGrep } from '../../ast.js';
6
+ import { transpileFile } from '../../transpile/index.js';
6
7
 
7
- async function replaceServerI18n({ rootDirectory, serverEntryPoint = "server" }, formatConfig, localeExtractImplementation) {
8
+ async function replaceServerI18n({ rootDirectory, serverEntryPoint = "server" }, formatConfig, localeExtractImpl, isJs) {
8
9
  const { filepath, astType } = await findEntryFile({
9
10
  rootDirectory,
10
11
  serverEntryPoint
@@ -34,7 +35,7 @@ async function replaceServerI18n({ rootDirectory, serverEntryPoint = "server" },
34
35
  }
35
36
  });
36
37
  const requestIdentifierName = requestIdentifier?.text() ?? "request";
37
- const i18nFunctionName = localeExtractImplementation.match(
38
+ const i18nFunctionName = localeExtractImpl.match(
38
39
  /^(export )?function (\w+)/m
39
40
  )?.[2];
40
41
  if (!i18nFunctionName) {
@@ -91,6 +92,36 @@ async function replaceServerI18n({ rootDirectory, serverEntryPoint = "server" },
91
92
  `Please add a call to ${importName}({...})`
92
93
  );
93
94
  }
95
+ const defaultExportObject = root.find({
96
+ rule: {
97
+ kind: "export_statement",
98
+ regex: "^export default \\{"
99
+ }
100
+ });
101
+ if (!defaultExportObject) {
102
+ throw new AbortError(
103
+ "Could not find a default export in the server entry point"
104
+ );
105
+ }
106
+ let localeExtractFn = localeExtractImpl.match(/^(\/\*\*.*?\*\/\n)?^function .+?^}/ms)?.[0] || "";
107
+ if (!localeExtractFn) {
108
+ throw new AbortError(
109
+ "Could not find the locale extract function. This is a bug in Hydrogen."
110
+ );
111
+ }
112
+ if (isJs) {
113
+ localeExtractFn = await transpileFile(
114
+ localeExtractFn,
115
+ "locale-extract-server.ts"
116
+ );
117
+ } else {
118
+ localeExtractFn = localeExtractFn.replace(/\/\*\*.*?\*\//gms, "");
119
+ }
120
+ const defaultExportEnd = defaultExportObject.range().end.index;
121
+ content = content.slice(0, defaultExportEnd) + `
122
+
123
+ ${localeExtractFn}
124
+ ` + content.slice(defaultExportEnd);
94
125
  const i18nProperty = argumentObject.find({
95
126
  rule: {
96
127
  kind: "property_identifier",
@@ -112,49 +143,21 @@ async function replaceServerI18n({ rootDirectory, serverEntryPoint = "server" },
112
143
  const firstPart = content.slice(0, end.index - 1);
113
144
  content = firstPart + ((/,\s*$/.test(firstPart) ? "" : ",") + `i18n: ${i18nFunctionCall}`) + content.slice(end.index - 1);
114
145
  }
115
- const importTypes = localeExtractImplementation.match(
116
- /import\s+type\s+[^;]+?;/
117
- )?.[0];
118
- if (importTypes) {
119
- localeExtractImplementation = localeExtractImplementation.replace(
120
- importTypes,
121
- ""
122
- );
123
- const lastImportNode = root.findAll({ rule: { kind: "import_statement" } }).pop();
124
- if (lastImportNode) {
125
- const lastImportContent = lastImportNode.text();
126
- content = content.replace(
127
- lastImportContent,
128
- lastImportContent + "\n" + importTypes.replace(
129
- /'[^']+'/,
130
- `'@shopify/hydrogen/storefront-api-types'`
131
- )
132
- );
133
- }
134
- }
135
- return content + `
136
-
137
- ${localeExtractImplementation.replace(/^export function/m, "function").replace(/^export {.*?;/m, "")}
138
- `;
146
+ return content;
139
147
  });
140
148
  }
141
- async function replaceRemixEnv({ rootDirectory, serverEntryPoint }, formatConfig, localeExtractImplementation) {
149
+ async function replaceRemixEnv({ rootDirectory }, formatConfig, localeExtractImpl) {
142
150
  const remixEnvPath = joinPath(rootDirectory, "remix.env.d.ts");
143
151
  if (!await fileExists(remixEnvPath)) {
144
152
  return;
145
153
  }
146
- const i18nTypeName = localeExtractImplementation.match(/export type (\w+)/)?.[1];
154
+ const i18nType = localeExtractImpl.match(
155
+ /^(export )?(type \w+ =\s+\{.*?\};)\n/ms
156
+ )?.[2];
157
+ const i18nTypeName = i18nType?.match(/^type (\w+)/)?.[1];
147
158
  if (!i18nTypeName) {
148
159
  return;
149
160
  }
150
- const { filepath: entryFilepath } = await findEntryFile({
151
- rootDirectory,
152
- serverEntryPoint
153
- });
154
- const relativePathToEntry = relativePath(
155
- rootDirectory,
156
- entryFilepath
157
- ).replace(/.[tj]sx?$/, "");
158
161
  await replaceFileContent(remixEnvPath, formatConfig, async (content) => {
159
162
  if (content.includes(`Storefront<`))
160
163
  return;
@@ -176,38 +179,55 @@ async function replaceRemixEnv({ rootDirectory, serverEntryPoint }, formatConfig
176
179
  }
177
180
  }
178
181
  });
179
- if (!storefrontTypeNode) {
180
- return;
182
+ if (storefrontTypeNode) {
183
+ const storefrontTypeNodeRange = storefrontTypeNode.range();
184
+ content = content.slice(0, storefrontTypeNodeRange.end.index) + `<${i18nTypeName}>` + content.slice(storefrontTypeNodeRange.end.index);
181
185
  }
182
- const { end } = storefrontTypeNode.range();
183
- content = content.slice(0, end.index) + `<${i18nTypeName}>` + content.slice(end.index);
184
- const serverImportNode = root.findAll({
186
+ const ambientDeclarationContentNode = root.find({
185
187
  rule: {
186
- kind: "import_statement",
187
- has: {
188
- kind: "string_fragment",
189
- stopBy: "end",
190
- regex: `^(\\./)?${relativePathToEntry.replaceAll(
191
- ".",
192
- "\\."
193
- )}(\\.[jt]sx?)?$`
188
+ kind: "statement_block",
189
+ inside: {
190
+ kind: "ambient_declaration"
194
191
  }
195
192
  }
196
- }).pop();
197
- if (serverImportNode) {
198
- content = content.replace(
199
- serverImportNode.text(),
200
- serverImportNode.text().replace("{", `{${i18nTypeName},`)
201
- );
193
+ });
194
+ const i18nTypeDeclaration = `
195
+ /**
196
+ * The I18nLocale used for Storefront API query context.
197
+ */
198
+ ${i18nType}`;
199
+ if (ambientDeclarationContentNode) {
200
+ const { end } = ambientDeclarationContentNode.range();
201
+ content = content.slice(0, end.index - 1) + `
202
+
203
+ ${i18nTypeDeclaration}
204
+ ` + content.slice(end.index - 1);
202
205
  } else {
203
- const lastImportNode = root.findAll({ rule: { kind: "import_statement" } }).pop() ?? root.findAll({ rule: { kind: "comment", regex: "^/// <reference" } }).pop();
204
- const { end: end2 } = lastImportNode?.range() ?? { end: { index: 0 } };
205
- const typeImport = `
206
- import type {${i18nTypeName}} from './${serverEntryPoint.replace(
207
- /\.[jt]s$/,
208
- ""
209
- )}';`;
210
- content = content.slice(0, end2.index) + typeImport + content.slice(end2.index);
206
+ content = content + `
207
+
208
+ declare global {
209
+ ${i18nTypeDeclaration}
210
+ }`;
211
+ }
212
+ const importImplTypes = localeExtractImpl.match(
213
+ /import\s+type\s+[^;]+?;/
214
+ )?.[0];
215
+ if (importImplTypes) {
216
+ const importPlace = root.findAll({
217
+ rule: {
218
+ kind: "import_statement",
219
+ has: {
220
+ kind: "string_fragment",
221
+ stopBy: "end",
222
+ regex: `^@shopify/hydrogen$`
223
+ }
224
+ }
225
+ }).pop() ?? root.findAll({ rule: { kind: "import_statement" } }).pop() ?? root.findAll({ rule: { kind: "comment", regex: "^/// <reference" } }).pop();
226
+ const importPlaceRange = importPlace?.range() ?? { end: { index: 0 } };
227
+ content = content.slice(0, importPlaceRange.end.index) + importImplTypes.replace(
228
+ /'[^']+'/,
229
+ `'@shopify/hydrogen/storefront-api-types'`
230
+ ) + content.slice(importPlaceRange.end.index);
211
231
  }
212
232
  return content;
213
233
  });
@@ -0,0 +1,242 @@
1
+ import { fileURLToPath } from 'node:url';
2
+ import { describe, it, expect } from 'vitest';
3
+ import { inTemporaryDirectory, copyFile, readFile, writeFile } from '@shopify/cli-kit/node/fs';
4
+ import { joinPath } from '@shopify/cli-kit/node/path';
5
+ import { ts } from 'ts-morph';
6
+ import { getSkeletonSourceDir } from '../../build.js';
7
+ import { replaceRemixEnv, replaceServerI18n } from './replacers.js';
8
+ import { DEFAULT_COMPILER_OPTIONS } from '../../transpile/morph/index.js';
9
+
10
+ const remixDts = "remix.env.d.ts";
11
+ const serverTs = "server.ts";
12
+ const checkTypes = (content) => {
13
+ const { diagnostics } = ts.transpileModule(content, {
14
+ reportDiagnostics: true,
15
+ compilerOptions: DEFAULT_COMPILER_OPTIONS
16
+ });
17
+ if (diagnostics && diagnostics.length > 0) {
18
+ throw new Error(
19
+ ts.formatDiagnostics(
20
+ diagnostics,
21
+ ts.createCompilerHost(DEFAULT_COMPILER_OPTIONS)
22
+ )
23
+ );
24
+ }
25
+ };
26
+ describe("i18n replacers", () => {
27
+ it("adds i18n type to remix.env.d.ts", async () => {
28
+ await inTemporaryDirectory(async (tmpDir) => {
29
+ const skeletonDir = getSkeletonSourceDir();
30
+ await copyFile(
31
+ joinPath(skeletonDir, remixDts),
32
+ joinPath(tmpDir, remixDts)
33
+ );
34
+ await replaceRemixEnv(
35
+ { rootDirectory: tmpDir },
36
+ {},
37
+ await readFile(
38
+ fileURLToPath(new URL("./templates/domains.ts", import.meta.url))
39
+ )
40
+ );
41
+ const newContent = await readFile(joinPath(tmpDir, remixDts));
42
+ expect(() => checkTypes(newContent)).not.toThrow();
43
+ expect(newContent).toMatchInlineSnapshot(`
44
+ "/// <reference types=\\"@remix-run/dev\\" />
45
+ /// <reference types=\\"@shopify/remix-oxygen\\" />
46
+ /// <reference types=\\"@shopify/oxygen-workers-types\\" />
47
+
48
+ // Enhance TypeScript's built-in typings.
49
+ import \\"@total-typescript/ts-reset\\";
50
+
51
+ import type { Storefront, HydrogenCart } from \\"@shopify/hydrogen\\";
52
+ import type {
53
+ LanguageCode,
54
+ CountryCode,
55
+ } from \\"@shopify/hydrogen/storefront-api-types\\";
56
+ import type { CustomerAccessToken } from \\"@shopify/hydrogen/storefront-api-types\\";
57
+ import type { HydrogenSession } from \\"./server\\";
58
+
59
+ declare global {
60
+ /**
61
+ * A global \`process\` object is only available during build to access NODE_ENV.
62
+ */
63
+ const process: { env: { NODE_ENV: \\"production\\" | \\"development\\" } };
64
+
65
+ /**
66
+ * Declare expected Env parameter in fetch handler.
67
+ */
68
+ interface Env {
69
+ SESSION_SECRET: string;
70
+ PUBLIC_STOREFRONT_API_TOKEN: string;
71
+ PRIVATE_STOREFRONT_API_TOKEN: string;
72
+ PUBLIC_STORE_DOMAIN: string;
73
+ PUBLIC_STOREFRONT_ID: string;
74
+ }
75
+
76
+ /**
77
+ * The I18nLocale used for Storefront API query context.
78
+ */
79
+ type I18nLocale = { language: LanguageCode; country: CountryCode };
80
+ }
81
+
82
+ declare module \\"@shopify/remix-oxygen\\" {
83
+ /**
84
+ * Declare local additions to the Remix loader context.
85
+ */
86
+ export interface AppLoadContext {
87
+ env: Env;
88
+ cart: HydrogenCart;
89
+ storefront: Storefront<I18nLocale>;
90
+ session: HydrogenSession;
91
+ waitUntil: ExecutionContext[\\"waitUntil\\"];
92
+ }
93
+
94
+ /**
95
+ * Declare the data we expect to access via \`context.session\`.
96
+ */
97
+ export interface SessionData {
98
+ customerAccessToken: CustomerAccessToken;
99
+ }
100
+ }
101
+ "
102
+ `);
103
+ });
104
+ });
105
+ it("adds i18n type to server.ts", async () => {
106
+ await inTemporaryDirectory(async (tmpDir) => {
107
+ const skeletonDir = getSkeletonSourceDir();
108
+ await writeFile(
109
+ joinPath(tmpDir, serverTs),
110
+ // Remove the part that is not needed for this test (HydrogenSession, Cart query, etc);
111
+ (await readFile(joinPath(skeletonDir, serverTs))).replace(/^};$.*/ms, "};")
112
+ );
113
+ await replaceServerI18n(
114
+ { rootDirectory: tmpDir, serverEntryPoint: serverTs },
115
+ {},
116
+ await readFile(
117
+ fileURLToPath(new URL("./templates/domains.ts", import.meta.url))
118
+ ),
119
+ false
120
+ );
121
+ const newContent = await readFile(joinPath(tmpDir, serverTs));
122
+ expect(() => checkTypes(newContent)).not.toThrow();
123
+ expect(newContent).toMatchInlineSnapshot(`
124
+ "// Virtual entry point for the app
125
+ import * as remixBuild from \\"@remix-run/dev/server-build\\";
126
+ import {
127
+ cartGetIdDefault,
128
+ cartSetIdDefault,
129
+ createCartHandler,
130
+ createStorefrontClient,
131
+ storefrontRedirect,
132
+ } from \\"@shopify/hydrogen\\";
133
+ import {
134
+ createRequestHandler,
135
+ getStorefrontHeaders,
136
+ createCookieSessionStorage,
137
+ type SessionStorage,
138
+ type Session,
139
+ } from \\"@shopify/remix-oxygen\\";
140
+
141
+ /**
142
+ * Export a fetch handler in module format.
143
+ */
144
+ export default {
145
+ async fetch(
146
+ request: Request,
147
+ env: Env,
148
+ executionContext: ExecutionContext
149
+ ): Promise<Response> {
150
+ try {
151
+ /**
152
+ * Open a cache instance in the worker and a custom session instance.
153
+ */
154
+ if (!env?.SESSION_SECRET) {
155
+ throw new Error(\\"SESSION_SECRET environment variable is not set\\");
156
+ }
157
+
158
+ const waitUntil = executionContext.waitUntil.bind(executionContext);
159
+ const [cache, session] = await Promise.all([
160
+ caches.open(\\"hydrogen\\"),
161
+ HydrogenSession.init(request, [env.SESSION_SECRET]),
162
+ ]);
163
+
164
+ /**
165
+ * Create Hydrogen's Storefront client.
166
+ */
167
+ const { storefront } = createStorefrontClient({
168
+ cache,
169
+ waitUntil,
170
+ i18n: getLocaleFromRequest(request),
171
+ publicStorefrontToken: env.PUBLIC_STOREFRONT_API_TOKEN,
172
+ privateStorefrontToken: env.PRIVATE_STOREFRONT_API_TOKEN,
173
+ storeDomain: env.PUBLIC_STORE_DOMAIN,
174
+ storefrontId: env.PUBLIC_STOREFRONT_ID,
175
+ storefrontHeaders: getStorefrontHeaders(request),
176
+ });
177
+
178
+ /*
179
+ * Create a cart handler that will be used to
180
+ * create and update the cart in the session.
181
+ */
182
+ const cart = createCartHandler({
183
+ storefront,
184
+ getCartId: cartGetIdDefault(request.headers),
185
+ setCartId: cartSetIdDefault(),
186
+ cartQueryFragment: CART_QUERY_FRAGMENT,
187
+ });
188
+
189
+ /**
190
+ * Create a Remix request handler and pass
191
+ * Hydrogen's Storefront client to the loader context.
192
+ */
193
+ const handleRequest = createRequestHandler({
194
+ build: remixBuild,
195
+ mode: process.env.NODE_ENV,
196
+ getLoadContext: () => ({ session, storefront, cart, env, waitUntil }),
197
+ });
198
+
199
+ const response = await handleRequest(request);
200
+
201
+ if (response.status === 404) {
202
+ /**
203
+ * Check for redirects only when there's a 404 from the app.
204
+ * If the redirect doesn't exist, then \`storefrontRedirect\`
205
+ * will pass through the 404 response.
206
+ */
207
+ return storefrontRedirect({ request, response, storefront });
208
+ }
209
+
210
+ return response;
211
+ } catch (error) {
212
+ // eslint-disable-next-line no-console
213
+ console.error(error);
214
+ return new Response(\\"An unexpected error occurred\\", { status: 500 });
215
+ }
216
+ },
217
+ };
218
+
219
+ function getLocaleFromRequest(request: Request): I18nLocale {
220
+ const defaultLocale: I18nLocale = { language: \\"EN\\", country: \\"US\\" };
221
+ const supportedLocales = {
222
+ ES: \\"ES\\",
223
+ FR: \\"FR\\",
224
+ DE: \\"DE\\",
225
+ JP: \\"JA\\",
226
+ } as Record<I18nLocale[\\"country\\"], I18nLocale[\\"language\\"]>;
227
+
228
+ const url = new URL(request.url);
229
+ const domain = url.hostname
230
+ .split(\\".\\")
231
+ .pop()
232
+ ?.toUpperCase() as keyof typeof supportedLocales;
233
+
234
+ return domain && supportedLocales[domain]
235
+ ? { language: supportedLocales[domain], country: domain }
236
+ : defaultLocale;
237
+ }
238
+ "
239
+ `);
240
+ });
241
+ });
242
+ });
@@ -1,5 +1,7 @@
1
+ import { fileURLToPath } from 'node:url';
1
2
  import { describe, it, expect } from 'vitest';
2
3
  import { getLocaleFromRequest } from './templates/subdomains.js';
4
+ import { readFile } from '@shopify/cli-kit/node/fs';
3
5
 
4
6
  describe("Setup i18n with subdomains", () => {
5
7
  it("extracts the locale from the subdomain", () => {
@@ -22,4 +24,16 @@ describe("Setup i18n with subdomains", () => {
22
24
  country: "ES"
23
25
  });
24
26
  });
27
+ it("does not access imported types directly", async () => {
28
+ const template = await readFile(
29
+ fileURLToPath(new URL("./templates/domains.ts", import.meta.url))
30
+ );
31
+ const typeImports = (template.match(/import\s+type\s+{([^}]+)}/)?.[1] || "").trim().split(/\s*,\s*/);
32
+ expect(typeImports).not.toHaveLength(0);
33
+ const fnCode = template.match(/function .*\n}$/ms)?.[0] || "";
34
+ expect(fnCode).toBeTruthy();
35
+ typeImports.forEach(
36
+ (typeImport) => expect(fnCode).not.toContain(typeImport)
37
+ );
38
+ });
25
39
  });
@@ -1,5 +1,7 @@
1
+ import { fileURLToPath } from 'node:url';
1
2
  import { describe, it, expect } from 'vitest';
2
3
  import { getLocaleFromRequest } from './templates/subfolders.js';
4
+ import { readFile } from '@shopify/cli-kit/node/fs';
3
5
 
4
6
  describe("Setup i18n with subfolders", () => {
5
7
  it("extracts the locale from the pathname", () => {
@@ -22,4 +24,16 @@ describe("Setup i18n with subfolders", () => {
22
24
  country: "ES"
23
25
  });
24
26
  });
27
+ it("does not access imported types directly", async () => {
28
+ const template = await readFile(
29
+ fileURLToPath(new URL("./templates/domains.ts", import.meta.url))
30
+ );
31
+ const typeImports = (template.match(/import\s+type\s+{([^}]+)}/)?.[1] || "").trim().split(/\s*,\s*/);
32
+ expect(typeImports).not.toHaveLength(0);
33
+ const fnCode = template.match(/function .*\n}$/ms)?.[0] || "";
34
+ expect(fnCode).toBeTruthy();
35
+ typeImports.forEach(
36
+ (typeImport) => expect(fnCode).not.toContain(typeImport)
37
+ );
38
+ });
25
39
  });
@@ -8,7 +8,7 @@ function getLocaleFromRequest(request) {
8
8
  };
9
9
  const url = new URL(request.url);
10
10
  const domain = url.hostname.split(".").pop()?.toUpperCase();
11
- return supportedLocales[domain] ? { language: supportedLocales[domain], country: domain } : defaultLocale;
11
+ return domain && supportedLocales[domain] ? { language: supportedLocales[domain], country: domain } : defaultLocale;
12
12
  }
13
13
 
14
14
  export { getLocaleFromRequest };
@@ -2,6 +2,9 @@ import type {LanguageCode, CountryCode} from '../mock-i18n-types.js';
2
2
 
3
3
  export type I18nLocale = {language: LanguageCode; country: CountryCode};
4
4
 
5
+ /**
6
+ * @returns {I18nLocale}
7
+ */
5
8
  function getLocaleFromRequest(request: Request): I18nLocale {
6
9
  const defaultLocale: I18nLocale = {language: 'EN', country: 'US'};
7
10
  const supportedLocales = {
@@ -9,7 +12,7 @@ function getLocaleFromRequest(request: Request): I18nLocale {
9
12
  FR: 'FR',
10
13
  DE: 'DE',
11
14
  JP: 'JA',
12
- } as Record<CountryCode, LanguageCode>;
15
+ } as Record<I18nLocale['country'], I18nLocale['language']>;
13
16
 
14
17
  const url = new URL(request.url);
15
18
  const domain = url.hostname
@@ -17,7 +20,7 @@ function getLocaleFromRequest(request: Request): I18nLocale {
17
20
  .pop()
18
21
  ?.toUpperCase() as keyof typeof supportedLocales;
19
22
 
20
- return supportedLocales[domain]
23
+ return domain && supportedLocales[domain]
21
24
  ? {language: supportedLocales[domain], country: domain}
22
25
  : defaultLocale;
23
26
  }
@@ -2,6 +2,9 @@ import type {LanguageCode, CountryCode} from '../mock-i18n-types.js';
2
2
 
3
3
  export type I18nLocale = {language: LanguageCode; country: CountryCode};
4
4
 
5
+ /**
6
+ * @returns {I18nLocale}
7
+ */
5
8
  function getLocaleFromRequest(request: Request): I18nLocale {
6
9
  const defaultLocale: I18nLocale = {language: 'EN', country: 'US'};
7
10
  const supportedLocales = {
@@ -9,7 +12,7 @@ function getLocaleFromRequest(request: Request): I18nLocale {
9
12
  FR: 'FR',
10
13
  DE: 'DE',
11
14
  JP: 'JA',
12
- } as Record<CountryCode, LanguageCode>;
15
+ } as Record<I18nLocale['country'], I18nLocale['language']>;
13
16
 
14
17
  const url = new URL(request.url);
15
18
  const firstSubdomain = url.hostname
@@ -2,8 +2,7 @@ function getLocaleFromRequest(request) {
2
2
  const url = new URL(request.url);
3
3
  const firstPathPart = url.pathname.split("/")[1]?.toUpperCase() ?? "";
4
4
  let pathPrefix = "";
5
- let language = "EN";
6
- let country = "US";
5
+ let [language, country] = ["EN", "US"];
7
6
  if (/^[A-Z]{2}-[A-Z]{2}$/i.test(firstPathPart)) {
8
7
  pathPrefix = "/" + firstPathPart;
9
8
  [language, country] = firstPathPart.split("-");
@@ -6,20 +6,21 @@ export type I18nLocale = {
6
6
  pathPrefix: string;
7
7
  };
8
8
 
9
+ /**
10
+ * @returns {I18nLocale}
11
+ */
9
12
  function getLocaleFromRequest(request: Request): I18nLocale {
10
13
  const url = new URL(request.url);
11
14
  const firstPathPart = url.pathname.split('/')[1]?.toUpperCase() ?? '';
12
15
 
16
+ type I18nFromUrl = [I18nLocale['language'], I18nLocale['country']];
17
+
13
18
  let pathPrefix = '';
14
- let language: LanguageCode = 'EN';
15
- let country: CountryCode = 'US';
19
+ let [language, country]: I18nFromUrl = ['EN', 'US'];
16
20
 
17
21
  if (/^[A-Z]{2}-[A-Z]{2}$/i.test(firstPathPart)) {
18
22
  pathPrefix = '/' + firstPathPart;
19
- [language, country] = firstPathPart.split('-') as [
20
- LanguageCode,
21
- CountryCode,
22
- ];
23
+ [language, country] = firstPathPart.split('-') as I18nFromUrl;
23
24
  }
24
25
 
25
26
  return {language, country, pathPrefix};
@@ -3,7 +3,7 @@ import { fileExists, mkdir, copyFile, readFile, writeFile } from '@shopify/cli-k
3
3
  import { joinPath, relativizePath, dirname, relativePath, resolvePath, basename } from '@shopify/cli-kit/node/path';
4
4
  import { AbortError } from '@shopify/cli-kit/node/error';
5
5
  import { renderConfirmationPrompt } from '@shopify/cli-kit/node/ui';
6
- import { transpileFile } from '../../../lib/transpile-ts.js';
6
+ import { transpileFile } from '../../transpile/index.js';
7
7
  import { getCodeFormatOptions, formatCode } from '../../../lib/format-code.js';
8
8
  import { getTemplateAppFile, GENERATOR_ROUTE_DIR, getStarterDir } from '../../../lib/build.js';
9
9
  import { getV2Flags, convertTemplateToRemixVersion, convertRouteToV1 } from '../../../lib/remix-version-interop.js';
@@ -165,7 +165,10 @@ async function generateProjectFile(routeFrom, {
165
165
  v2Flags
166
166
  );
167
167
  if (!typescript) {
168
- templateContent = await transpileFile(templateContent);
168
+ templateContent = await transpileFile(
169
+ templateContent,
170
+ templateAppFilePath
171
+ );
169
172
  }
170
173
  if (adapter) {
171
174
  templateContent = templateContent.replace(
@@ -0,0 +1,6 @@
1
+ async function transpileFile(code, filepath, keepTypes = true) {
2
+ const { transpileTs } = await import('./morph/index.js');
3
+ return transpileTs(code, filepath, keepTypes);
4
+ }
5
+
6
+ export { transpileFile };
@@ -0,0 +1,2 @@
1
+ export { transpileFile } from './file.js';
2
+ export { transpileProject } from './project.js';