@salesforce/storefront-next-dev 0.1.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.
- package/LICENSE.txt +181 -0
- package/README.md +302 -0
- package/dist/cartridge-services/index.d.ts +60 -0
- package/dist/cartridge-services/index.d.ts.map +1 -0
- package/dist/cartridge-services/index.js +954 -0
- package/dist/cartridge-services/index.js.map +1 -0
- package/dist/cli.js +3373 -0
- package/dist/configs/react-router.config.d.ts +13 -0
- package/dist/configs/react-router.config.d.ts.map +1 -0
- package/dist/configs/react-router.config.js +36 -0
- package/dist/configs/react-router.config.js.map +1 -0
- package/dist/extensibility/templates/install-instructions.mdc.hbs +192 -0
- package/dist/extensibility/templates/uninstall-instructions.mdc.hbs +137 -0
- package/dist/index.d.ts +327 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2606 -0
- package/dist/index.js.map +1 -0
- package/dist/mrt/sfnext-server-chunk-DUt5XHAg.mjs +1 -0
- package/dist/mrt/sfnext-server-jiti-DjnmHo-6.mjs +10 -0
- package/dist/mrt/sfnext-server-jiti-DjnmHo-6.mjs.map +1 -0
- package/dist/mrt/ssr.d.ts +19 -0
- package/dist/mrt/ssr.d.ts.map +1 -0
- package/dist/mrt/ssr.mjs +246 -0
- package/dist/mrt/ssr.mjs.map +1 -0
- package/dist/mrt/streamingHandler.d.ts +11 -0
- package/dist/mrt/streamingHandler.d.ts.map +1 -0
- package/dist/mrt/streamingHandler.mjs +255 -0
- package/dist/mrt/streamingHandler.mjs.map +1 -0
- package/dist/react-router/Scripts.d.ts +36 -0
- package/dist/react-router/Scripts.d.ts.map +1 -0
- package/dist/react-router/Scripts.js +68 -0
- package/dist/react-router/Scripts.js.map +1 -0
- package/package.json +157 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,2606 @@
|
|
|
1
|
+
import path, { basename, extname, join, resolve } from "node:path";
|
|
2
|
+
import fs from "fs-extra";
|
|
3
|
+
import path$1, { dirname, join as join$1, relative, resolve as resolve$1 } from "path";
|
|
4
|
+
import { URL as URL$1, fileURLToPath, pathToFileURL } from "url";
|
|
5
|
+
import { parse } from "@babel/parser";
|
|
6
|
+
import { isJSXAttribute, isJSXElement, isJSXFragment, isJSXIdentifier, jsxClosingElement, jsxClosingFragment, jsxElement, jsxFragment, jsxIdentifier, jsxOpeningElement, jsxOpeningFragment, jsxText } from "@babel/types";
|
|
7
|
+
import { generate } from "@babel/generator";
|
|
8
|
+
import traverseModule from "@babel/traverse";
|
|
9
|
+
import fs$1, { existsSync, readFileSync, writeFileSync } from "fs";
|
|
10
|
+
import { glob } from "glob";
|
|
11
|
+
import { Node, Project, ts } from "ts-morph";
|
|
12
|
+
import os from "os";
|
|
13
|
+
import archiver from "archiver";
|
|
14
|
+
import { Minimatch, minimatch } from "minimatch";
|
|
15
|
+
import { execSync } from "child_process";
|
|
16
|
+
import dotenv from "dotenv";
|
|
17
|
+
import chalk from "chalk";
|
|
18
|
+
import express from "express";
|
|
19
|
+
import { createRequestHandler } from "@react-router/express";
|
|
20
|
+
import { existsSync as existsSync$1, readFileSync as readFileSync$1, unlinkSync } from "node:fs";
|
|
21
|
+
import { createProxyMiddleware } from "http-proxy-middleware";
|
|
22
|
+
import compression from "compression";
|
|
23
|
+
import zlib from "node:zlib";
|
|
24
|
+
import morgan from "morgan";
|
|
25
|
+
import { access, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
|
26
|
+
import { execSync as execSync$1 } from "node:child_process";
|
|
27
|
+
import { tmpdir } from "node:os";
|
|
28
|
+
import { randomUUID } from "node:crypto";
|
|
29
|
+
import { npmRunPathEnv } from "npm-run-path";
|
|
30
|
+
|
|
31
|
+
//#region src/plugins/fixReactRouterManifestUrls.ts
|
|
32
|
+
function patchAssetsPaths(dir) {
|
|
33
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
34
|
+
for (const entry of entries) {
|
|
35
|
+
const fullPath = path.join(dir, entry.name);
|
|
36
|
+
if (entry.isDirectory()) patchAssetsPaths(fullPath);
|
|
37
|
+
else if (entry.isFile() && entry.name.endsWith(".js")) {
|
|
38
|
+
const content = fs.readFileSync(fullPath, "utf-8");
|
|
39
|
+
if (content.includes("\"/assets/") || content.includes("'/assets/")) {
|
|
40
|
+
fs.writeFileSync(fullPath, content.replace(/["']\/assets\//g, "(window._BUNDLE_PATH || \"/\") + \"assets/"));
|
|
41
|
+
console.log(`patched /assets/ references in ${fullPath}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Plugin to transform React Router client manifest URLs to use dynamic bundle paths
|
|
48
|
+
*/
|
|
49
|
+
function fixReactRouterManifestUrlsPlugin() {
|
|
50
|
+
let resolvedConfig;
|
|
51
|
+
return {
|
|
52
|
+
name: "odyssey:fix-react-router-manifest-urls",
|
|
53
|
+
enforce: "post",
|
|
54
|
+
configResolved(config) {
|
|
55
|
+
resolvedConfig = config;
|
|
56
|
+
},
|
|
57
|
+
closeBundle() {
|
|
58
|
+
const clientBuildDir = resolvedConfig.environments.client.build.outDir;
|
|
59
|
+
if (fs.existsSync(clientBuildDir)) patchAssetsPaths(clientBuildDir);
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
//#endregion
|
|
65
|
+
//#region src/plugins/readableChunkFileNames.ts
|
|
66
|
+
/**
|
|
67
|
+
* Generates human-readable chunk file names for better debugging in production builds.
|
|
68
|
+
*
|
|
69
|
+
* Transforms Rollup's default hash-based chunk names into structured paths that reflect
|
|
70
|
+
* the original source location, making it easier to identify and debug specific chunks.
|
|
71
|
+
*
|
|
72
|
+
* @param chunkInfo - Rollup's pre-rendered chunk information containing module IDs and metadata
|
|
73
|
+
* @returns A formatted string pattern for the chunk filename with one of these formats:
|
|
74
|
+
* - `assets/(folder1)-(folder2)-filename.[hash].js` for source files in /src/
|
|
75
|
+
* - `assets/(package)-(pkg-name)-(subfolder)-filename.[hash].js` for node_modules
|
|
76
|
+
* - `assets/(chunk)-[name].[hash].js` as fallback for chunks without identifiable paths
|
|
77
|
+
*
|
|
78
|
+
* @example
|
|
79
|
+
* // Source file: /src/components/ui/Button.tsx
|
|
80
|
+
* // Output: assets/(components)-(ui)-Button.[hash].js
|
|
81
|
+
*
|
|
82
|
+
* @example
|
|
83
|
+
* // Node module: /node_modules/@radix-ui/react-dialog/dist/index.js
|
|
84
|
+
* // Output: assets/(package)-(@radix-ui)-(react-dialog)-(dist)-index.[hash].js
|
|
85
|
+
*/
|
|
86
|
+
const readableChunkFileNames = (chunkInfo) => {
|
|
87
|
+
const moduleIds = chunkInfo.moduleIds;
|
|
88
|
+
const defaultName = "assets/(chunk)-[name].[hash].js";
|
|
89
|
+
if (!moduleIds || moduleIds.length === 0) return defaultName;
|
|
90
|
+
const lastModuleId = moduleIds[moduleIds.length - 1];
|
|
91
|
+
const toPosixPath = (pathname) => {
|
|
92
|
+
return pathname.replace(/\\/g, "/");
|
|
93
|
+
};
|
|
94
|
+
const getFileName = (pathname) => {
|
|
95
|
+
const posixPath = toPosixPath(pathname);
|
|
96
|
+
return path$1.posix.parse(posixPath).base.split("?")[0].replace(/\.(tsx?|jsx?|mjs|js)$/, "");
|
|
97
|
+
};
|
|
98
|
+
const cleanPath = (pathname) => {
|
|
99
|
+
return pathname?.split("?")[0];
|
|
100
|
+
};
|
|
101
|
+
const normalizedModuleId = toPosixPath(lastModuleId);
|
|
102
|
+
if (normalizedModuleId.includes("/src/")) {
|
|
103
|
+
const match = toPosixPath(cleanPath(lastModuleId)).match(/\/src\/(.+)$/);
|
|
104
|
+
if (match) {
|
|
105
|
+
const parts = match[1].split("/");
|
|
106
|
+
const fileName = getFileName(parts[parts.length - 1]);
|
|
107
|
+
return `assets/${parts.slice(0, -1).map((f) => `(${f})`).join("-")}-${fileName}.[hash].js`;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (normalizedModuleId.includes("/node_modules/")) {
|
|
111
|
+
const parts = toPosixPath(cleanPath(lastModuleId)).split("/node_modules/");
|
|
112
|
+
const pathParts = parts[parts.length - 1].split("/");
|
|
113
|
+
let packageName;
|
|
114
|
+
let remainingPath;
|
|
115
|
+
if (pathParts[0].startsWith("@")) {
|
|
116
|
+
packageName = `${pathParts[0]}-${pathParts[1]}`;
|
|
117
|
+
remainingPath = pathParts.slice(2);
|
|
118
|
+
} else {
|
|
119
|
+
packageName = pathParts[0];
|
|
120
|
+
remainingPath = pathParts.slice(1);
|
|
121
|
+
}
|
|
122
|
+
const fileName = getFileName(remainingPath[remainingPath.length - 1]);
|
|
123
|
+
const folders = remainingPath.slice(0, -1);
|
|
124
|
+
return `assets/${[
|
|
125
|
+
"package",
|
|
126
|
+
packageName,
|
|
127
|
+
...folders
|
|
128
|
+
].map((s) => `(${s})`).join("-")}-${fileName}.[hash].js`;
|
|
129
|
+
}
|
|
130
|
+
return defaultName;
|
|
131
|
+
};
|
|
132
|
+
/**
|
|
133
|
+
* Vite plugin that configures Rollup to use human-readable chunk file names in production builds.
|
|
134
|
+
*
|
|
135
|
+
* Applies the `readableChunkFileNames` naming strategy to both code-split chunks and entry files,
|
|
136
|
+
* making it easier to identify the source of specific chunks when debugging production builds.
|
|
137
|
+
*
|
|
138
|
+
* @returns A Vite plugin that configures chunk naming for the client build environment
|
|
139
|
+
*
|
|
140
|
+
* @example
|
|
141
|
+
* // In vite.config.ts
|
|
142
|
+
* export default defineConfig({
|
|
143
|
+
* plugins: [readableChunkFileNamesPlugin()]
|
|
144
|
+
* })
|
|
145
|
+
*/
|
|
146
|
+
const readableChunkFileNamesPlugin = () => {
|
|
147
|
+
return {
|
|
148
|
+
name: "odyssey:readable-chunk-file-names",
|
|
149
|
+
apply: "build",
|
|
150
|
+
config() {
|
|
151
|
+
return { environments: { client: { build: { rollupOptions: { output: {
|
|
152
|
+
chunkFileNames: readableChunkFileNames,
|
|
153
|
+
entryFileNames: readableChunkFileNames
|
|
154
|
+
} } } } } };
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
//#endregion
|
|
160
|
+
//#region src/mrt/utils.ts
|
|
161
|
+
const MRT_BUNDLE_TYPE_SSR = "ssr";
|
|
162
|
+
const MRT_STREAMING_ENTRY_FILE = "streamingHandler";
|
|
163
|
+
/**
|
|
164
|
+
* Gets the MRT entry file for the given mode
|
|
165
|
+
* @param mode - The mode to get the MRT entry file for
|
|
166
|
+
* @returns The MRT entry file for the given mode
|
|
167
|
+
*/
|
|
168
|
+
const getMrtEntryFile = (mode) => {
|
|
169
|
+
return process.env.MRT_BUNDLE_TYPE !== MRT_BUNDLE_TYPE_SSR && mode === "production" ? MRT_STREAMING_ENTRY_FILE : MRT_BUNDLE_TYPE_SSR;
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
//#endregion
|
|
173
|
+
//#region src/plugins/managedRuntimeBundle.ts
|
|
174
|
+
const __dirname$1 = path$1.dirname(fileURLToPath(import.meta.url));
|
|
175
|
+
/**
|
|
176
|
+
* This is a Vite plugin specifically for building the Managed Runtime production bundle.
|
|
177
|
+
* This plugin relies on the @react-router/dev/vite plugin to work.
|
|
178
|
+
* This plugin creates the Managed Runtime production bundle from the build output of the @react-router/dev/vite plugin.
|
|
179
|
+
*
|
|
180
|
+
* @returns {Plugin} A Vite plugin for building the Managed Runtime production react-router bundle
|
|
181
|
+
*/
|
|
182
|
+
const managedRuntimeBundlePlugin = () => {
|
|
183
|
+
let resolvedConfig;
|
|
184
|
+
let buildDirectory;
|
|
185
|
+
/**
|
|
186
|
+
* Creates the Managed Runtime production bundle assets
|
|
187
|
+
* - ssr.mjs or streamingHandler.mjs
|
|
188
|
+
* - loader.js
|
|
189
|
+
* - package.json
|
|
190
|
+
*
|
|
191
|
+
* @returns {Promise<void>}
|
|
192
|
+
*/
|
|
193
|
+
const createManagedRuntimeBundleAssets = async () => {
|
|
194
|
+
const loaderPath = path$1.resolve(buildDirectory, "loader.js");
|
|
195
|
+
const mrtEntryFile = `${getMrtEntryFile(resolvedConfig?.mode)}.mjs`;
|
|
196
|
+
const mrtEntryPath = path$1.resolve(buildDirectory, mrtEntryFile);
|
|
197
|
+
await fs.ensureDir(buildDirectory);
|
|
198
|
+
await fs.outputFile(loaderPath, "// This file is intentionally empty");
|
|
199
|
+
const prebuiltMrtEntryPath = path$1.resolve(__dirname$1, `./mrt/${mrtEntryFile}`);
|
|
200
|
+
await fs.copy(prebuiltMrtEntryPath, mrtEntryPath);
|
|
201
|
+
const mrtDir = path$1.resolve(__dirname$1, "./mrt");
|
|
202
|
+
if (await fs.pathExists(mrtDir)) {
|
|
203
|
+
const files = await fs.readdir(mrtDir);
|
|
204
|
+
for (const file of files) if (file.startsWith("sfnext-server-") && file.endsWith(".mjs")) await fs.copy(path$1.join(mrtDir, file), path$1.resolve(buildDirectory, file));
|
|
205
|
+
}
|
|
206
|
+
const packageJsonPath = path$1.resolve(resolvedConfig.root, "package.json");
|
|
207
|
+
const buildPackageJsonPath = path$1.resolve(buildDirectory, "package.json");
|
|
208
|
+
const packageJson = await fs.readJson(packageJsonPath);
|
|
209
|
+
delete packageJson.type;
|
|
210
|
+
await fs.writeJson(buildPackageJsonPath, packageJson, { spaces: 2 });
|
|
211
|
+
};
|
|
212
|
+
return {
|
|
213
|
+
name: "odyssey:managed-runtime-bundle",
|
|
214
|
+
apply: "build",
|
|
215
|
+
config({ mode }) {
|
|
216
|
+
return {
|
|
217
|
+
environments: { ssr: { resolve: { noExternal: true } } },
|
|
218
|
+
experimental: { renderBuiltUrl(filename, { type }) {
|
|
219
|
+
if (mode !== "preview" && (type === "asset" || type === "public")) return { runtime: `(typeof window !== 'undefined' ? window._BUNDLE_PATH : ('/mobify/bundle/'+(process.env.BUNDLE_ID??'local')+'/client/')) + ${JSON.stringify(filename)}` };
|
|
220
|
+
} }
|
|
221
|
+
};
|
|
222
|
+
},
|
|
223
|
+
configResolved(config) {
|
|
224
|
+
resolvedConfig = config;
|
|
225
|
+
buildDirectory = config.__reactRouterPluginContext.reactRouterConfig.buildDirectory;
|
|
226
|
+
},
|
|
227
|
+
buildApp: {
|
|
228
|
+
order: "post",
|
|
229
|
+
handler: async () => {
|
|
230
|
+
await createManagedRuntimeBundleAssets();
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
//#endregion
|
|
237
|
+
//#region src/plugins/patchReactRouter.ts
|
|
238
|
+
const VIRTUAL_MODULE_ID = "\0patched-react-router";
|
|
239
|
+
const MODULE_TO_PATCH = "react-router";
|
|
240
|
+
/**
|
|
241
|
+
* This plugin intercepts imports of 'react-router' and provides patched versions
|
|
242
|
+
* of specific components (like Scripts) with custom logic.
|
|
243
|
+
*
|
|
244
|
+
* @returns {Plugin} A Vite plugin for patching react-router components
|
|
245
|
+
*/
|
|
246
|
+
const patchReactRouterPlugin = () => {
|
|
247
|
+
let isTestMode = false;
|
|
248
|
+
return {
|
|
249
|
+
name: "odyssey:patch-react-router",
|
|
250
|
+
enforce: "pre",
|
|
251
|
+
config(_config, { mode }) {
|
|
252
|
+
isTestMode = mode === "test";
|
|
253
|
+
},
|
|
254
|
+
configEnvironment(name) {
|
|
255
|
+
if (isTestMode) return;
|
|
256
|
+
if (name === "ssr") return { resolve: { noExternal: ["react-router"] } };
|
|
257
|
+
},
|
|
258
|
+
resolveId(id, importer) {
|
|
259
|
+
if (isTestMode) return null;
|
|
260
|
+
if (id === MODULE_TO_PATCH) {
|
|
261
|
+
if (importer === VIRTUAL_MODULE_ID || importer?.includes("storefront-next-dev")) return null;
|
|
262
|
+
return VIRTUAL_MODULE_ID;
|
|
263
|
+
}
|
|
264
|
+
return null;
|
|
265
|
+
},
|
|
266
|
+
load(id) {
|
|
267
|
+
if (isTestMode) return null;
|
|
268
|
+
if (id === VIRTUAL_MODULE_ID) return `
|
|
269
|
+
export * from 'react-router';
|
|
270
|
+
export { Scripts } from '@salesforce/storefront-next-dev/react-router/Scripts';
|
|
271
|
+
`;
|
|
272
|
+
return null;
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
//#endregion
|
|
278
|
+
//#region src/extensibility/plugin-utils.ts
|
|
279
|
+
const traverse = traverseModule.default || traverseModule;
|
|
280
|
+
const PLUGIN_COMPONENT_TAG = "PluginComponent";
|
|
281
|
+
const PLUGIN_PROVIDERS_TAG = "PluginProviders";
|
|
282
|
+
const PLUGIN_ID_ATTRIBUTE = "pluginId";
|
|
283
|
+
/**
|
|
284
|
+
* Find and replace the PluginProviders tags with the corresponding context providers
|
|
285
|
+
* @param element - the AST element to replace
|
|
286
|
+
* @param contextProviders - the context providers to replace
|
|
287
|
+
*/
|
|
288
|
+
function findAndReplaceProviders(element, contextProviders) {
|
|
289
|
+
if (isJSXIdentifier(element.node.openingElement.name, { name: PLUGIN_PROVIDERS_TAG })) if (contextProviders.length > 0) {
|
|
290
|
+
let nested = element.node.children;
|
|
291
|
+
for (let i = contextProviders.length - 1; i >= 0; i--) {
|
|
292
|
+
const componentName = contextProviders[i].componentName;
|
|
293
|
+
nested = [jsxElement(jsxOpeningElement(jsxIdentifier(componentName), [], false), jsxClosingElement(jsxIdentifier(componentName)), nested, false)];
|
|
294
|
+
}
|
|
295
|
+
element.replaceWithMultiple(nested);
|
|
296
|
+
} else element.replaceWith(jsxFragment(jsxOpeningFragment(), jsxClosingFragment(), element.node.children));
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Find and replace the plugin component with the replacement code
|
|
300
|
+
* @param componentName - the name of the component to replace
|
|
301
|
+
* @param element - the AST element as the replacement candidate
|
|
302
|
+
* @param pluginRegistry - the plugin registry
|
|
303
|
+
* @returns the pluginId that was replaced, or null if no replacement was found
|
|
304
|
+
*/
|
|
305
|
+
function findAndReplaceComponent(componentName, element, pluginRegistry) {
|
|
306
|
+
let pluginIdReplaced = null;
|
|
307
|
+
if (isJSXIdentifier(element.node.openingElement.name, { name: componentName })) {
|
|
308
|
+
let replaced = false;
|
|
309
|
+
if (Array.isArray(element.node.openingElement.attributes)) {
|
|
310
|
+
const attr = element.node.openingElement.attributes.find((a) => isJSXAttribute(a) && isJSXIdentifier(a.name, { name: PLUGIN_ID_ATTRIBUTE }));
|
|
311
|
+
const pluginId = attr && isJSXAttribute(attr) && attr.value && "value" in attr.value ? attr.value.value : void 0;
|
|
312
|
+
if (pluginId == null) throw new Error(`PluginComponent must contain a pluginId attribute`);
|
|
313
|
+
if (pluginRegistry[pluginId] && pluginRegistry[pluginId].length > 0) {
|
|
314
|
+
const components = pluginRegistry[pluginId].map((pluginComponent) => {
|
|
315
|
+
return jsxElement(jsxOpeningElement(jsxIdentifier(pluginComponent.componentName), [], true), null, [], true);
|
|
316
|
+
});
|
|
317
|
+
if (components.length > 1) element.replaceWith(jsxFragment(jsxOpeningFragment(), jsxClosingFragment(), components));
|
|
318
|
+
else element.replaceWith(components[0]);
|
|
319
|
+
pluginIdReplaced = pluginId;
|
|
320
|
+
replaced = true;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
if (!replaced) if (element.node.children && element.node.children.length > 0) element.replaceWithMultiple(element.node.children);
|
|
324
|
+
else element.remove();
|
|
325
|
+
}
|
|
326
|
+
return pluginIdReplaced;
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* Run a replacement pass on the AST
|
|
330
|
+
* @param ast - the AST to traverse
|
|
331
|
+
* @param tagName - the name of the tag to replace
|
|
332
|
+
* @param pluginRegistry - the plugin registry
|
|
333
|
+
* @param contextProviders - the context providers to replace
|
|
334
|
+
* @returns a set of pluginIds that were replaced
|
|
335
|
+
*/
|
|
336
|
+
function runReplacementPass(ast, tagName, pluginRegistry = null, contextProviders = null) {
|
|
337
|
+
const pluginIdsReplaced = /* @__PURE__ */ new Set();
|
|
338
|
+
const applyReplacement = (pathToReplace) => {
|
|
339
|
+
if (pluginRegistry) {
|
|
340
|
+
const replacedId = findAndReplaceComponent(tagName, pathToReplace, pluginRegistry);
|
|
341
|
+
if (replacedId) pluginIdsReplaced.add(replacedId);
|
|
342
|
+
} else if (contextProviders) findAndReplaceProviders(pathToReplace, contextProviders);
|
|
343
|
+
};
|
|
344
|
+
traverse(ast, {
|
|
345
|
+
VariableDeclaration(nodePath) {
|
|
346
|
+
const declarationPaths = nodePath.get("declarations");
|
|
347
|
+
const declarationsArray = Array.isArray(declarationPaths) ? declarationPaths : [declarationPaths];
|
|
348
|
+
for (const declarationPath of declarationsArray) {
|
|
349
|
+
const initPath = declarationPath.get("init");
|
|
350
|
+
if (initPath && isJSXElement(initPath.node)) {
|
|
351
|
+
const content = generate(initPath.node).code;
|
|
352
|
+
if ((/* @__PURE__ */ new RegExp(`<(${tagName})(\\s|\\/|>)`)).test(content)) {
|
|
353
|
+
applyReplacement(initPath);
|
|
354
|
+
initPath.traverse({ JSXElement(inner) {
|
|
355
|
+
applyReplacement(inner);
|
|
356
|
+
} });
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
},
|
|
361
|
+
ReturnStatement(nodePath) {
|
|
362
|
+
const arg = nodePath.node.argument;
|
|
363
|
+
if (!isJSXElement(arg) && !isJSXFragment(arg)) return;
|
|
364
|
+
nodePath.traverse({ JSXElement(inner) {
|
|
365
|
+
applyReplacement(inner);
|
|
366
|
+
} });
|
|
367
|
+
}
|
|
368
|
+
});
|
|
369
|
+
return pluginIdsReplaced;
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* Build the import statements for the plugin components
|
|
373
|
+
* @param pluginIds - the pluginIds that were replaced
|
|
374
|
+
* @param pluginRegistry - the plugin registry
|
|
375
|
+
* @returns the import statements
|
|
376
|
+
*/
|
|
377
|
+
function buildReplacementImportStatements(pluginIds, pluginRegistry) {
|
|
378
|
+
const importStatements = /* @__PURE__ */ new Set();
|
|
379
|
+
for (const pluginId of pluginIds) {
|
|
380
|
+
const pluginComponents = pluginRegistry[pluginId];
|
|
381
|
+
for (const pluginComponent of pluginComponents) importStatements.add(`import ${pluginComponent.componentName} from '@/${pluginComponent.path.replace(".tsx", "")}';`);
|
|
382
|
+
}
|
|
383
|
+
return Array.from(importStatements).join("\n");
|
|
384
|
+
}
|
|
385
|
+
function transformPlugins(code, pluginRegistry, contextProviders) {
|
|
386
|
+
if (!code.includes(PLUGIN_COMPONENT_TAG) && !code.includes(PLUGIN_PROVIDERS_TAG)) return null;
|
|
387
|
+
const ast = parse(code, {
|
|
388
|
+
sourceType: "module",
|
|
389
|
+
plugins: [
|
|
390
|
+
"typescript",
|
|
391
|
+
"jsx",
|
|
392
|
+
"decorators-legacy"
|
|
393
|
+
]
|
|
394
|
+
});
|
|
395
|
+
if (code.includes(PLUGIN_COMPONENT_TAG)) {
|
|
396
|
+
const replacementImportStatements = buildReplacementImportStatements(runReplacementPass(ast, PLUGIN_COMPONENT_TAG, pluginRegistry, null), pluginRegistry);
|
|
397
|
+
traverse(ast, { ImportDeclaration(nodePath) {
|
|
398
|
+
if (nodePath.node.source.value.includes("@/plugins/plugin-component")) nodePath.replaceWith(jsxText(replacementImportStatements));
|
|
399
|
+
} });
|
|
400
|
+
}
|
|
401
|
+
if (code.includes(PLUGIN_PROVIDERS_TAG)) {
|
|
402
|
+
const importStatements = /* @__PURE__ */ new Set();
|
|
403
|
+
for (const contextProvider of contextProviders) importStatements.add(`import ${contextProvider.componentName} from '@/${contextProvider.path.replace(".tsx", "")}';`);
|
|
404
|
+
const replacementImportStatements = Array.from(importStatements).join("\n");
|
|
405
|
+
traverse(ast, { ImportDeclaration(nodePath) {
|
|
406
|
+
if (nodePath.node.source.value.includes("@/plugins/plugin-providers")) nodePath.replaceWith(jsxText(replacementImportStatements));
|
|
407
|
+
} });
|
|
408
|
+
runReplacementPass(ast, PLUGIN_PROVIDERS_TAG, null, contextProviders);
|
|
409
|
+
}
|
|
410
|
+
return generate(ast).code;
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* Build the plugin registry from the extension directories
|
|
414
|
+
* @param rootDir - the root directory of the project
|
|
415
|
+
* @param sourceDir - the source directory of the project
|
|
416
|
+
* @returns the plugin registry
|
|
417
|
+
*/
|
|
418
|
+
function buildPluginRegistry(rootDir) {
|
|
419
|
+
const componentRegistry = {};
|
|
420
|
+
const contextProviders = [];
|
|
421
|
+
const extensionDirPath = path$1.join(rootDir, "extensions");
|
|
422
|
+
const extensionDirs = fs.readdirSync(extensionDirPath, { withFileTypes: true });
|
|
423
|
+
const getNamespaceAndComponentName = (dir, filePath) => {
|
|
424
|
+
const namespace = dir.name.split("-").map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()).join("");
|
|
425
|
+
return {
|
|
426
|
+
namespace,
|
|
427
|
+
componentName: `${namespace}_${(filePath.split("/").pop()?.replace(".tsx", ""))?.split("-").map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()).join("")}`
|
|
428
|
+
};
|
|
429
|
+
};
|
|
430
|
+
for (const dir of extensionDirs) if (dir.isDirectory()) {
|
|
431
|
+
const configPath = path$1.join(extensionDirPath, dir.name, "plugin-config.json");
|
|
432
|
+
if (fs.existsSync(configPath)) {
|
|
433
|
+
const pluginConfig = fs.readJsonSync(configPath);
|
|
434
|
+
if (pluginConfig && pluginConfig.components) for (const pluginComponent of pluginConfig.components) {
|
|
435
|
+
const { pluginId, path: componentPath, order = 0 } = pluginComponent;
|
|
436
|
+
if (pluginId && componentPath) {
|
|
437
|
+
if (!componentRegistry[pluginId]) componentRegistry[pluginId] = [];
|
|
438
|
+
const { namespace, componentName } = getNamespaceAndComponentName(dir, componentPath);
|
|
439
|
+
componentRegistry[pluginId].push({
|
|
440
|
+
pluginId,
|
|
441
|
+
path: componentPath,
|
|
442
|
+
order,
|
|
443
|
+
namespace,
|
|
444
|
+
componentName
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
if (pluginConfig && pluginConfig.contextProviders) for (const contextProvider of pluginConfig.contextProviders) {
|
|
449
|
+
const { path: providerPath, order = 0 } = contextProvider;
|
|
450
|
+
if (providerPath) {
|
|
451
|
+
const { namespace, componentName } = getNamespaceAndComponentName(dir, providerPath);
|
|
452
|
+
contextProviders.push({
|
|
453
|
+
path: providerPath,
|
|
454
|
+
namespace,
|
|
455
|
+
componentName,
|
|
456
|
+
order
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
for (const pluginId in componentRegistry) componentRegistry[pluginId].sort((a, b) => a.order - b.order);
|
|
463
|
+
contextProviders.sort((a, b) => a.order - b.order);
|
|
464
|
+
return {
|
|
465
|
+
componentRegistry,
|
|
466
|
+
contextProviders
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
//#endregion
|
|
471
|
+
//#region src/plugins/transformPlugins.ts
|
|
472
|
+
function transformPluginPlaceholderPlugin() {
|
|
473
|
+
let componentRegistry;
|
|
474
|
+
let contextProviders;
|
|
475
|
+
let sourceDir;
|
|
476
|
+
return {
|
|
477
|
+
name: "odyssey:transform-plugin-placeholder",
|
|
478
|
+
enforce: "pre",
|
|
479
|
+
configResolved(config) {
|
|
480
|
+
sourceDir = config.resolve.alias.find((alias) => alias.find === "@")?.replacement || path$1.resolve(__dirname, "./src");
|
|
481
|
+
},
|
|
482
|
+
buildStart() {
|
|
483
|
+
({componentRegistry, contextProviders} = buildPluginRegistry(sourceDir));
|
|
484
|
+
},
|
|
485
|
+
transform(code, id) {
|
|
486
|
+
try {
|
|
487
|
+
const transformedCode = transformPlugins(code, componentRegistry, contextProviders);
|
|
488
|
+
if (transformedCode) return {
|
|
489
|
+
code: transformedCode,
|
|
490
|
+
map: null
|
|
491
|
+
};
|
|
492
|
+
return null;
|
|
493
|
+
} catch (err) {
|
|
494
|
+
console.error(`PluginComponent replace ERROR in ${id}: ${err instanceof Error ? err.stack : String(err)}`);
|
|
495
|
+
throw err;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
//#endregion
|
|
502
|
+
//#region src/plugins/watchConfigFiles.ts
|
|
503
|
+
const watchConfigFilesPlugin = () => {
|
|
504
|
+
let viteConfig;
|
|
505
|
+
return {
|
|
506
|
+
name: "odyssey:watch-config-files",
|
|
507
|
+
configResolved(config) {
|
|
508
|
+
viteConfig = config;
|
|
509
|
+
},
|
|
510
|
+
configureServer(server) {
|
|
511
|
+
const aliases = viteConfig.resolve.alias;
|
|
512
|
+
const root = Object.values(aliases).find((alias) => alias.find === "@")?.replacement || "src";
|
|
513
|
+
const glob$1 = path$1.posix.join(root, "extensions", "**", "plugin-config.json");
|
|
514
|
+
server.watcher.add(glob$1);
|
|
515
|
+
const onChange = (file) => {
|
|
516
|
+
if (file.endsWith("plugin-config.json")) {
|
|
517
|
+
console.log(`🔁 plugin-config.json changed: ${file}`);
|
|
518
|
+
server.restart();
|
|
519
|
+
}
|
|
520
|
+
};
|
|
521
|
+
server.watcher.on("add", onChange);
|
|
522
|
+
server.watcher.on("change", onChange);
|
|
523
|
+
server.watcher.on("unlink", onChange);
|
|
524
|
+
}
|
|
525
|
+
};
|
|
526
|
+
};
|
|
527
|
+
|
|
528
|
+
//#endregion
|
|
529
|
+
//#region src/plugins/staticRegistry.ts
|
|
530
|
+
const DEFAULT_COMPONENT_GROUP$1 = "odyssey_base";
|
|
531
|
+
/**
|
|
532
|
+
* Extracts component ID and group from @Component decorator using ts-morph AST parsing
|
|
533
|
+
*/
|
|
534
|
+
function extractComponentInfo(decorator) {
|
|
535
|
+
const callExpression = decorator.getCallExpression();
|
|
536
|
+
if (!callExpression) return null;
|
|
537
|
+
const args = callExpression.getArguments();
|
|
538
|
+
if (args.length === 0) return null;
|
|
539
|
+
const firstArg = args[0];
|
|
540
|
+
let baseComponentId;
|
|
541
|
+
if (Node.isStringLiteral(firstArg)) baseComponentId = firstArg.getLiteralValue();
|
|
542
|
+
else if (Node.isNoSubstitutionTemplateLiteral(firstArg)) baseComponentId = firstArg.getText().slice(1, -1);
|
|
543
|
+
else if (Node.isTemplateExpression(firstArg)) throw new Error(`@Component id must be a simple string literal or backtick string without interpolation. Found: ${firstArg.getText()}`);
|
|
544
|
+
else return null;
|
|
545
|
+
let group = DEFAULT_COMPONENT_GROUP$1;
|
|
546
|
+
if (args.length > 1) {
|
|
547
|
+
const secondArg = args[1];
|
|
548
|
+
if (Node.isObjectLiteralExpression(secondArg)) {
|
|
549
|
+
const groupProperty = secondArg.getProperty("group");
|
|
550
|
+
if (groupProperty && Node.isPropertyAssignment(groupProperty)) {
|
|
551
|
+
const initializer = groupProperty.getInitializer();
|
|
552
|
+
if (initializer && Node.isStringLiteral(initializer)) group = initializer.getLiteralValue();
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
return {
|
|
557
|
+
id: `${group}.${baseComponentId}`,
|
|
558
|
+
group
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
/**
|
|
562
|
+
* Checks if a source file has a specific named export using ts-morph AST parsing
|
|
563
|
+
*/
|
|
564
|
+
function hasNamedExport(sourceFile, exportName) {
|
|
565
|
+
if (sourceFile.getFunctions().filter((func) => func.hasExportKeyword() && func.getName() === exportName).length > 0) return true;
|
|
566
|
+
const variableStatements = sourceFile.getVariableStatements().filter((stmt) => stmt.hasExportKeyword());
|
|
567
|
+
for (const stmt of variableStatements) {
|
|
568
|
+
const declarations = stmt.getDeclarations();
|
|
569
|
+
for (const decl of declarations) if (decl.getName() === exportName) return true;
|
|
570
|
+
}
|
|
571
|
+
const exportDeclarations = sourceFile.getExportDeclarations();
|
|
572
|
+
for (const exportDecl of exportDeclarations) {
|
|
573
|
+
const namedExports = exportDecl.getNamedExports();
|
|
574
|
+
for (const namedExport of namedExports) {
|
|
575
|
+
const localName = namedExport.getName();
|
|
576
|
+
const aliasName = namedExport.getAliasNode()?.getText();
|
|
577
|
+
if (localName === exportName || aliasName === exportName) return true;
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
return false;
|
|
581
|
+
}
|
|
582
|
+
/**
|
|
583
|
+
* Checks if a source file has a fallback export (including default exports with 'fallback' in name)
|
|
584
|
+
*/
|
|
585
|
+
function hasFallbackExport(sourceFile) {
|
|
586
|
+
if (hasNamedExport(sourceFile, "fallback")) return true;
|
|
587
|
+
const functions = sourceFile.getFunctions().filter((func) => func.hasExportKeyword() && func.hasDefaultKeyword());
|
|
588
|
+
for (const func of functions) {
|
|
589
|
+
const name = func.getName();
|
|
590
|
+
if (name && name.toLowerCase().includes("fallback")) return true;
|
|
591
|
+
}
|
|
592
|
+
return false;
|
|
593
|
+
}
|
|
594
|
+
/**
|
|
595
|
+
* Scans all files in the component directory for @Component decorators and extracts metadata using ts-morph
|
|
596
|
+
*/
|
|
597
|
+
async function scanComponents(project, projectRoot, componentPath, registryPath, verbose$1) {
|
|
598
|
+
const componentFiles = await glob(`${componentPath}/**/*.{ts,tsx}`, {
|
|
599
|
+
cwd: projectRoot,
|
|
600
|
+
absolute: true
|
|
601
|
+
});
|
|
602
|
+
if (verbose$1) console.log(`🔍 Scanning ${componentFiles.length} files in ${componentPath}...`);
|
|
603
|
+
const components = [];
|
|
604
|
+
const registryDir = dirname(resolve$1(projectRoot, registryPath));
|
|
605
|
+
for (const filePath of componentFiles) try {
|
|
606
|
+
const content = readFileSync(filePath, "utf-8");
|
|
607
|
+
const sourceFile = project.createSourceFile(filePath, content, { overwrite: true });
|
|
608
|
+
const classes = sourceFile.getClasses();
|
|
609
|
+
for (const classDeclaration of classes) {
|
|
610
|
+
const decorators = classDeclaration.getDecorators();
|
|
611
|
+
for (const decorator of decorators) if (decorator.getName() === "Component") {
|
|
612
|
+
const componentInfo = extractComponentInfo(decorator);
|
|
613
|
+
if (componentInfo) {
|
|
614
|
+
let relativePath = relative(registryDir, filePath).replace(/\\/g, "/").replace(/\.(ts|tsx)$/, "");
|
|
615
|
+
if (!relativePath.startsWith(".")) relativePath = `./${relativePath}`;
|
|
616
|
+
const hasLoaderExport = hasNamedExport(sourceFile, "loader");
|
|
617
|
+
const hasClientLoaderExport = hasNamedExport(sourceFile, "clientLoader");
|
|
618
|
+
const hasFallback = hasFallbackExport(sourceFile);
|
|
619
|
+
components.push({
|
|
620
|
+
id: componentInfo.id,
|
|
621
|
+
filePath,
|
|
622
|
+
relativePath,
|
|
623
|
+
hasLoader: hasLoaderExport,
|
|
624
|
+
hasClientLoader: hasClientLoaderExport,
|
|
625
|
+
hasFallback
|
|
626
|
+
});
|
|
627
|
+
if (verbose$1) {
|
|
628
|
+
const exports = [];
|
|
629
|
+
if (hasLoaderExport) exports.push("loader");
|
|
630
|
+
if (hasClientLoaderExport) exports.push("clientLoader");
|
|
631
|
+
if (hasFallback) exports.push("fallback");
|
|
632
|
+
const exportsText = exports.length > 0 ? ` (with ${exports.join(", ")})` : "";
|
|
633
|
+
console.log(` ✅ Found component: ${componentInfo.id} → ${relativePath}${exportsText}`);
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
} catch (error$1) {
|
|
639
|
+
if (verbose$1) console.warn(`⚠️ Could not process ${filePath}:`, error$1.message);
|
|
640
|
+
}
|
|
641
|
+
return components;
|
|
642
|
+
}
|
|
643
|
+
/**
|
|
644
|
+
* Generates the initializeRegistry function code
|
|
645
|
+
*/
|
|
646
|
+
function generateRegistryCode(components, registryIdentifier = "registry") {
|
|
647
|
+
const sorted = [...components].sort((a, b) => a.id.localeCompare(b.id) || a.relativePath.localeCompare(b.relativePath));
|
|
648
|
+
if (sorted.length === 0) return `
|
|
649
|
+
/* eslint-disable */
|
|
650
|
+
/**
|
|
651
|
+
* Initialize the static component registry.
|
|
652
|
+
* This function is auto-generated by the staticRegistry Vite plugin.
|
|
653
|
+
*
|
|
654
|
+
* DO NOT EDIT THIS FUNCTION MANUALLY - it will be overwritten on next build.
|
|
655
|
+
*/
|
|
656
|
+
export function initializeRegistry(targetRegistry = ${registryIdentifier}): void {
|
|
657
|
+
// No components found with @Component decorators
|
|
658
|
+
}
|
|
659
|
+
`;
|
|
660
|
+
const registrations = sorted.map(({ id, relativePath, hasLoader, hasClientLoader, hasFallback }) => {
|
|
661
|
+
if (hasLoader || hasClientLoader || hasFallback) {
|
|
662
|
+
const metadata = [];
|
|
663
|
+
if (hasLoader) metadata.push(`loader: 'loader'`);
|
|
664
|
+
if (hasClientLoader) metadata.push(`clientLoader: 'clientLoader'`);
|
|
665
|
+
if (hasFallback) metadata.push(`fallback: 'fallback'`);
|
|
666
|
+
return ` targetRegistry.registerImporter('${id}', () => import('${relativePath}'), { ${metadata.join(", ")} });`;
|
|
667
|
+
} else return ` targetRegistry.registerImporter('${id}', () => import('${relativePath}'));`;
|
|
668
|
+
}).join("\n");
|
|
669
|
+
return `
|
|
670
|
+
/* eslint-disable */
|
|
671
|
+
/**
|
|
672
|
+
* Initialize the static component registry.
|
|
673
|
+
* This function is auto-generated by the staticRegistry Vite plugin.
|
|
674
|
+
*
|
|
675
|
+
* DO NOT EDIT THIS FUNCTION MANUALLY - it will be overwritten on next build.
|
|
676
|
+
*
|
|
677
|
+
* Components registered: ${sorted.map((c) => c.id).join(", ")}
|
|
678
|
+
*/
|
|
679
|
+
export function initializeRegistry(targetRegistry = ${registryIdentifier}): void {
|
|
680
|
+
${registrations}
|
|
681
|
+
}
|
|
682
|
+
`;
|
|
683
|
+
}
|
|
684
|
+
/**
|
|
685
|
+
* Updates the registry.ts file with the generated code
|
|
686
|
+
*/
|
|
687
|
+
function updateRegistryFile(registryFilePath, generatedCode, verbose$1) {
|
|
688
|
+
let existingContent;
|
|
689
|
+
if (!existsSync(registryFilePath)) {
|
|
690
|
+
if (verbose$1) console.log(`📝 Creating new registry file...`);
|
|
691
|
+
const basicRegistryContent = `import { ComponentRegistry } from '@/lib/component-registry';
|
|
692
|
+
|
|
693
|
+
// Create the component registry instance
|
|
694
|
+
export const registry = new ComponentRegistry();
|
|
695
|
+
|
|
696
|
+
// STATIC_REGISTRY_START
|
|
697
|
+
// Generated content will be inserted here by the static registry plugin
|
|
698
|
+
// STATIC_REGISTRY_END
|
|
699
|
+
`;
|
|
700
|
+
writeFileSync(registryFilePath, basicRegistryContent, "utf-8");
|
|
701
|
+
existingContent = basicRegistryContent;
|
|
702
|
+
} else try {
|
|
703
|
+
existingContent = readFileSync(registryFilePath, "utf-8");
|
|
704
|
+
} catch (error$1) {
|
|
705
|
+
throw new Error(`Failed to read registry file: ${error$1.message}`);
|
|
706
|
+
}
|
|
707
|
+
const startMarker = "// STATIC_REGISTRY_START";
|
|
708
|
+
const endMarker = "// STATIC_REGISTRY_END";
|
|
709
|
+
const startIndex = existingContent.indexOf(startMarker);
|
|
710
|
+
const endIndex = existingContent.indexOf(endMarker);
|
|
711
|
+
if (startIndex === -1 || endIndex === -1) throw new Error(`Registry file ${registryFilePath} is missing static registry markers. Please add "${startMarker}" and "${endMarker}" markers to define the generated content area.`);
|
|
712
|
+
const updatedContent = `${existingContent.slice(0, startIndex + 24)}\n${generatedCode}\n${existingContent.slice(endIndex)}`;
|
|
713
|
+
try {
|
|
714
|
+
writeFileSync(registryFilePath, updatedContent, "utf-8");
|
|
715
|
+
if (verbose$1) console.log(`💾 Updated registry file: ${registryFilePath}`);
|
|
716
|
+
} catch (error$1) {
|
|
717
|
+
throw new Error(`Failed to write registry file: ${error$1.message}`);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
/**
|
|
721
|
+
* Vite plugin that generates static component registry based on @Component decorators.
|
|
722
|
+
*
|
|
723
|
+
* This plugin scans component files for @Component decorators and automatically generates
|
|
724
|
+
* a static registry function that pre-registers all components with their import paths.
|
|
725
|
+
* This eliminates the need for manual component registration and provides build-time
|
|
726
|
+
* optimization for component discovery.
|
|
727
|
+
*
|
|
728
|
+
* @param config - Configuration options for the plugin
|
|
729
|
+
* @returns A Vite plugin that generates static component registrations
|
|
730
|
+
*
|
|
731
|
+
* @example
|
|
732
|
+
* // In vite.config.ts
|
|
733
|
+
* export default defineConfig({
|
|
734
|
+
* plugins: [
|
|
735
|
+
* staticRegistryPlugin({
|
|
736
|
+
* componentPath: 'src/components',
|
|
737
|
+
* registryPath: 'src/lib/registry.ts',
|
|
738
|
+
* verbose: true
|
|
739
|
+
* })
|
|
740
|
+
* ]
|
|
741
|
+
* })
|
|
742
|
+
*/
|
|
743
|
+
const staticRegistryPlugin = (config = {}) => {
|
|
744
|
+
const { componentPath = "src/components", registryPath = "src/lib/static-registry.ts", registryIdentifier = "registry", failOnError = true, verbose: verbose$1 = false } = config;
|
|
745
|
+
let projectRoot;
|
|
746
|
+
const runRegistryGeneration = async () => {
|
|
747
|
+
if (verbose$1) console.log("🚀 Starting static registry generation...");
|
|
748
|
+
const components = await scanComponents(new Project({ compilerOptions: {
|
|
749
|
+
target: ts.ScriptTarget.Latest,
|
|
750
|
+
module: ts.ModuleKind.ESNext,
|
|
751
|
+
jsx: ts.JsxEmit.ReactJSX,
|
|
752
|
+
allowJs: true,
|
|
753
|
+
skipLibCheck: true,
|
|
754
|
+
noEmit: true
|
|
755
|
+
} }), projectRoot, componentPath, registryPath, verbose$1);
|
|
756
|
+
if (verbose$1) console.log(`📦 Found ${components.length} components with @Component decorators`);
|
|
757
|
+
const generatedCode = generateRegistryCode(components, registryIdentifier);
|
|
758
|
+
const registryFilePath = resolve$1(projectRoot, registryPath);
|
|
759
|
+
updateRegistryFile(registryFilePath, generatedCode, verbose$1);
|
|
760
|
+
if (verbose$1) console.log("✅ Static registry generation complete!");
|
|
761
|
+
return registryFilePath;
|
|
762
|
+
};
|
|
763
|
+
return {
|
|
764
|
+
name: "storefrontnext:static-registry",
|
|
765
|
+
configResolved(resolvedConfig) {
|
|
766
|
+
projectRoot = resolvedConfig.root;
|
|
767
|
+
},
|
|
768
|
+
async buildStart() {
|
|
769
|
+
try {
|
|
770
|
+
await runRegistryGeneration();
|
|
771
|
+
} catch (error$1) {
|
|
772
|
+
console.error(`❌ Static registry generation failed: ${error$1.message}`);
|
|
773
|
+
if (failOnError) throw error$1;
|
|
774
|
+
console.warn("⚠️ Continuing build without static registry...");
|
|
775
|
+
}
|
|
776
|
+
},
|
|
777
|
+
async handleHotUpdate({ file, server }) {
|
|
778
|
+
const normalizedComponentPath = componentPath.replace(/\\/g, "/");
|
|
779
|
+
const normalizedFile = file.replace(/\\/g, "/");
|
|
780
|
+
if (normalizedFile.includes(`/${normalizedComponentPath}/`) && (normalizedFile.endsWith(".ts") || normalizedFile.endsWith(".tsx"))) {
|
|
781
|
+
if (verbose$1) console.log(`🔄 Component file changed: ${file}, regenerating registry...`);
|
|
782
|
+
try {
|
|
783
|
+
const registryFilePath = await runRegistryGeneration();
|
|
784
|
+
const registryModule = server.moduleGraph.getModuleById(registryFilePath);
|
|
785
|
+
if (registryModule) await server.reloadModule(registryModule);
|
|
786
|
+
if (verbose$1) console.log("✅ Registry regenerated successfully!");
|
|
787
|
+
} catch (error$1) {
|
|
788
|
+
console.error(`❌ Failed to regenerate registry: ${error$1.message}`);
|
|
789
|
+
}
|
|
790
|
+
return [];
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
};
|
|
794
|
+
};
|
|
795
|
+
|
|
796
|
+
//#endregion
|
|
797
|
+
//#region src/plugins/configLoader.ts
|
|
798
|
+
/**
|
|
799
|
+
* Load the engagement config from config.server.ts
|
|
800
|
+
*/
|
|
801
|
+
async function loadEngagementConfig(projectRoot, configPath, verbose$1) {
|
|
802
|
+
const absoluteConfigPath = resolve$1(projectRoot, configPath);
|
|
803
|
+
try {
|
|
804
|
+
const config = (await import(pathToFileURL(absoluteConfigPath).href)).default;
|
|
805
|
+
if (verbose$1) console.log(` 📄 Loaded config from ${configPath}`);
|
|
806
|
+
const engagement = config?.app?.engagement;
|
|
807
|
+
if (!engagement) {
|
|
808
|
+
if (verbose$1) console.log(` ⚠️ No engagement config found in ${configPath}`);
|
|
809
|
+
return null;
|
|
810
|
+
}
|
|
811
|
+
return engagement;
|
|
812
|
+
} catch (error$1) {
|
|
813
|
+
if (verbose$1) console.warn(` ⚠️ Could not load config from ${configPath}: ${error$1.message}`);
|
|
814
|
+
return null;
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
//#endregion
|
|
819
|
+
//#region src/plugins/eventInstrumentationValidator.ts
|
|
820
|
+
/**
|
|
821
|
+
* Extract all trackEvent calls from source files and return the event types found
|
|
822
|
+
*/
|
|
823
|
+
async function scanForInstrumentedEvents(projectRoot, scanPaths, verbose$1) {
|
|
824
|
+
const instrumentedEvents = /* @__PURE__ */ new Set();
|
|
825
|
+
const trackEventPattern = /trackEvent\s*\([^,]+,[^,]+,[^,]+,\s*['"]([^'"]+)['"]/g;
|
|
826
|
+
const sendViewPagePattern = /sendViewPageEvent\s*\(/g;
|
|
827
|
+
const createEventPattern = /createEvent\s*\(\s*['"]([^'"]+)['"]/g;
|
|
828
|
+
for (const scanPath of scanPaths) {
|
|
829
|
+
const files = await glob(join$1(resolve$1(projectRoot, scanPath), "**/*.{ts,tsx}"), { ignore: [
|
|
830
|
+
"**/*.test.ts",
|
|
831
|
+
"**/*.test.tsx",
|
|
832
|
+
"**/*.spec.ts",
|
|
833
|
+
"**/*.spec.tsx",
|
|
834
|
+
"**/node_modules/**"
|
|
835
|
+
] });
|
|
836
|
+
if (verbose$1) console.log(` 📂 Scanning ${files.length} files in ${scanPath}...`);
|
|
837
|
+
for (const file of files) try {
|
|
838
|
+
const content = readFileSync(file, "utf-8");
|
|
839
|
+
let match;
|
|
840
|
+
while ((match = trackEventPattern.exec(content)) !== null) {
|
|
841
|
+
const eventType = match[1];
|
|
842
|
+
instrumentedEvents.add(eventType);
|
|
843
|
+
if (verbose$1) console.log(` ✓ Found trackEvent('${eventType}') in ${file}`);
|
|
844
|
+
}
|
|
845
|
+
if (sendViewPagePattern.test(content)) {
|
|
846
|
+
instrumentedEvents.add("view_page");
|
|
847
|
+
if (verbose$1) console.log(` ✓ Found sendViewPageEvent() in ${file}`);
|
|
848
|
+
}
|
|
849
|
+
while ((match = createEventPattern.exec(content)) !== null) {
|
|
850
|
+
const eventType = match[1];
|
|
851
|
+
instrumentedEvents.add(eventType);
|
|
852
|
+
if (verbose$1) console.log(` ✓ Found createEvent('${eventType}') in ${file}`);
|
|
853
|
+
}
|
|
854
|
+
trackEventPattern.lastIndex = 0;
|
|
855
|
+
sendViewPagePattern.lastIndex = 0;
|
|
856
|
+
createEventPattern.lastIndex = 0;
|
|
857
|
+
} catch (error$1) {
|
|
858
|
+
if (verbose$1) console.warn(` ⚠️ Could not read ${file}: ${error$1.message}`);
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
return instrumentedEvents;
|
|
862
|
+
}
|
|
863
|
+
/**
|
|
864
|
+
* Extract enabled event toggles per adapter
|
|
865
|
+
* Dynamically iterates over all keys in eventToggles - supports custom event types
|
|
866
|
+
*/
|
|
867
|
+
function extractEnabledEvents(engagement) {
|
|
868
|
+
const adapterEvents = /* @__PURE__ */ new Map();
|
|
869
|
+
if (!engagement.adapters) return adapterEvents;
|
|
870
|
+
for (const [adapterName, adapterConfig] of Object.entries(engagement.adapters)) {
|
|
871
|
+
if (!adapterConfig.enabled) continue;
|
|
872
|
+
const enabledEvents = /* @__PURE__ */ new Set();
|
|
873
|
+
if (adapterConfig.eventToggles) {
|
|
874
|
+
for (const [eventType, isEnabled] of Object.entries(adapterConfig.eventToggles)) if (isEnabled === true) enabledEvents.add(eventType);
|
|
875
|
+
}
|
|
876
|
+
if (enabledEvents.size > 0) adapterEvents.set(adapterName, enabledEvents);
|
|
877
|
+
}
|
|
878
|
+
return adapterEvents;
|
|
879
|
+
}
|
|
880
|
+
/**
|
|
881
|
+
* Vite plugin that validates event instrumentation at build time.
|
|
882
|
+
*
|
|
883
|
+
* This plugin scans source files for trackEvent() calls and validates that
|
|
884
|
+
* all enabled event toggles in config.server.ts have corresponding instrumentation.
|
|
885
|
+
*
|
|
886
|
+
* @param config - Configuration options for the plugin
|
|
887
|
+
* @returns A Vite plugin that validates event instrumentation
|
|
888
|
+
*
|
|
889
|
+
* @example
|
|
890
|
+
* // In vite.config.ts
|
|
891
|
+
* export default defineConfig({
|
|
892
|
+
* plugins: [
|
|
893
|
+
* eventInstrumentationValidatorPlugin({
|
|
894
|
+
* configPath: 'config.server.ts',
|
|
895
|
+
* scanPaths: ['src'],
|
|
896
|
+
* verbose: true
|
|
897
|
+
* })
|
|
898
|
+
* ]
|
|
899
|
+
* })
|
|
900
|
+
*/
|
|
901
|
+
const eventInstrumentationValidatorPlugin = (config = {}) => {
|
|
902
|
+
const { configPath = "config.server.ts", scanPaths = ["src"], failOnMissing = false, verbose: verbose$1 = false } = config;
|
|
903
|
+
let resolvedConfig;
|
|
904
|
+
return {
|
|
905
|
+
name: "storefrontnext:event-instrumentation-validator",
|
|
906
|
+
apply: "build",
|
|
907
|
+
configResolved(viteConfig) {
|
|
908
|
+
resolvedConfig = viteConfig;
|
|
909
|
+
},
|
|
910
|
+
async buildStart() {
|
|
911
|
+
const projectRoot = resolvedConfig.root;
|
|
912
|
+
if (verbose$1) console.log("\n🔍 [event-instrumentation] Validating event instrumentation...");
|
|
913
|
+
const engagement = await loadEngagementConfig(projectRoot, configPath, verbose$1);
|
|
914
|
+
if (!engagement) {
|
|
915
|
+
if (verbose$1) console.log(" ℹ️ Skipping validation - no engagement config found\n");
|
|
916
|
+
return;
|
|
917
|
+
}
|
|
918
|
+
const adapterEvents = extractEnabledEvents(engagement);
|
|
919
|
+
if (adapterEvents.size === 0) {
|
|
920
|
+
if (verbose$1) console.log(" ℹ️ No enabled adapters with event toggles found\n");
|
|
921
|
+
return;
|
|
922
|
+
}
|
|
923
|
+
const instrumentedEvents = await scanForInstrumentedEvents(projectRoot, scanPaths, verbose$1);
|
|
924
|
+
if (verbose$1) {
|
|
925
|
+
console.log(`\n 🔎 Found ${instrumentedEvents.size} instrumented event types:`);
|
|
926
|
+
for (const event of instrumentedEvents) console.log(` - ${event}`);
|
|
927
|
+
}
|
|
928
|
+
const missingInstrumentation = [];
|
|
929
|
+
for (const [adapterName, enabledEvents] of adapterEvents) for (const eventType of enabledEvents) if (!instrumentedEvents.has(eventType)) missingInstrumentation.push({
|
|
930
|
+
adapter: adapterName,
|
|
931
|
+
event: eventType
|
|
932
|
+
});
|
|
933
|
+
if (missingInstrumentation.length === 0) {
|
|
934
|
+
if (verbose$1) console.log("\n ✅ All enabled events are instrumented\n");
|
|
935
|
+
return;
|
|
936
|
+
}
|
|
937
|
+
console.log("\n");
|
|
938
|
+
for (const { adapter, event } of missingInstrumentation) console.warn(` ⚠️ [event-instrumentation] ${adapter}.${event} is enabled but '${event}' is never instrumented`);
|
|
939
|
+
console.log("\n");
|
|
940
|
+
if (failOnMissing) throw new Error(`[event-instrumentation] ${missingInstrumentation.length} event(s) are enabled but not instrumented. Either add instrumentation or disable the event toggles in config.server.ts.`);
|
|
941
|
+
}
|
|
942
|
+
};
|
|
943
|
+
};
|
|
944
|
+
|
|
945
|
+
//#endregion
|
|
946
|
+
//#region src/plugin.ts
|
|
947
|
+
/**
|
|
948
|
+
* Storefront Next Vite plugin that powers the React Router RSC app.
|
|
949
|
+
* Supports building and optimizing for the managed runtime environment.
|
|
950
|
+
*
|
|
951
|
+
* @param config - Configuration options for the plugin
|
|
952
|
+
* @returns {Plugin[]} An array of Vite plugins for Storefront Next functionality
|
|
953
|
+
*
|
|
954
|
+
* @example
|
|
955
|
+
* // With default options
|
|
956
|
+
* export default defineConfig({
|
|
957
|
+
* plugins: [storefrontNextPlugins()]
|
|
958
|
+
* })
|
|
959
|
+
*
|
|
960
|
+
* @example
|
|
961
|
+
* // Disable readable chunk names
|
|
962
|
+
* export default defineConfig({
|
|
963
|
+
* plugins: [storefrontNextPlugins({ readableChunkNames: false })]
|
|
964
|
+
* })
|
|
965
|
+
*/
|
|
966
|
+
function storefrontNextPlugins(config = {}) {
|
|
967
|
+
const { readableChunkNames = false, staticRegistry = {
|
|
968
|
+
componentPath: "",
|
|
969
|
+
registryPath: "",
|
|
970
|
+
verbose: false
|
|
971
|
+
}, eventInstrumentationValidator = {
|
|
972
|
+
configPath: "config.server.ts",
|
|
973
|
+
scanPaths: ["src"],
|
|
974
|
+
failOnMissing: false,
|
|
975
|
+
verbose: false
|
|
976
|
+
} } = config;
|
|
977
|
+
const plugins = [
|
|
978
|
+
managedRuntimeBundlePlugin(),
|
|
979
|
+
fixReactRouterManifestUrlsPlugin(),
|
|
980
|
+
patchReactRouterPlugin(),
|
|
981
|
+
transformPluginPlaceholderPlugin(),
|
|
982
|
+
watchConfigFilesPlugin()
|
|
983
|
+
];
|
|
984
|
+
if (staticRegistry?.componentPath && staticRegistry?.registryPath) plugins.push(staticRegistryPlugin(staticRegistry));
|
|
985
|
+
if (eventInstrumentationValidator !== false) plugins.push(eventInstrumentationValidatorPlugin(eventInstrumentationValidator));
|
|
986
|
+
if (readableChunkNames) plugins.push(readableChunkFileNamesPlugin());
|
|
987
|
+
return plugins;
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
//#endregion
|
|
991
|
+
//#region package.json
|
|
992
|
+
var version = "0.1.0";
|
|
993
|
+
|
|
994
|
+
//#endregion
|
|
995
|
+
//#region src/utils/logger.ts
|
|
996
|
+
/**
|
|
997
|
+
* Logger utilities
|
|
998
|
+
*/
|
|
999
|
+
const colors = {
|
|
1000
|
+
warn: "yellow",
|
|
1001
|
+
error: "red",
|
|
1002
|
+
success: "cyan",
|
|
1003
|
+
info: "green",
|
|
1004
|
+
debug: "gray"
|
|
1005
|
+
};
|
|
1006
|
+
const fancyLog = (level, msg) => {
|
|
1007
|
+
const colorFn = chalk[colors[level]];
|
|
1008
|
+
console.log(`${colorFn(level)}: ${msg}`);
|
|
1009
|
+
};
|
|
1010
|
+
const info = (msg) => fancyLog("info", msg);
|
|
1011
|
+
const success = (msg) => fancyLog("success", msg);
|
|
1012
|
+
const warn = (msg) => fancyLog("warn", msg);
|
|
1013
|
+
const error = (msg) => fancyLog("error", msg);
|
|
1014
|
+
const debug = (msg, data) => {
|
|
1015
|
+
if (process.env.DEBUG || process.env.NODE_ENV !== "production") {
|
|
1016
|
+
fancyLog("debug", msg);
|
|
1017
|
+
if (data) console.log(data);
|
|
1018
|
+
}
|
|
1019
|
+
};
|
|
1020
|
+
|
|
1021
|
+
//#endregion
|
|
1022
|
+
//#region src/utils.ts
|
|
1023
|
+
const DEFAULT_CLOUD_ORIGIN = "https://cloud.mobify.com";
|
|
1024
|
+
const getDefaultBuildDir = (targetDir) => path$1.join(targetDir, "build");
|
|
1025
|
+
const NODE_ENV = process.env.NODE_ENV || "development";
|
|
1026
|
+
/**
|
|
1027
|
+
* Get credentials file path based on cloud origin
|
|
1028
|
+
*/
|
|
1029
|
+
const getCredentialsFile = (cloudOrigin, credentialsFile) => {
|
|
1030
|
+
if (credentialsFile) return credentialsFile;
|
|
1031
|
+
const host = new URL(cloudOrigin).host;
|
|
1032
|
+
const suffix = host === "cloud.mobify.com" ? "" : `--${host}`;
|
|
1033
|
+
return path$1.join(os.homedir(), `.mobify${suffix}`);
|
|
1034
|
+
};
|
|
1035
|
+
/**
|
|
1036
|
+
* Read credentials from file
|
|
1037
|
+
*/
|
|
1038
|
+
const readCredentials = async (filepath) => {
|
|
1039
|
+
try {
|
|
1040
|
+
const data = await fs.readJSON(filepath);
|
|
1041
|
+
return {
|
|
1042
|
+
username: data.username,
|
|
1043
|
+
api_key: data.api_key
|
|
1044
|
+
};
|
|
1045
|
+
} catch {
|
|
1046
|
+
throw new Error(`Credentials file "${filepath}" not found.\nVisit https://runtime.commercecloud.com/account/settings for steps on authorizing your computer to push bundles.`);
|
|
1047
|
+
}
|
|
1048
|
+
};
|
|
1049
|
+
/**
|
|
1050
|
+
* Get project package.json
|
|
1051
|
+
*/
|
|
1052
|
+
const getProjectPkg = (projectDir) => {
|
|
1053
|
+
const packagePath = path$1.join(projectDir, "package.json");
|
|
1054
|
+
try {
|
|
1055
|
+
return fs.readJSONSync(packagePath);
|
|
1056
|
+
} catch {
|
|
1057
|
+
throw new Error(`Could not read project package at "${packagePath}"`);
|
|
1058
|
+
}
|
|
1059
|
+
};
|
|
1060
|
+
/**
|
|
1061
|
+
* Load .env file from project directory
|
|
1062
|
+
*/
|
|
1063
|
+
const loadEnvFile = (projectDir) => {
|
|
1064
|
+
const envPath = path$1.join(projectDir, ".env");
|
|
1065
|
+
if (fs.existsSync(envPath)) dotenv.config({ path: envPath });
|
|
1066
|
+
else warn("No .env file found");
|
|
1067
|
+
};
|
|
1068
|
+
/**
|
|
1069
|
+
* Get MRT configuration with priority logic: .env -> package.json -> defaults
|
|
1070
|
+
*/
|
|
1071
|
+
const getMrtConfig = (projectDir) => {
|
|
1072
|
+
loadEnvFile(projectDir);
|
|
1073
|
+
const pkg = getProjectPkg(projectDir);
|
|
1074
|
+
const defaultMrtProject = process.env.MRT_PROJECT ?? pkg.name;
|
|
1075
|
+
if (!defaultMrtProject || defaultMrtProject.trim() === "") throw new Error("Project name couldn't be determined. Do one of these options:\n 1. Set MRT_PROJECT in your .env file, or\n 2. Ensure package.json has a valid \"name\" field.");
|
|
1076
|
+
const defaultMrtTarget = process.env.MRT_TARGET ?? void 0;
|
|
1077
|
+
debug("MRT configuration resolved", {
|
|
1078
|
+
projectDir,
|
|
1079
|
+
envMrtProject: process.env.MRT_PROJECT,
|
|
1080
|
+
envMrtTarget: process.env.MRT_TARGET,
|
|
1081
|
+
packageName: pkg.name,
|
|
1082
|
+
resolvedProject: defaultMrtProject,
|
|
1083
|
+
resolvedTarget: defaultMrtTarget
|
|
1084
|
+
});
|
|
1085
|
+
return {
|
|
1086
|
+
defaultMrtProject,
|
|
1087
|
+
defaultMrtTarget
|
|
1088
|
+
};
|
|
1089
|
+
};
|
|
1090
|
+
/**
|
|
1091
|
+
* Get project dependency tree (simplified version)
|
|
1092
|
+
*/
|
|
1093
|
+
const getProjectDependencyTree = (projectDir) => {
|
|
1094
|
+
try {
|
|
1095
|
+
const tmpFile = path$1.join(os.tmpdir(), `npm-ls-${Date.now()}.json`);
|
|
1096
|
+
execSync(`npm ls --all --json > ${tmpFile}`, {
|
|
1097
|
+
stdio: "ignore",
|
|
1098
|
+
cwd: projectDir
|
|
1099
|
+
});
|
|
1100
|
+
const data = fs.readJSONSync(tmpFile);
|
|
1101
|
+
fs.unlinkSync(tmpFile);
|
|
1102
|
+
return data;
|
|
1103
|
+
} catch {
|
|
1104
|
+
return null;
|
|
1105
|
+
}
|
|
1106
|
+
};
|
|
1107
|
+
/**
|
|
1108
|
+
* Get PWA Kit dependencies from dependency tree
|
|
1109
|
+
*/
|
|
1110
|
+
const getPwaKitDependencies = (dependencyTree) => {
|
|
1111
|
+
if (!dependencyTree) return {};
|
|
1112
|
+
const pwaKitDependencies = ["@salesforce/storefront-next-dev"];
|
|
1113
|
+
const result = {};
|
|
1114
|
+
const searchDeps = (tree) => {
|
|
1115
|
+
if (tree.dependencies) for (const [name, dep] of Object.entries(tree.dependencies)) {
|
|
1116
|
+
if (pwaKitDependencies.includes(name)) result[name] = dep.version || "unknown";
|
|
1117
|
+
if (dep.dependencies) searchDeps({ dependencies: dep.dependencies });
|
|
1118
|
+
}
|
|
1119
|
+
};
|
|
1120
|
+
searchDeps(dependencyTree);
|
|
1121
|
+
return result;
|
|
1122
|
+
};
|
|
1123
|
+
/**
|
|
1124
|
+
* Get default commit message from git
|
|
1125
|
+
*/
|
|
1126
|
+
const getDefaultMessage = (projectDir) => {
|
|
1127
|
+
try {
|
|
1128
|
+
return `${execSync("git rev-parse --abbrev-ref HEAD", {
|
|
1129
|
+
encoding: "utf8",
|
|
1130
|
+
cwd: projectDir
|
|
1131
|
+
}).trim()}: ${execSync("git rev-parse --short HEAD", {
|
|
1132
|
+
encoding: "utf8",
|
|
1133
|
+
cwd: projectDir
|
|
1134
|
+
}).trim()}`;
|
|
1135
|
+
} catch {
|
|
1136
|
+
debug("Using default bundle message as no message was provided and not in a Git repo.");
|
|
1137
|
+
return "PWA Kit Bundle";
|
|
1138
|
+
}
|
|
1139
|
+
};
|
|
1140
|
+
|
|
1141
|
+
//#endregion
|
|
1142
|
+
//#region src/bundle.ts
|
|
1143
|
+
/**
|
|
1144
|
+
* Create a bundle from the build directory
|
|
1145
|
+
*/
|
|
1146
|
+
const createBundle = async (options) => {
|
|
1147
|
+
const { message, ssr_parameters, ssr_only, ssr_shared, buildDirectory, projectDirectory, projectSlug } = options;
|
|
1148
|
+
const tmpDir = fs.mkdtempSync(path$1.join(os.tmpdir(), "storefront-next-dev-push-"));
|
|
1149
|
+
const destination = path$1.join(tmpDir, "build.tar");
|
|
1150
|
+
const filesInArchive = [];
|
|
1151
|
+
if (!ssr_only || ssr_only.length === 0 || !ssr_shared || ssr_shared.length === 0) throw new Error("no ssrOnly or ssrShared files are defined");
|
|
1152
|
+
return new Promise((resolve$2, reject) => {
|
|
1153
|
+
const output = fs.createWriteStream(destination);
|
|
1154
|
+
const archive = archiver("tar");
|
|
1155
|
+
archive.pipe(output);
|
|
1156
|
+
const newRoot = path$1.join(projectSlug, "bld", "");
|
|
1157
|
+
const storybookExclusionMatchers = [
|
|
1158
|
+
"**/*.stories.tsx",
|
|
1159
|
+
"**/*.stories.ts",
|
|
1160
|
+
"**/*-snapshot.tsx",
|
|
1161
|
+
".storybook/**/*",
|
|
1162
|
+
"storybook-static/**/*",
|
|
1163
|
+
"**/__mocks__/**/*",
|
|
1164
|
+
"**/__snapshots__/**/*"
|
|
1165
|
+
].map((pattern) => new Minimatch(pattern, { nocomment: true }));
|
|
1166
|
+
archive.directory(buildDirectory, "", (entry) => {
|
|
1167
|
+
if (entry.name && storybookExclusionMatchers.some((matcher) => matcher.match(entry.name))) return false;
|
|
1168
|
+
if (entry.stats?.isFile() && entry.name) filesInArchive.push(entry.name);
|
|
1169
|
+
entry.prefix = newRoot;
|
|
1170
|
+
return entry;
|
|
1171
|
+
});
|
|
1172
|
+
archive.on("error", reject);
|
|
1173
|
+
output.on("finish", () => {
|
|
1174
|
+
try {
|
|
1175
|
+
const { dependencies = {}, devDependencies = {} } = getProjectPkg(projectDirectory);
|
|
1176
|
+
const dependencyTree = getProjectDependencyTree(projectDirectory);
|
|
1177
|
+
const pwaKitDeps = dependencyTree ? getPwaKitDependencies(dependencyTree) : {};
|
|
1178
|
+
const bundle_metadata = { dependencies: {
|
|
1179
|
+
...dependencies,
|
|
1180
|
+
...devDependencies,
|
|
1181
|
+
...pwaKitDeps
|
|
1182
|
+
} };
|
|
1183
|
+
const data = fs.readFileSync(destination);
|
|
1184
|
+
const encoding = "base64";
|
|
1185
|
+
fs.rmSync(tmpDir, { recursive: true });
|
|
1186
|
+
const createGlobMatcher = (patterns) => {
|
|
1187
|
+
const allPatterns = patterns.map((pattern) => new Minimatch(pattern, { nocomment: true })).filter((pattern) => !pattern.empty);
|
|
1188
|
+
const positivePatterns = allPatterns.filter((pattern) => !pattern.negate);
|
|
1189
|
+
const negativePatterns = allPatterns.filter((pattern) => pattern.negate);
|
|
1190
|
+
return (filePath) => {
|
|
1191
|
+
if (filePath) {
|
|
1192
|
+
const positive = positivePatterns.some((pattern) => pattern.match(filePath));
|
|
1193
|
+
const negative = negativePatterns.some((pattern) => !pattern.match(filePath));
|
|
1194
|
+
return positive && !negative;
|
|
1195
|
+
}
|
|
1196
|
+
return false;
|
|
1197
|
+
};
|
|
1198
|
+
};
|
|
1199
|
+
resolve$2({
|
|
1200
|
+
message,
|
|
1201
|
+
encoding,
|
|
1202
|
+
data: data.toString(encoding),
|
|
1203
|
+
ssr_parameters,
|
|
1204
|
+
ssr_only: filesInArchive.filter(createGlobMatcher(ssr_only)),
|
|
1205
|
+
ssr_shared: filesInArchive.filter(createGlobMatcher(ssr_shared)),
|
|
1206
|
+
bundle_metadata
|
|
1207
|
+
});
|
|
1208
|
+
} catch (err) {
|
|
1209
|
+
reject(err);
|
|
1210
|
+
}
|
|
1211
|
+
});
|
|
1212
|
+
archive.finalize().catch(reject);
|
|
1213
|
+
});
|
|
1214
|
+
};
|
|
1215
|
+
|
|
1216
|
+
//#endregion
|
|
1217
|
+
//#region src/cloud-api.ts
|
|
1218
|
+
var CloudAPIClient = class {
|
|
1219
|
+
credentials;
|
|
1220
|
+
origin;
|
|
1221
|
+
constructor({ credentials, origin }) {
|
|
1222
|
+
this.credentials = credentials;
|
|
1223
|
+
this.origin = origin;
|
|
1224
|
+
}
|
|
1225
|
+
getAuthHeader() {
|
|
1226
|
+
const { username, api_key } = this.credentials;
|
|
1227
|
+
return { Authorization: `Basic ${Buffer.from(`${username}:${api_key}`, "binary").toString("base64")}` };
|
|
1228
|
+
}
|
|
1229
|
+
getHeaders() {
|
|
1230
|
+
return {
|
|
1231
|
+
"User-Agent": `storefront-next-dev@${version}`,
|
|
1232
|
+
...this.getAuthHeader()
|
|
1233
|
+
};
|
|
1234
|
+
}
|
|
1235
|
+
/**
|
|
1236
|
+
* Push bundle to Managed Runtime
|
|
1237
|
+
*/
|
|
1238
|
+
async push(bundle, projectSlug, target) {
|
|
1239
|
+
const base = `api/projects/${projectSlug}/builds/`;
|
|
1240
|
+
const pathname = target ? `${base}${target}/` : base;
|
|
1241
|
+
const url = new URL$1(this.origin);
|
|
1242
|
+
url.pathname = pathname;
|
|
1243
|
+
const body = Buffer.from(JSON.stringify(bundle));
|
|
1244
|
+
const headers = {
|
|
1245
|
+
...this.getHeaders(),
|
|
1246
|
+
"Content-Length": body.length.toString()
|
|
1247
|
+
};
|
|
1248
|
+
const res = await fetch(url.toString(), {
|
|
1249
|
+
body,
|
|
1250
|
+
method: "POST",
|
|
1251
|
+
headers
|
|
1252
|
+
});
|
|
1253
|
+
if (res.status >= 400) {
|
|
1254
|
+
const bodyText = await res.text();
|
|
1255
|
+
let errorData;
|
|
1256
|
+
try {
|
|
1257
|
+
errorData = JSON.parse(bodyText);
|
|
1258
|
+
} catch {
|
|
1259
|
+
errorData = { message: bodyText };
|
|
1260
|
+
}
|
|
1261
|
+
throw new Error(`HTTP ${res.status}: ${errorData.message || bodyText}\nFor more information visit https://developer.salesforce.com/docs/commerce/pwa-kit-managed-runtime/guide/pushing-and-deploying-bundles.html`);
|
|
1262
|
+
}
|
|
1263
|
+
return await res.json();
|
|
1264
|
+
}
|
|
1265
|
+
/**
|
|
1266
|
+
* Wait for deployment to complete
|
|
1267
|
+
*/
|
|
1268
|
+
async waitForDeploy(project, environment) {
|
|
1269
|
+
return new Promise((resolve$2, reject) => {
|
|
1270
|
+
const delay = 3e4;
|
|
1271
|
+
const check = async () => {
|
|
1272
|
+
const url = new URL$1(`/api/projects/${project}/target/${environment}`, this.origin);
|
|
1273
|
+
const res = await fetch(url, { headers: this.getHeaders() });
|
|
1274
|
+
if (!res.ok) {
|
|
1275
|
+
const text = await res.text();
|
|
1276
|
+
let json;
|
|
1277
|
+
try {
|
|
1278
|
+
if (text) json = JSON.parse(text);
|
|
1279
|
+
} catch {}
|
|
1280
|
+
const message = json?.detail ?? text;
|
|
1281
|
+
const detail = message ? `: ${message}` : "";
|
|
1282
|
+
throw new Error(`${res.status} ${res.statusText}${detail}`);
|
|
1283
|
+
}
|
|
1284
|
+
const data = await res.json();
|
|
1285
|
+
if (typeof data.state !== "string") return reject(/* @__PURE__ */ new Error("An unknown state occurred when polling the deployment."));
|
|
1286
|
+
switch (data.state) {
|
|
1287
|
+
case "CREATE_IN_PROGRESS":
|
|
1288
|
+
case "PUBLISH_IN_PROGRESS":
|
|
1289
|
+
setTimeout(() => {
|
|
1290
|
+
check().catch(reject);
|
|
1291
|
+
}, delay);
|
|
1292
|
+
return;
|
|
1293
|
+
case "CREATE_FAILED":
|
|
1294
|
+
case "PUBLISH_FAILED": return reject(/* @__PURE__ */ new Error("Deployment failed."));
|
|
1295
|
+
case "ACTIVE": return resolve$2();
|
|
1296
|
+
default: return reject(/* @__PURE__ */ new Error(`Unknown deployment state "${data.state}".`));
|
|
1297
|
+
}
|
|
1298
|
+
};
|
|
1299
|
+
setTimeout(() => {
|
|
1300
|
+
check().catch(reject);
|
|
1301
|
+
}, delay);
|
|
1302
|
+
});
|
|
1303
|
+
}
|
|
1304
|
+
};
|
|
1305
|
+
|
|
1306
|
+
//#endregion
|
|
1307
|
+
//#region src/config.ts
|
|
1308
|
+
const SFNEXT_BASE_CARTRIDGE_NAME = "app_storefrontnext_base";
|
|
1309
|
+
const SFNEXT_BASE_CARTRIDGE_OUTPUT_DIR = `${SFNEXT_BASE_CARTRIDGE_NAME}/cartridge/experience`;
|
|
1310
|
+
/**
|
|
1311
|
+
* Build MRT SSR configuration for bundle deployment
|
|
1312
|
+
*
|
|
1313
|
+
* Defines which files should be:
|
|
1314
|
+
* - Server-only (ssrOnly): Deployed only to Lambda functions
|
|
1315
|
+
* - Shared (ssrShared): Deployed to both Lambda and CDN
|
|
1316
|
+
*
|
|
1317
|
+
* @param buildDirectory - Path to the build output directory
|
|
1318
|
+
* @param projectDirectory - Path to the project root (reserved for future use)
|
|
1319
|
+
* @returns MRT SSR configuration with glob patterns
|
|
1320
|
+
*/
|
|
1321
|
+
const buildMrtConfig = (_buildDirectory, _projectDirectory) => {
|
|
1322
|
+
const ssrEntryPoint = getMrtEntryFile("production");
|
|
1323
|
+
return {
|
|
1324
|
+
ssrOnly: [
|
|
1325
|
+
"server/**/*",
|
|
1326
|
+
"loader.js",
|
|
1327
|
+
`${ssrEntryPoint}.{js,mjs,cjs}`,
|
|
1328
|
+
`${ssrEntryPoint}.{js,mjs,cjs}.map`,
|
|
1329
|
+
"!static/**/*",
|
|
1330
|
+
"sfnext-server-*.mjs",
|
|
1331
|
+
"!**/*.stories.tsx",
|
|
1332
|
+
"!**/*.stories.ts",
|
|
1333
|
+
"!**/*-snapshot.tsx",
|
|
1334
|
+
"!.storybook/**/*",
|
|
1335
|
+
"!storybook-static/**/*",
|
|
1336
|
+
"!**/__mocks__/**/*",
|
|
1337
|
+
"!**/__snapshots__/**/*"
|
|
1338
|
+
],
|
|
1339
|
+
ssrShared: [
|
|
1340
|
+
"client/**/*",
|
|
1341
|
+
"static/**/*",
|
|
1342
|
+
"**/*.css",
|
|
1343
|
+
"**/*.png",
|
|
1344
|
+
"**/*.jpg",
|
|
1345
|
+
"**/*.jpeg",
|
|
1346
|
+
"**/*.gif",
|
|
1347
|
+
"**/*.svg",
|
|
1348
|
+
"**/*.ico",
|
|
1349
|
+
"**/*.woff",
|
|
1350
|
+
"**/*.woff2",
|
|
1351
|
+
"**/*.ttf",
|
|
1352
|
+
"**/*.eot",
|
|
1353
|
+
"!**/*.stories.tsx",
|
|
1354
|
+
"!**/*.stories.ts",
|
|
1355
|
+
"!**/*-snapshot.tsx",
|
|
1356
|
+
"!.storybook/**/*",
|
|
1357
|
+
"!storybook-static/**/*",
|
|
1358
|
+
"!**/__mocks__/**/*",
|
|
1359
|
+
"!**/__snapshots__/**/*"
|
|
1360
|
+
],
|
|
1361
|
+
ssrParameters: { ssrFunctionNodeVersion: "24.x" }
|
|
1362
|
+
};
|
|
1363
|
+
};
|
|
1364
|
+
|
|
1365
|
+
//#endregion
|
|
1366
|
+
//#region src/commands/push.ts
|
|
1367
|
+
/**
|
|
1368
|
+
* Main function to push bundle to Managed Runtime
|
|
1369
|
+
*/
|
|
1370
|
+
async function push(options) {
|
|
1371
|
+
const mrtConfig = getMrtConfig(options.projectDirectory);
|
|
1372
|
+
const resolvedTarget = options.target ?? mrtConfig.defaultMrtTarget;
|
|
1373
|
+
if (options.wait && !resolvedTarget) throw new Error("You must provide a target to deploy to when using --wait (via --target flag or .env MRT_TARGET)");
|
|
1374
|
+
if (options.user && !options.key || !options.user && options.key) throw new Error("You must provide both --user and --key together, or neither");
|
|
1375
|
+
if (!fs.existsSync(options.projectDirectory)) throw new Error(`Project directory "${options.projectDirectory}" does not exist!`);
|
|
1376
|
+
const projectSlug = options.projectSlug ?? mrtConfig.defaultMrtProject;
|
|
1377
|
+
if (!projectSlug || projectSlug.trim() === "") throw new Error("Project slug could not be determined from CLI, .env, or package.json");
|
|
1378
|
+
const target = resolvedTarget;
|
|
1379
|
+
const buildDirectory = options.buildDirectory ?? getDefaultBuildDir(options.projectDirectory);
|
|
1380
|
+
if (!fs.existsSync(buildDirectory)) throw new Error(`Build directory "${buildDirectory}" does not exist!`);
|
|
1381
|
+
try {
|
|
1382
|
+
if (target) process.env.DEPLOY_TARGET = target;
|
|
1383
|
+
let credentials;
|
|
1384
|
+
if (options.user && options.key) credentials = {
|
|
1385
|
+
username: options.user,
|
|
1386
|
+
api_key: options.key
|
|
1387
|
+
};
|
|
1388
|
+
else credentials = await readCredentials(getCredentialsFile(options.cloudOrigin ?? DEFAULT_CLOUD_ORIGIN, options.credentialsFile));
|
|
1389
|
+
const config = buildMrtConfig(buildDirectory, options.projectDirectory);
|
|
1390
|
+
const message = options.message ?? getDefaultMessage(options.projectDirectory);
|
|
1391
|
+
info(`Creating bundle for project: ${projectSlug}`);
|
|
1392
|
+
if (options.projectSlug) debug("Using project slug from CLI argument");
|
|
1393
|
+
else if (process.env.MRT_PROJECT) debug("Using project slug from .env MRT_PROJECT");
|
|
1394
|
+
else debug("Using project slug from package.json name");
|
|
1395
|
+
if (target) {
|
|
1396
|
+
info(`Target environment: ${target}`);
|
|
1397
|
+
if (options.target) debug("Using target from CLI argument");
|
|
1398
|
+
else debug("Using target from .env");
|
|
1399
|
+
}
|
|
1400
|
+
debug("SSR shared files", config.ssrShared);
|
|
1401
|
+
debug("SSR only files", config.ssrOnly);
|
|
1402
|
+
const bundle = await createBundle({
|
|
1403
|
+
message,
|
|
1404
|
+
ssr_parameters: config.ssrParameters,
|
|
1405
|
+
ssr_only: config.ssrOnly,
|
|
1406
|
+
ssr_shared: config.ssrShared,
|
|
1407
|
+
buildDirectory,
|
|
1408
|
+
projectDirectory: options.projectDirectory,
|
|
1409
|
+
projectSlug
|
|
1410
|
+
});
|
|
1411
|
+
const client = new CloudAPIClient({
|
|
1412
|
+
credentials,
|
|
1413
|
+
origin: options.cloudOrigin ?? DEFAULT_CLOUD_ORIGIN
|
|
1414
|
+
});
|
|
1415
|
+
info(`Beginning upload to ${options.cloudOrigin ?? DEFAULT_CLOUD_ORIGIN}`);
|
|
1416
|
+
const data = await client.push(bundle, projectSlug, target);
|
|
1417
|
+
debug("API response", data);
|
|
1418
|
+
(data.warnings || []).forEach(warn);
|
|
1419
|
+
if (options.wait && target) {
|
|
1420
|
+
success("Bundle uploaded - waiting for deployment to complete");
|
|
1421
|
+
await client.waitForDeploy(projectSlug, target);
|
|
1422
|
+
success("Deployment complete!");
|
|
1423
|
+
} else success("Bundle uploaded successfully!");
|
|
1424
|
+
if (data.url) info(`Bundle URL: ${data.url}`);
|
|
1425
|
+
} catch (err) {
|
|
1426
|
+
error(err.message || err?.toString() || "Unknown error");
|
|
1427
|
+
throw err;
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
//#endregion
|
|
1432
|
+
//#region src/server/ts-import.ts
|
|
1433
|
+
/**
|
|
1434
|
+
* Parse TypeScript paths from tsconfig.json and convert to jiti alias format.
|
|
1435
|
+
*
|
|
1436
|
+
* @param tsconfigPath - Path to tsconfig.json
|
|
1437
|
+
* @param projectDirectory - Project root directory for resolving relative paths
|
|
1438
|
+
* @returns Record of alias mappings for jiti
|
|
1439
|
+
*
|
|
1440
|
+
* @example
|
|
1441
|
+
* // tsconfig.json: { "compilerOptions": { "paths": { "@/*": ["./src/*"] } } }
|
|
1442
|
+
* // Returns: { "@/": "/absolute/path/to/src/" }
|
|
1443
|
+
*/
|
|
1444
|
+
function parseTsconfigPaths(tsconfigPath, projectDirectory) {
|
|
1445
|
+
const alias = {};
|
|
1446
|
+
if (!existsSync$1(tsconfigPath)) return alias;
|
|
1447
|
+
try {
|
|
1448
|
+
const tsconfigContent = readFileSync$1(tsconfigPath, "utf-8");
|
|
1449
|
+
const tsconfig = JSON.parse(tsconfigContent);
|
|
1450
|
+
const paths = tsconfig.compilerOptions?.paths;
|
|
1451
|
+
const baseUrl = tsconfig.compilerOptions?.baseUrl || ".";
|
|
1452
|
+
if (paths) {
|
|
1453
|
+
for (const [key, values] of Object.entries(paths)) if (values && values.length > 0) {
|
|
1454
|
+
const aliasKey = key.replace(/\/\*$/, "/");
|
|
1455
|
+
alias[aliasKey] = resolve(projectDirectory, baseUrl, values[0].replace(/\/\*$/, "/").replace(/^\.\//, ""));
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
} catch {}
|
|
1459
|
+
return alias;
|
|
1460
|
+
}
|
|
1461
|
+
/**
|
|
1462
|
+
* Import a TypeScript file using jiti with proper path alias resolution.
|
|
1463
|
+
* This is a cross-platform alternative to tsx that works on Windows.
|
|
1464
|
+
*
|
|
1465
|
+
* @param filePath - Absolute path to the TypeScript file to import
|
|
1466
|
+
* @param options - Import options including project directory
|
|
1467
|
+
* @returns The imported module
|
|
1468
|
+
*/
|
|
1469
|
+
async function importTypescript(filePath, options) {
|
|
1470
|
+
const { projectDirectory, tsconfigPath = resolve(projectDirectory, "tsconfig.json") } = options;
|
|
1471
|
+
const { createJiti } = await import("jiti");
|
|
1472
|
+
const alias = parseTsconfigPaths(tsconfigPath, projectDirectory);
|
|
1473
|
+
return createJiti(import.meta.url, {
|
|
1474
|
+
fsCache: false,
|
|
1475
|
+
interopDefault: true,
|
|
1476
|
+
alias
|
|
1477
|
+
}).import(filePath);
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
//#endregion
|
|
1481
|
+
//#region src/server/config.ts
|
|
1482
|
+
/**
|
|
1483
|
+
* This is a temporary function before we move the config implementation from
|
|
1484
|
+
* template-retail-rsc-app to the SDK.
|
|
1485
|
+
*
|
|
1486
|
+
* @ TODO: Remove this function after we move the config implementation from
|
|
1487
|
+
* template-retail-rsc-app to the SDK.
|
|
1488
|
+
*
|
|
1489
|
+
*/
|
|
1490
|
+
function loadConfigFromEnv() {
|
|
1491
|
+
const shortCode = process.env.PUBLIC__app__commerce__api__shortCode;
|
|
1492
|
+
const organizationId = process.env.PUBLIC__app__commerce__api__organizationId;
|
|
1493
|
+
const clientId = process.env.PUBLIC__app__commerce__api__clientId;
|
|
1494
|
+
const siteId = process.env.PUBLIC__app__commerce__api__siteId;
|
|
1495
|
+
const proxy = process.env.PUBLIC__app__commerce__api__proxy || "/mobify/proxy/api";
|
|
1496
|
+
if (!shortCode) throw new Error("Missing PUBLIC__app__commerce__api__shortCode environment variable.\nPlease set it in your .env file or environment.");
|
|
1497
|
+
if (!organizationId) throw new Error("Missing PUBLIC__app__commerce__api__organizationId environment variable.\nPlease set it in your .env file or environment.");
|
|
1498
|
+
if (!clientId) throw new Error("Missing PUBLIC__app__commerce__api__clientId environment variable.\nPlease set it in your .env file or environment.");
|
|
1499
|
+
if (!siteId) throw new Error("Missing PUBLIC__app__commerce__api__siteId environment variable.\nPlease set it in your .env file or environment.");
|
|
1500
|
+
return { commerce: { api: {
|
|
1501
|
+
shortCode,
|
|
1502
|
+
organizationId,
|
|
1503
|
+
clientId,
|
|
1504
|
+
siteId,
|
|
1505
|
+
proxy
|
|
1506
|
+
} } };
|
|
1507
|
+
}
|
|
1508
|
+
/**
|
|
1509
|
+
* Load storefront-next project configuration from config.server.ts.
|
|
1510
|
+
* Requires projectDirectory to be provided.
|
|
1511
|
+
*
|
|
1512
|
+
* @param projectDirectory - Project directory to load config.server.ts from
|
|
1513
|
+
* @throws Error if config.server.ts is not found or invalid
|
|
1514
|
+
*/
|
|
1515
|
+
async function loadProjectConfig(projectDirectory) {
|
|
1516
|
+
const configPath = resolve(projectDirectory, "config.server.ts");
|
|
1517
|
+
const tsconfigPath = resolve(projectDirectory, "tsconfig.json");
|
|
1518
|
+
if (!existsSync$1(configPath)) throw new Error(`config.server.ts not found at ${configPath}.\nPlease ensure config.server.ts exists in your project root.`);
|
|
1519
|
+
const config = (await importTypescript(configPath, {
|
|
1520
|
+
projectDirectory,
|
|
1521
|
+
tsconfigPath
|
|
1522
|
+
})).default;
|
|
1523
|
+
if (!config?.app?.commerce?.api) throw new Error("Invalid config.server.ts: missing app.commerce.api configuration.\nPlease ensure your config.server.ts has the commerce API configuration.");
|
|
1524
|
+
const api = config.app.commerce.api;
|
|
1525
|
+
if (!api.shortCode) throw new Error("Missing shortCode in config.server.ts commerce.api configuration");
|
|
1526
|
+
if (!api.organizationId) throw new Error("Missing organizationId in config.server.ts commerce.api configuration");
|
|
1527
|
+
if (!api.clientId) throw new Error("Missing clientId in config.server.ts commerce.api configuration");
|
|
1528
|
+
if (!api.siteId) throw new Error("Missing siteId in config.server.ts commerce.api configuration");
|
|
1529
|
+
return { commerce: { api: {
|
|
1530
|
+
shortCode: api.shortCode,
|
|
1531
|
+
organizationId: api.organizationId,
|
|
1532
|
+
clientId: api.clientId,
|
|
1533
|
+
siteId: api.siteId,
|
|
1534
|
+
proxy: api.proxy || "/mobify/proxy/api"
|
|
1535
|
+
} } };
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
//#endregion
|
|
1539
|
+
//#region src/utils/paths.ts
|
|
1540
|
+
/**
|
|
1541
|
+
* Copyright 2026 Salesforce, Inc.
|
|
1542
|
+
*
|
|
1543
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
1544
|
+
* you may not use this file except in compliance with the License.
|
|
1545
|
+
* You may obtain a copy of the License at
|
|
1546
|
+
*
|
|
1547
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
1548
|
+
*
|
|
1549
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
1550
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
1551
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
1552
|
+
* See the License for the specific language governing permissions and
|
|
1553
|
+
* limitations under the License.
|
|
1554
|
+
*/
|
|
1555
|
+
/**
|
|
1556
|
+
* Get the Commerce Cloud API URL from a short code
|
|
1557
|
+
*/
|
|
1558
|
+
function getCommerceCloudApiUrl(shortCode) {
|
|
1559
|
+
return `https://${shortCode}.api.commercecloud.salesforce.com`;
|
|
1560
|
+
}
|
|
1561
|
+
/**
|
|
1562
|
+
* Get the bundle path for static assets
|
|
1563
|
+
*/
|
|
1564
|
+
function getBundlePath(bundleId) {
|
|
1565
|
+
return `/mobify/bundle/${bundleId}/client/`;
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
//#endregion
|
|
1569
|
+
//#region src/server/middleware/proxy.ts
|
|
1570
|
+
/**
|
|
1571
|
+
* Create proxy middleware for Commerce Cloud API
|
|
1572
|
+
* Proxies requests from /mobify/proxy/api to the Commerce Cloud API
|
|
1573
|
+
*/
|
|
1574
|
+
function createCommerceProxyMiddleware(config) {
|
|
1575
|
+
return createProxyMiddleware({
|
|
1576
|
+
target: getCommerceCloudApiUrl(config.commerce.api.shortCode),
|
|
1577
|
+
changeOrigin: true
|
|
1578
|
+
});
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1581
|
+
//#endregion
|
|
1582
|
+
//#region src/server/middleware/static.ts
|
|
1583
|
+
/**
|
|
1584
|
+
* Create static file serving middleware for client assets
|
|
1585
|
+
* Serves files from build/client at /mobify/bundle/{BUNDLE_ID}/client/
|
|
1586
|
+
*/
|
|
1587
|
+
function createStaticMiddleware(bundleId, projectDirectory) {
|
|
1588
|
+
const bundlePath = getBundlePath(bundleId);
|
|
1589
|
+
const clientBuildDir = path$1.join(projectDirectory, "build", "client");
|
|
1590
|
+
info(`Serving static assets from ${clientBuildDir} at ${bundlePath}`);
|
|
1591
|
+
return express.static(clientBuildDir, { setHeaders: (res) => {
|
|
1592
|
+
res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
|
|
1593
|
+
res.setHeader("x-local-static-cache-control", "1");
|
|
1594
|
+
} });
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
//#endregion
|
|
1598
|
+
//#region src/server/middleware/compression.ts
|
|
1599
|
+
/**
|
|
1600
|
+
* Parse and validate COMPRESSION_LEVEL environment variable
|
|
1601
|
+
* @returns Valid compression level (0-9) or default compression level
|
|
1602
|
+
*/
|
|
1603
|
+
function getCompressionLevel() {
|
|
1604
|
+
const raw = process.env.COMPRESSION_LEVEL;
|
|
1605
|
+
const DEFAULT = zlib.constants.Z_DEFAULT_COMPRESSION;
|
|
1606
|
+
if (raw == null || raw.trim() === "") return DEFAULT;
|
|
1607
|
+
const level = Number(raw);
|
|
1608
|
+
if (!(Number.isInteger(level) && level >= 0 && level <= 9)) {
|
|
1609
|
+
warn(`[compression] Invalid COMPRESSION_LEVEL="${raw}". Using default (${DEFAULT}).`);
|
|
1610
|
+
return DEFAULT;
|
|
1611
|
+
}
|
|
1612
|
+
return level;
|
|
1613
|
+
}
|
|
1614
|
+
/**
|
|
1615
|
+
* Create compression middleware for gzip/brotli compression
|
|
1616
|
+
* Used in preview mode to optimize response sizes
|
|
1617
|
+
*/
|
|
1618
|
+
function createCompressionMiddleware() {
|
|
1619
|
+
return compression({
|
|
1620
|
+
filter: (req, res) => {
|
|
1621
|
+
if (req.headers["x-no-compression"]) return false;
|
|
1622
|
+
return compression.filter(req, res);
|
|
1623
|
+
},
|
|
1624
|
+
level: getCompressionLevel()
|
|
1625
|
+
});
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
//#endregion
|
|
1629
|
+
//#region src/server/middleware/logging.ts
|
|
1630
|
+
/**
|
|
1631
|
+
* Patterns for URLs to skip logging (static assets and Vite internals)
|
|
1632
|
+
*/
|
|
1633
|
+
const SKIP_PATTERNS = [
|
|
1634
|
+
"/@vite/**",
|
|
1635
|
+
"/@id/**",
|
|
1636
|
+
"/@fs/**",
|
|
1637
|
+
"/@react-router/**",
|
|
1638
|
+
"/src/**",
|
|
1639
|
+
"/node_modules/**",
|
|
1640
|
+
"**/*.js",
|
|
1641
|
+
"**/*.css",
|
|
1642
|
+
"**/*.ts",
|
|
1643
|
+
"**/*.tsx",
|
|
1644
|
+
"**/*.js.map",
|
|
1645
|
+
"**/*.css.map"
|
|
1646
|
+
];
|
|
1647
|
+
/**
|
|
1648
|
+
* Create request logging middleware
|
|
1649
|
+
* Used in dev and preview modes for request visibility
|
|
1650
|
+
*/
|
|
1651
|
+
function createLoggingMiddleware() {
|
|
1652
|
+
morgan.token("status-colored", (req, res) => {
|
|
1653
|
+
const status = res.statusCode;
|
|
1654
|
+
let color = chalk.green;
|
|
1655
|
+
if (status >= 500) color = chalk.red;
|
|
1656
|
+
else if (status >= 400) color = chalk.yellow;
|
|
1657
|
+
else if (status >= 300) color = chalk.cyan;
|
|
1658
|
+
return color(String(status));
|
|
1659
|
+
});
|
|
1660
|
+
morgan.token("method-colored", (req) => {
|
|
1661
|
+
const method = req.method;
|
|
1662
|
+
const colors$1 = {
|
|
1663
|
+
GET: chalk.green,
|
|
1664
|
+
POST: chalk.blue,
|
|
1665
|
+
PUT: chalk.yellow,
|
|
1666
|
+
DELETE: chalk.red,
|
|
1667
|
+
PATCH: chalk.magenta
|
|
1668
|
+
};
|
|
1669
|
+
return (method && colors$1[method] || chalk.white)(method);
|
|
1670
|
+
});
|
|
1671
|
+
return morgan((tokens, req, res) => {
|
|
1672
|
+
return [
|
|
1673
|
+
chalk.gray("["),
|
|
1674
|
+
tokens["method-colored"](req, res),
|
|
1675
|
+
chalk.gray("]"),
|
|
1676
|
+
tokens.url(req, res),
|
|
1677
|
+
"-",
|
|
1678
|
+
tokens["status-colored"](req, res),
|
|
1679
|
+
chalk.gray(`(${tokens["response-time"](req, res)}ms)`)
|
|
1680
|
+
].join(" ");
|
|
1681
|
+
}, { skip: (req) => {
|
|
1682
|
+
return SKIP_PATTERNS.some((pattern) => minimatch(req.url, pattern, { dot: true }));
|
|
1683
|
+
} });
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
//#endregion
|
|
1687
|
+
//#region src/server/middleware/host-header.ts
|
|
1688
|
+
/**
|
|
1689
|
+
* Normalizes the X-Forwarded-Host header to support React Router's CSRF validation features.
|
|
1690
|
+
*
|
|
1691
|
+
* NOTE: This middleware performs header manipulation as a temporary, internal
|
|
1692
|
+
* solution for MRT/Lambda environments. It may be updated or removed if React Router
|
|
1693
|
+
* introduces a first-class configuration for validating against forwarded headers.
|
|
1694
|
+
*
|
|
1695
|
+
* React Router v7.12+ uses the X-Forwarded-Host header (preferring it over Host)
|
|
1696
|
+
* to validate request origins for security. In Managed Runtime (MRT) with a vanity
|
|
1697
|
+
* domain, the eCDN automatically sets the X-Forwarded-Host to the vanity domain.
|
|
1698
|
+
* React Router handles cases where this header contains multiple comma-separated
|
|
1699
|
+
* values by prioritizing the first entry.
|
|
1700
|
+
*
|
|
1701
|
+
* This middleware ensures that X-Forwarded-Host is always present by falling back
|
|
1702
|
+
* to a configured public domain if the header is missing (e.g., local development).
|
|
1703
|
+
* By only modifying X-Forwarded-Host, we provide a consistent environment for
|
|
1704
|
+
* React Router's security checks without modifying the internal 'Host' header,
|
|
1705
|
+
* which is required for environment-specific routing logic (e.g., Hybrid Proxy).
|
|
1706
|
+
*
|
|
1707
|
+
* Priority order:
|
|
1708
|
+
* 1. X-Forwarded-Host: Automatically set by eCDN for vanity domains.
|
|
1709
|
+
* 2. EXTERNAL_DOMAIN_NAME: Fallback environment variable for the public domain
|
|
1710
|
+
* used when no forwarded headers are present (e.g., local development).
|
|
1711
|
+
*/
|
|
1712
|
+
function createHostHeaderMiddleware() {
|
|
1713
|
+
return (req, _res, next) => {
|
|
1714
|
+
if (!req.get("x-forwarded-host") && process.env.EXTERNAL_DOMAIN_NAME) req.headers["x-forwarded-host"] = process.env.EXTERNAL_DOMAIN_NAME;
|
|
1715
|
+
next();
|
|
1716
|
+
};
|
|
1717
|
+
}
|
|
1718
|
+
|
|
1719
|
+
//#endregion
|
|
1720
|
+
//#region src/server/utils.ts
|
|
1721
|
+
/**
|
|
1722
|
+
* Patch React Router build to rewrite asset URLs with the correct bundle path
|
|
1723
|
+
* This is needed because the build output uses /assets/ but we preview at /mobify/bundle/{BUNDLE_ID}/client/assets/
|
|
1724
|
+
*/
|
|
1725
|
+
function patchReactRouterBuild(build, bundleId) {
|
|
1726
|
+
const bundlePath = getBundlePath(bundleId);
|
|
1727
|
+
const patchedAssetsJson = JSON.stringify(build.assets).replace(/"\/assets\//g, `"${bundlePath}assets/`);
|
|
1728
|
+
const newAssets = JSON.parse(patchedAssetsJson);
|
|
1729
|
+
return Object.assign({}, build, {
|
|
1730
|
+
publicPath: bundlePath,
|
|
1731
|
+
assets: newAssets
|
|
1732
|
+
});
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
//#endregion
|
|
1736
|
+
//#region src/server/modes.ts
|
|
1737
|
+
/**
|
|
1738
|
+
* Default feature configuration for each server mode
|
|
1739
|
+
*/
|
|
1740
|
+
const ServerModeFeatureMap = {
|
|
1741
|
+
development: {
|
|
1742
|
+
enableProxy: true,
|
|
1743
|
+
enableStaticServing: false,
|
|
1744
|
+
enableCompression: false,
|
|
1745
|
+
enableLogging: true,
|
|
1746
|
+
enableAssetUrlPatching: false
|
|
1747
|
+
},
|
|
1748
|
+
preview: {
|
|
1749
|
+
enableProxy: true,
|
|
1750
|
+
enableStaticServing: true,
|
|
1751
|
+
enableCompression: true,
|
|
1752
|
+
enableLogging: true,
|
|
1753
|
+
enableAssetUrlPatching: true
|
|
1754
|
+
},
|
|
1755
|
+
production: {
|
|
1756
|
+
enableProxy: false,
|
|
1757
|
+
enableStaticServing: false,
|
|
1758
|
+
enableCompression: true,
|
|
1759
|
+
enableLogging: true,
|
|
1760
|
+
enableAssetUrlPatching: true
|
|
1761
|
+
}
|
|
1762
|
+
};
|
|
1763
|
+
|
|
1764
|
+
//#endregion
|
|
1765
|
+
//#region src/server/index.ts
|
|
1766
|
+
/**
|
|
1767
|
+
* Create a unified Express server for development, preview, or production mode
|
|
1768
|
+
*/
|
|
1769
|
+
async function createServer(options) {
|
|
1770
|
+
const { mode, projectDirectory = process.cwd(), config: providedConfig, vite, build, streaming = false, enableProxy = ServerModeFeatureMap[mode].enableProxy, enableStaticServing = ServerModeFeatureMap[mode].enableStaticServing, enableCompression = ServerModeFeatureMap[mode].enableCompression, enableLogging = ServerModeFeatureMap[mode].enableLogging, enableAssetUrlPatching = ServerModeFeatureMap[mode].enableAssetUrlPatching } = options;
|
|
1771
|
+
if (mode === "development" && !vite) throw new Error("Vite dev server instance is required for development mode");
|
|
1772
|
+
if ((mode === "preview" || mode === "production") && !build) throw new Error("React Router server build is required for preview/production mode");
|
|
1773
|
+
const config = providedConfig ?? loadConfigFromEnv();
|
|
1774
|
+
const bundleId = process.env.BUNDLE_ID ?? "local";
|
|
1775
|
+
const app = express();
|
|
1776
|
+
app.disable("x-powered-by");
|
|
1777
|
+
if (enableLogging) app.use(createLoggingMiddleware());
|
|
1778
|
+
if (enableCompression && !streaming) app.use(createCompressionMiddleware());
|
|
1779
|
+
if (enableStaticServing && build) {
|
|
1780
|
+
const bundlePath = getBundlePath(bundleId);
|
|
1781
|
+
app.use(bundlePath, createStaticMiddleware(bundleId, projectDirectory));
|
|
1782
|
+
}
|
|
1783
|
+
const middlewareRegistryPath = resolve(projectDirectory, "src/server/middleware-registry.ts");
|
|
1784
|
+
if (existsSync$1(middlewareRegistryPath)) {
|
|
1785
|
+
const registry = await importTypescript(middlewareRegistryPath, { projectDirectory });
|
|
1786
|
+
if (registry.customMiddlewares && Array.isArray(registry.customMiddlewares)) registry.customMiddlewares.forEach((middleware) => {
|
|
1787
|
+
app.use(middleware);
|
|
1788
|
+
});
|
|
1789
|
+
}
|
|
1790
|
+
if (mode === "development" && vite) app.use(vite.middlewares);
|
|
1791
|
+
if (enableProxy) app.use(config.commerce.api.proxy, createCommerceProxyMiddleware(config));
|
|
1792
|
+
app.use(createHostHeaderMiddleware());
|
|
1793
|
+
app.all("*", await createSSRHandler(mode, bundleId, vite, build, enableAssetUrlPatching));
|
|
1794
|
+
return app;
|
|
1795
|
+
}
|
|
1796
|
+
/**
|
|
1797
|
+
* Create the SSR request handler based on mode
|
|
1798
|
+
*/
|
|
1799
|
+
async function createSSRHandler(mode, bundleId, vite, build, enableAssetUrlPatching) {
|
|
1800
|
+
if (mode === "development" && vite) {
|
|
1801
|
+
const { isRunnableDevEnvironment } = await import("vite");
|
|
1802
|
+
return async (req, res, next) => {
|
|
1803
|
+
try {
|
|
1804
|
+
const ssrEnvironment = vite.environments.ssr;
|
|
1805
|
+
if (!isRunnableDevEnvironment(ssrEnvironment)) {
|
|
1806
|
+
next(/* @__PURE__ */ new Error("SSR environment is not runnable. Please ensure:\n 1. \"@salesforce/storefront-next-dev\" plugin is added to vite.config.ts\n 2. React Router config uses the Storefront Next preset"));
|
|
1807
|
+
return;
|
|
1808
|
+
}
|
|
1809
|
+
await createRequestHandler({
|
|
1810
|
+
build: await ssrEnvironment.runner.import("virtual:react-router/server-build"),
|
|
1811
|
+
mode: process.env.NODE_ENV
|
|
1812
|
+
})(req, res, next);
|
|
1813
|
+
} catch (error$1) {
|
|
1814
|
+
vite.ssrFixStacktrace(error$1);
|
|
1815
|
+
next(error$1);
|
|
1816
|
+
}
|
|
1817
|
+
};
|
|
1818
|
+
} else if (build) {
|
|
1819
|
+
let patchedBuild = build;
|
|
1820
|
+
if (enableAssetUrlPatching) patchedBuild = patchReactRouterBuild(build, bundleId);
|
|
1821
|
+
return createRequestHandler({
|
|
1822
|
+
build: patchedBuild,
|
|
1823
|
+
mode: process.env.NODE_ENV
|
|
1824
|
+
});
|
|
1825
|
+
} else throw new Error("Invalid server configuration: no vite or build provided");
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
//#endregion
|
|
1829
|
+
//#region src/extensibility/path-util.ts
|
|
1830
|
+
const FILE_EXTENSIONS = [
|
|
1831
|
+
".tsx",
|
|
1832
|
+
".ts",
|
|
1833
|
+
".d.ts"
|
|
1834
|
+
];
|
|
1835
|
+
function isSupportedFileExtension(fileName) {
|
|
1836
|
+
return FILE_EXTENSIONS.some((ext) => fileName.endsWith(ext));
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
//#endregion
|
|
1840
|
+
//#region src/extensibility/trim-extensions.ts
|
|
1841
|
+
const SINGLE_LINE_MARKER = "@sfdc-extension-line";
|
|
1842
|
+
const BLOCK_MARKER_START = "@sfdc-extension-block-start";
|
|
1843
|
+
const BLOCK_MARKER_END = "@sfdc-extension-block-end";
|
|
1844
|
+
const FILE_MARKER = "@sfdc-extension-file";
|
|
1845
|
+
let verbose = false;
|
|
1846
|
+
function trimExtensions(directory, selectedExtensions, extensionConfig, verboseOverride = false) {
|
|
1847
|
+
const startTime = Date.now();
|
|
1848
|
+
verbose = verboseOverride ?? false;
|
|
1849
|
+
const configuredExtensions = extensionConfig?.extensions || {};
|
|
1850
|
+
const extensions = {};
|
|
1851
|
+
Object.keys(configuredExtensions).forEach((pluginKey) => {
|
|
1852
|
+
extensions[pluginKey] = Boolean(selectedExtensions?.[pluginKey]) || false;
|
|
1853
|
+
});
|
|
1854
|
+
if (Object.keys(extensions).length === 0) {
|
|
1855
|
+
if (verbose) console.log("No plugins found, skipping trim");
|
|
1856
|
+
return;
|
|
1857
|
+
}
|
|
1858
|
+
const processDirectory = (dir) => {
|
|
1859
|
+
fs$1.readdirSync(dir).forEach((file) => {
|
|
1860
|
+
const filePath = path$1.join(dir, file);
|
|
1861
|
+
const stats = fs$1.statSync(filePath);
|
|
1862
|
+
if (!filePath.includes("node_modules")) {
|
|
1863
|
+
if (stats.isDirectory()) processDirectory(filePath);
|
|
1864
|
+
else if (isSupportedFileExtension(file)) processFile(filePath, extensions);
|
|
1865
|
+
}
|
|
1866
|
+
});
|
|
1867
|
+
};
|
|
1868
|
+
processDirectory(directory);
|
|
1869
|
+
if (extensionConfig?.extensions) {
|
|
1870
|
+
deleteExtensionFolders(directory, extensions, extensionConfig);
|
|
1871
|
+
updateExtensionConfig(directory, extensions);
|
|
1872
|
+
}
|
|
1873
|
+
const endTime = Date.now();
|
|
1874
|
+
if (verbose) console.log(`Trim extensions took ${endTime - startTime}ms`);
|
|
1875
|
+
}
|
|
1876
|
+
/**
|
|
1877
|
+
* Update the extension config file to only include the selected extensions.
|
|
1878
|
+
* @param projectDirectory - The project directory
|
|
1879
|
+
* @param extensionSelections - The selected extensions
|
|
1880
|
+
*/
|
|
1881
|
+
function updateExtensionConfig(projectDirectory, extensionSelections) {
|
|
1882
|
+
const extensionConfigPath = path$1.join(projectDirectory, "src", "extensions", "config.json");
|
|
1883
|
+
const extensionConfig = JSON.parse(fs$1.readFileSync(extensionConfigPath, "utf8"));
|
|
1884
|
+
Object.keys(extensionConfig.extensions).forEach((extensionKey) => {
|
|
1885
|
+
if (!extensionSelections[extensionKey]) delete extensionConfig.extensions[extensionKey];
|
|
1886
|
+
});
|
|
1887
|
+
fs$1.writeFileSync(extensionConfigPath, JSON.stringify({ extensions: extensionConfig.extensions }, null, 4), "utf8");
|
|
1888
|
+
}
|
|
1889
|
+
/**
|
|
1890
|
+
* Process a file to trim extension-specific code based on markers.
|
|
1891
|
+
* @param filePath - The file path to process
|
|
1892
|
+
* @param extensions - The extension selections
|
|
1893
|
+
*/
|
|
1894
|
+
function processFile(filePath, extensions) {
|
|
1895
|
+
const source = fs$1.readFileSync(filePath, "utf-8");
|
|
1896
|
+
if (source.includes(FILE_MARKER)) {
|
|
1897
|
+
const markerLine = source.split("\n").find((line) => line.includes(FILE_MARKER));
|
|
1898
|
+
const extMatch = Object.keys(extensions).find((ext) => markerLine.includes(ext));
|
|
1899
|
+
if (!extMatch) {
|
|
1900
|
+
if (verbose) console.warn(`File ${filePath} is marked with ${markerLine} but it does not match any known extensions`);
|
|
1901
|
+
} else if (extensions[extMatch] === false) {
|
|
1902
|
+
try {
|
|
1903
|
+
fs$1.unlinkSync(filePath);
|
|
1904
|
+
if (verbose) console.log(`Deleted file ${filePath}`);
|
|
1905
|
+
} catch (e) {
|
|
1906
|
+
const error$1 = e;
|
|
1907
|
+
console.error(`Error deleting file ${filePath}: ${error$1.message}`);
|
|
1908
|
+
throw e;
|
|
1909
|
+
}
|
|
1910
|
+
return;
|
|
1911
|
+
}
|
|
1912
|
+
}
|
|
1913
|
+
const extKeys = Object.keys(extensions);
|
|
1914
|
+
if (new RegExp(extKeys.join("|"), "g").test(source)) {
|
|
1915
|
+
const lines = source.split("\n");
|
|
1916
|
+
const newLines = [];
|
|
1917
|
+
const blockMarkers = [];
|
|
1918
|
+
let skippingBlock = false;
|
|
1919
|
+
let i = 0;
|
|
1920
|
+
while (i < lines.length) {
|
|
1921
|
+
const line = lines[i];
|
|
1922
|
+
if (line.includes(SINGLE_LINE_MARKER)) {
|
|
1923
|
+
const matchingExtension = Object.keys(extensions).find((extension) => line.includes(extension));
|
|
1924
|
+
if (matchingExtension && extensions[matchingExtension] === false) {
|
|
1925
|
+
i += 2;
|
|
1926
|
+
continue;
|
|
1927
|
+
}
|
|
1928
|
+
} else if (line.includes(BLOCK_MARKER_START)) {
|
|
1929
|
+
const matchingExtension = Object.keys(extensions).find((extension) => line.includes(extension));
|
|
1930
|
+
if (matchingExtension) {
|
|
1931
|
+
blockMarkers.push({
|
|
1932
|
+
extension: matchingExtension,
|
|
1933
|
+
line: i
|
|
1934
|
+
});
|
|
1935
|
+
skippingBlock = extensions[matchingExtension] === false;
|
|
1936
|
+
} else if (verbose) console.warn(`Warning: Unknown marker found in ${filePath} at line ${i}: \n${line}`);
|
|
1937
|
+
} else if (line.includes(BLOCK_MARKER_END)) {
|
|
1938
|
+
if (Object.keys(extensions).find((extension) => line.includes(extension))) {
|
|
1939
|
+
const extension = Object.keys(extensions).find((p) => line.includes(p));
|
|
1940
|
+
if (blockMarkers.length === 0) throw new Error(`Block marker mismatch in ${filePath}, encountered end marker ${extension} without a matching start marker at line ${i}:\n${lines[i]}`);
|
|
1941
|
+
const startMarker = blockMarkers.pop();
|
|
1942
|
+
if (!extension || startMarker.extension !== extension) throw new Error(`Block marker mismatch in ${filePath}, expected end marker for ${startMarker.extension} but got ${extension} at line ${i}:\n${lines[i]}`);
|
|
1943
|
+
if (extensions[extension] === false) {
|
|
1944
|
+
skippingBlock = false;
|
|
1945
|
+
i++;
|
|
1946
|
+
continue;
|
|
1947
|
+
}
|
|
1948
|
+
}
|
|
1949
|
+
}
|
|
1950
|
+
if (!skippingBlock) newLines.push(line);
|
|
1951
|
+
i++;
|
|
1952
|
+
}
|
|
1953
|
+
if (blockMarkers.length > 0) throw new Error(`Unclosed end marker found in ${filePath}: ${blockMarkers[blockMarkers.length - 1].extension}`);
|
|
1954
|
+
const newSource = newLines.join("\n");
|
|
1955
|
+
if (newSource !== source) try {
|
|
1956
|
+
fs$1.writeFileSync(filePath, newSource);
|
|
1957
|
+
if (verbose) console.log(`Updated file ${filePath}`);
|
|
1958
|
+
} catch (e) {
|
|
1959
|
+
const error$1 = e;
|
|
1960
|
+
console.error(`Error updating file ${filePath}: ${error$1.message}`);
|
|
1961
|
+
throw e;
|
|
1962
|
+
}
|
|
1963
|
+
}
|
|
1964
|
+
}
|
|
1965
|
+
/**
|
|
1966
|
+
* Delete extension folders for disabled extensions.
|
|
1967
|
+
* @param projectRoot - The project root directory
|
|
1968
|
+
* @param extensions - The extension selections
|
|
1969
|
+
* @param extensionConfig - The extension configuration
|
|
1970
|
+
*/
|
|
1971
|
+
function deleteExtensionFolders(projectRoot, extensions, extensionConfig) {
|
|
1972
|
+
const extensionsDir = path$1.join(projectRoot, "src", "extensions");
|
|
1973
|
+
if (!fs$1.existsSync(extensionsDir)) return;
|
|
1974
|
+
const configuredExtensions = extensionConfig.extensions;
|
|
1975
|
+
Object.keys(extensions).filter((ext) => extensions[ext] === false).forEach((extKey) => {
|
|
1976
|
+
const extensionMeta = configuredExtensions[extKey];
|
|
1977
|
+
if (extensionMeta?.folder) {
|
|
1978
|
+
const extensionFolderPath = path$1.join(extensionsDir, extensionMeta.folder);
|
|
1979
|
+
if (fs$1.existsSync(extensionFolderPath)) try {
|
|
1980
|
+
fs$1.rmSync(extensionFolderPath, {
|
|
1981
|
+
recursive: true,
|
|
1982
|
+
force: true
|
|
1983
|
+
});
|
|
1984
|
+
if (verbose) console.log(`Deleted extension folder: ${extensionFolderPath}`);
|
|
1985
|
+
} catch (err) {
|
|
1986
|
+
const error$1 = err;
|
|
1987
|
+
if (error$1.code === "EPERM") console.error(`Permission denied - cannot delete ${extensionFolderPath}. You may need to run with sudo or check permissions.`);
|
|
1988
|
+
else console.error(`Error deleting ${extensionFolderPath}: ${error$1.message}`);
|
|
1989
|
+
}
|
|
1990
|
+
}
|
|
1991
|
+
});
|
|
1992
|
+
}
|
|
1993
|
+
|
|
1994
|
+
//#endregion
|
|
1995
|
+
//#region src/cartridge-services/react-router-config.ts
|
|
1996
|
+
let isCliAvailable = null;
|
|
1997
|
+
function checkReactRouterCli(projectDirectory) {
|
|
1998
|
+
if (isCliAvailable !== null) return isCliAvailable;
|
|
1999
|
+
try {
|
|
2000
|
+
execSync$1("react-router --version", {
|
|
2001
|
+
cwd: projectDirectory,
|
|
2002
|
+
env: npmRunPathEnv(),
|
|
2003
|
+
stdio: "pipe"
|
|
2004
|
+
});
|
|
2005
|
+
isCliAvailable = true;
|
|
2006
|
+
} catch {
|
|
2007
|
+
isCliAvailable = false;
|
|
2008
|
+
}
|
|
2009
|
+
return isCliAvailable;
|
|
2010
|
+
}
|
|
2011
|
+
/**
|
|
2012
|
+
* Get the fully resolved routes from React Router by invoking its CLI.
|
|
2013
|
+
* This ensures we get the exact same route resolution as React Router uses internally,
|
|
2014
|
+
* including all presets, file-system routes, and custom route configurations.
|
|
2015
|
+
* @param projectDirectory - The project root directory
|
|
2016
|
+
* @returns Array of resolved route config entries
|
|
2017
|
+
* @example
|
|
2018
|
+
* const routes = getReactRouterRoutes('/path/to/project');
|
|
2019
|
+
* // Returns the same structure as `react-router routes --json`
|
|
2020
|
+
*/
|
|
2021
|
+
function getReactRouterRoutes(projectDirectory) {
|
|
2022
|
+
if (!checkReactRouterCli(projectDirectory)) throw new Error("React Router CLI is not available. Please make sure @react-router/dev is installed and accessible.");
|
|
2023
|
+
const tempFile = join(tmpdir(), `react-router-routes-${randomUUID()}.json`);
|
|
2024
|
+
try {
|
|
2025
|
+
execSync$1(`react-router routes --json > "${tempFile}"`, {
|
|
2026
|
+
cwd: projectDirectory,
|
|
2027
|
+
env: npmRunPathEnv(),
|
|
2028
|
+
encoding: "utf-8",
|
|
2029
|
+
stdio: [
|
|
2030
|
+
"pipe",
|
|
2031
|
+
"pipe",
|
|
2032
|
+
"pipe"
|
|
2033
|
+
]
|
|
2034
|
+
});
|
|
2035
|
+
const output = readFileSync$1(tempFile, "utf-8");
|
|
2036
|
+
return JSON.parse(output);
|
|
2037
|
+
} catch (error$1) {
|
|
2038
|
+
throw new Error(`Failed to get routes from React Router CLI: ${error$1.message}`);
|
|
2039
|
+
} finally {
|
|
2040
|
+
try {
|
|
2041
|
+
if (existsSync$1(tempFile)) unlinkSync(tempFile);
|
|
2042
|
+
} catch {}
|
|
2043
|
+
}
|
|
2044
|
+
}
|
|
2045
|
+
/**
|
|
2046
|
+
* Convert a file path to its corresponding route path using React Router's CLI.
|
|
2047
|
+
* This ensures we get the exact same route resolution as React Router uses internally.
|
|
2048
|
+
* @param filePath - Absolute path to the route file
|
|
2049
|
+
* @param projectRoot - The project root directory
|
|
2050
|
+
* @returns The route path (e.g., '/cart', '/product/:productId')
|
|
2051
|
+
* @example
|
|
2052
|
+
* const route = filePathToRoute('/path/to/project/src/routes/_app.cart.tsx', '/path/to/project');
|
|
2053
|
+
* // Returns: '/cart'
|
|
2054
|
+
*/
|
|
2055
|
+
function filePathToRoute(filePath, projectRoot) {
|
|
2056
|
+
const filePathPosix = filePath.replace(/\\/g, "/");
|
|
2057
|
+
const flatRoutes = flattenRoutes(getReactRouterRoutes(projectRoot));
|
|
2058
|
+
for (const route of flatRoutes) {
|
|
2059
|
+
const routeFilePosix = route.file.replace(/\\/g, "/");
|
|
2060
|
+
if (filePathPosix.endsWith(routeFilePosix) || filePathPosix.endsWith(`/${routeFilePosix}`)) return route.path;
|
|
2061
|
+
const routeFileNormalized = routeFilePosix.replace(/^\.\//, "");
|
|
2062
|
+
if (filePathPosix.endsWith(routeFileNormalized) || filePathPosix.endsWith(`/${routeFileNormalized}`)) return route.path;
|
|
2063
|
+
}
|
|
2064
|
+
console.warn(`Warning: Could not find route for file: ${filePath}`);
|
|
2065
|
+
return "/unknown";
|
|
2066
|
+
}
|
|
2067
|
+
/**
|
|
2068
|
+
* Flatten a nested route tree into a flat array with computed paths.
|
|
2069
|
+
* Each route will have its full path computed from parent paths.
|
|
2070
|
+
* @param routes - The nested route config entries
|
|
2071
|
+
* @param parentPath - The parent path prefix (used internally for recursion)
|
|
2072
|
+
* @returns Flat array of routes with their full paths
|
|
2073
|
+
*/
|
|
2074
|
+
function flattenRoutes(routes, parentPath = "") {
|
|
2075
|
+
const result = [];
|
|
2076
|
+
for (const route of routes) {
|
|
2077
|
+
let fullPath;
|
|
2078
|
+
if (route.index) fullPath = parentPath || "/";
|
|
2079
|
+
else if (route.path) {
|
|
2080
|
+
const pathSegment = route.path.startsWith("/") ? route.path : `/${route.path}`;
|
|
2081
|
+
fullPath = parentPath ? `${parentPath}${pathSegment}`.replace(/\/+/g, "/") : pathSegment;
|
|
2082
|
+
} else fullPath = parentPath || "/";
|
|
2083
|
+
if (route.id) result.push({
|
|
2084
|
+
id: route.id,
|
|
2085
|
+
path: fullPath,
|
|
2086
|
+
file: route.file,
|
|
2087
|
+
index: route.index
|
|
2088
|
+
});
|
|
2089
|
+
if (route.children && route.children.length > 0) {
|
|
2090
|
+
const childPath = route.path ? fullPath : parentPath;
|
|
2091
|
+
result.push(...flattenRoutes(route.children, childPath));
|
|
2092
|
+
}
|
|
2093
|
+
}
|
|
2094
|
+
return result;
|
|
2095
|
+
}
|
|
2096
|
+
|
|
2097
|
+
//#endregion
|
|
2098
|
+
//#region src/cartridge-services/generate-cartridge.ts
|
|
2099
|
+
const SKIP_DIRECTORIES = [
|
|
2100
|
+
"build",
|
|
2101
|
+
"dist",
|
|
2102
|
+
"node_modules",
|
|
2103
|
+
".git",
|
|
2104
|
+
".next",
|
|
2105
|
+
"coverage"
|
|
2106
|
+
];
|
|
2107
|
+
const DEFAULT_COMPONENT_GROUP = "odyssey_base";
|
|
2108
|
+
const ARCH_TYPE_HEADLESS = "headless";
|
|
2109
|
+
const VALID_ATTRIBUTE_TYPES = [
|
|
2110
|
+
"string",
|
|
2111
|
+
"text",
|
|
2112
|
+
"markup",
|
|
2113
|
+
"integer",
|
|
2114
|
+
"boolean",
|
|
2115
|
+
"product",
|
|
2116
|
+
"category",
|
|
2117
|
+
"file",
|
|
2118
|
+
"page",
|
|
2119
|
+
"image",
|
|
2120
|
+
"url",
|
|
2121
|
+
"enum",
|
|
2122
|
+
"custom",
|
|
2123
|
+
"cms_record"
|
|
2124
|
+
];
|
|
2125
|
+
const TYPE_MAPPING = {
|
|
2126
|
+
String: "string",
|
|
2127
|
+
string: "string",
|
|
2128
|
+
Number: "integer",
|
|
2129
|
+
number: "integer",
|
|
2130
|
+
Boolean: "boolean",
|
|
2131
|
+
boolean: "boolean",
|
|
2132
|
+
Date: "string",
|
|
2133
|
+
URL: "url",
|
|
2134
|
+
CMSRecord: "cms_record"
|
|
2135
|
+
};
|
|
2136
|
+
function resolveAttributeType(decoratorType, tsMorphType, fieldName) {
|
|
2137
|
+
if (decoratorType) {
|
|
2138
|
+
if (!VALID_ATTRIBUTE_TYPES.includes(decoratorType)) {
|
|
2139
|
+
console.error(`Error: Invalid attribute type '${decoratorType}' for field '${fieldName || "unknown"}'. Valid types are: ${VALID_ATTRIBUTE_TYPES.join(", ")}`);
|
|
2140
|
+
process.exit(1);
|
|
2141
|
+
}
|
|
2142
|
+
return decoratorType;
|
|
2143
|
+
}
|
|
2144
|
+
if (tsMorphType && TYPE_MAPPING[tsMorphType]) return TYPE_MAPPING[tsMorphType];
|
|
2145
|
+
return "string";
|
|
2146
|
+
}
|
|
2147
|
+
function toHumanReadableName(fieldName) {
|
|
2148
|
+
return fieldName.replace(/([A-Z])/g, " $1").replace(/^./, (str) => str.toUpperCase()).trim();
|
|
2149
|
+
}
|
|
2150
|
+
function toCamelCaseFileName(name) {
|
|
2151
|
+
if (!/[\s-]/.test(name)) return name;
|
|
2152
|
+
return name.split(/[\s-]+/).map((word, index) => {
|
|
2153
|
+
if (index === 0) return word.toLowerCase();
|
|
2154
|
+
return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
|
|
2155
|
+
}).join("");
|
|
2156
|
+
}
|
|
2157
|
+
function getTypeFromTsMorph(property, _sourceFile) {
|
|
2158
|
+
try {
|
|
2159
|
+
const typeNode = property.getTypeNode();
|
|
2160
|
+
if (typeNode) return typeNode.getText().split("|")[0].split("&")[0].trim();
|
|
2161
|
+
} catch {}
|
|
2162
|
+
return "string";
|
|
2163
|
+
}
|
|
2164
|
+
function parseExpression(expression) {
|
|
2165
|
+
if (Node.isStringLiteral(expression)) return expression.getLiteralValue();
|
|
2166
|
+
else if (Node.isNumericLiteral(expression)) return expression.getLiteralValue();
|
|
2167
|
+
else if (Node.isTrueLiteral(expression)) return true;
|
|
2168
|
+
else if (Node.isFalseLiteral(expression)) return false;
|
|
2169
|
+
else if (Node.isObjectLiteralExpression(expression)) return parseNestedObject(expression);
|
|
2170
|
+
else if (Node.isArrayLiteralExpression(expression)) return parseArrayLiteral(expression);
|
|
2171
|
+
else return expression.getText();
|
|
2172
|
+
}
|
|
2173
|
+
function parseNestedObject(objectLiteral) {
|
|
2174
|
+
const result = {};
|
|
2175
|
+
try {
|
|
2176
|
+
const properties = objectLiteral.getProperties();
|
|
2177
|
+
for (const property of properties) if (Node.isPropertyAssignment(property)) {
|
|
2178
|
+
const name = property.getName();
|
|
2179
|
+
const initializer = property.getInitializer();
|
|
2180
|
+
if (initializer) result[name] = parseExpression(initializer);
|
|
2181
|
+
}
|
|
2182
|
+
} catch (error$1) {
|
|
2183
|
+
console.warn(`Warning: Could not parse nested object: ${error$1.message}`);
|
|
2184
|
+
return result;
|
|
2185
|
+
}
|
|
2186
|
+
return result;
|
|
2187
|
+
}
|
|
2188
|
+
function parseArrayLiteral(arrayLiteral) {
|
|
2189
|
+
const result = [];
|
|
2190
|
+
try {
|
|
2191
|
+
const elements = arrayLiteral.getElements();
|
|
2192
|
+
for (const element of elements) result.push(parseExpression(element));
|
|
2193
|
+
} catch (error$1) {
|
|
2194
|
+
console.warn(`Warning: Could not parse array literal: ${error$1.message}`);
|
|
2195
|
+
}
|
|
2196
|
+
return result;
|
|
2197
|
+
}
|
|
2198
|
+
function parseDecoratorArgs(decorator) {
|
|
2199
|
+
const result = {};
|
|
2200
|
+
try {
|
|
2201
|
+
const args = decorator.getArguments();
|
|
2202
|
+
if (args.length === 0) return result;
|
|
2203
|
+
const firstArg = args[0];
|
|
2204
|
+
if (Node.isObjectLiteralExpression(firstArg)) {
|
|
2205
|
+
const properties = firstArg.getProperties();
|
|
2206
|
+
for (const property of properties) if (Node.isPropertyAssignment(property)) {
|
|
2207
|
+
const name = property.getName();
|
|
2208
|
+
const initializer = property.getInitializer();
|
|
2209
|
+
if (initializer) result[name] = parseExpression(initializer);
|
|
2210
|
+
}
|
|
2211
|
+
} else if (Node.isStringLiteral(firstArg)) {
|
|
2212
|
+
result.id = parseExpression(firstArg);
|
|
2213
|
+
if (args.length > 1) {
|
|
2214
|
+
const secondArg = args[1];
|
|
2215
|
+
if (Node.isObjectLiteralExpression(secondArg)) {
|
|
2216
|
+
const properties = secondArg.getProperties();
|
|
2217
|
+
for (const property of properties) if (Node.isPropertyAssignment(property)) {
|
|
2218
|
+
const name = property.getName();
|
|
2219
|
+
const initializer = property.getInitializer();
|
|
2220
|
+
if (initializer) result[name] = parseExpression(initializer);
|
|
2221
|
+
}
|
|
2222
|
+
}
|
|
2223
|
+
}
|
|
2224
|
+
}
|
|
2225
|
+
return result;
|
|
2226
|
+
} catch (error$1) {
|
|
2227
|
+
console.warn(`Warning: Could not parse decorator arguments: ${error$1.message}`);
|
|
2228
|
+
return result;
|
|
2229
|
+
}
|
|
2230
|
+
}
|
|
2231
|
+
function extractAttributesFromSource(sourceFile, className) {
|
|
2232
|
+
const attributes = [];
|
|
2233
|
+
try {
|
|
2234
|
+
const classDeclaration = sourceFile.getClass(className);
|
|
2235
|
+
if (!classDeclaration) return attributes;
|
|
2236
|
+
const properties = classDeclaration.getProperties();
|
|
2237
|
+
for (const property of properties) {
|
|
2238
|
+
const attributeDecorator = property.getDecorator("AttributeDefinition");
|
|
2239
|
+
if (!attributeDecorator) continue;
|
|
2240
|
+
const fieldName = property.getName();
|
|
2241
|
+
const config = parseDecoratorArgs(attributeDecorator);
|
|
2242
|
+
const isRequired = !property.hasQuestionToken();
|
|
2243
|
+
const inferredType = config.type || getTypeFromTsMorph(property, sourceFile);
|
|
2244
|
+
const attribute = {
|
|
2245
|
+
id: config.id || fieldName,
|
|
2246
|
+
name: config.name || toHumanReadableName(fieldName),
|
|
2247
|
+
type: resolveAttributeType(config.type, inferredType, fieldName),
|
|
2248
|
+
required: config.required !== void 0 ? config.required : isRequired,
|
|
2249
|
+
description: config.description || `Field: ${fieldName}`
|
|
2250
|
+
};
|
|
2251
|
+
if (config.values) attribute.values = config.values;
|
|
2252
|
+
if (config.defaultValue !== void 0) attribute.default_value = config.defaultValue;
|
|
2253
|
+
attributes.push(attribute);
|
|
2254
|
+
}
|
|
2255
|
+
} catch (error$1) {
|
|
2256
|
+
console.warn(`Warning: Could not extract attributes from class ${className}: ${error$1.message}`);
|
|
2257
|
+
}
|
|
2258
|
+
return attributes;
|
|
2259
|
+
}
|
|
2260
|
+
function extractRegionDefinitionsFromSource(sourceFile, className) {
|
|
2261
|
+
const regionDefinitions = [];
|
|
2262
|
+
try {
|
|
2263
|
+
const classDeclaration = sourceFile.getClass(className);
|
|
2264
|
+
if (!classDeclaration) return regionDefinitions;
|
|
2265
|
+
const classRegionDecorator = classDeclaration.getDecorator("RegionDefinition");
|
|
2266
|
+
if (classRegionDecorator) {
|
|
2267
|
+
const args = classRegionDecorator.getArguments();
|
|
2268
|
+
if (args.length > 0) {
|
|
2269
|
+
const firstArg = args[0];
|
|
2270
|
+
if (Node.isArrayLiteralExpression(firstArg)) {
|
|
2271
|
+
const elements = firstArg.getElements();
|
|
2272
|
+
for (const element of elements) if (Node.isObjectLiteralExpression(element)) {
|
|
2273
|
+
const regionConfig = parseDecoratorArgs({ getArguments: () => [element] });
|
|
2274
|
+
const regionDefinition = {
|
|
2275
|
+
id: regionConfig.id || "region",
|
|
2276
|
+
name: regionConfig.name || "Region"
|
|
2277
|
+
};
|
|
2278
|
+
if (regionConfig.componentTypes) regionDefinition.component_types = regionConfig.componentTypes;
|
|
2279
|
+
if (Array.isArray(regionConfig.componentTypeInclusions)) regionDefinition.component_type_inclusions = regionConfig.componentTypeInclusions.map((incl) => ({ type_id: incl }));
|
|
2280
|
+
if (Array.isArray(regionConfig.componentTypeExclusions)) regionDefinition.component_type_exclusions = regionConfig.componentTypeExclusions.map((excl) => ({ type_id: excl }));
|
|
2281
|
+
if (regionConfig.maxComponents !== void 0) regionDefinition.max_components = regionConfig.maxComponents;
|
|
2282
|
+
if (regionConfig.minComponents !== void 0) regionDefinition.min_components = regionConfig.minComponents;
|
|
2283
|
+
if (regionConfig.allowMultiple !== void 0) regionDefinition.allow_multiple = regionConfig.allowMultiple;
|
|
2284
|
+
if (regionConfig.defaultComponentConstructors) regionDefinition.default_component_constructors = regionConfig.defaultComponentConstructors;
|
|
2285
|
+
regionDefinitions.push(regionDefinition);
|
|
2286
|
+
}
|
|
2287
|
+
}
|
|
2288
|
+
}
|
|
2289
|
+
}
|
|
2290
|
+
} catch (error$1) {
|
|
2291
|
+
console.warn(`Warning: Could not extract region definitions from class ${className}: ${error$1.message}`);
|
|
2292
|
+
}
|
|
2293
|
+
return regionDefinitions;
|
|
2294
|
+
}
|
|
2295
|
+
async function processComponentFile(filePath, _projectRoot) {
|
|
2296
|
+
try {
|
|
2297
|
+
const content = await readFile(filePath, "utf-8");
|
|
2298
|
+
const components = [];
|
|
2299
|
+
if (!content.includes("@Component")) return components;
|
|
2300
|
+
try {
|
|
2301
|
+
const sourceFile = new Project({
|
|
2302
|
+
useInMemoryFileSystem: true,
|
|
2303
|
+
skipAddingFilesFromTsConfig: true
|
|
2304
|
+
}).createSourceFile(filePath, content);
|
|
2305
|
+
const classes = sourceFile.getClasses();
|
|
2306
|
+
for (const classDeclaration of classes) {
|
|
2307
|
+
const componentDecorator = classDeclaration.getDecorator("Component");
|
|
2308
|
+
if (!componentDecorator) continue;
|
|
2309
|
+
const className = classDeclaration.getName();
|
|
2310
|
+
if (!className) continue;
|
|
2311
|
+
const componentConfig = parseDecoratorArgs(componentDecorator);
|
|
2312
|
+
const attributes = extractAttributesFromSource(sourceFile, className);
|
|
2313
|
+
const regionDefinitions = extractRegionDefinitionsFromSource(sourceFile, className);
|
|
2314
|
+
const componentMetadata = {
|
|
2315
|
+
typeId: componentConfig.id || className.toLowerCase(),
|
|
2316
|
+
name: componentConfig.name || toHumanReadableName(className),
|
|
2317
|
+
group: componentConfig.group || DEFAULT_COMPONENT_GROUP,
|
|
2318
|
+
description: componentConfig.description || `Custom component: ${className}`,
|
|
2319
|
+
regionDefinitions,
|
|
2320
|
+
attributes
|
|
2321
|
+
};
|
|
2322
|
+
components.push(componentMetadata);
|
|
2323
|
+
}
|
|
2324
|
+
} catch (error$1) {
|
|
2325
|
+
console.warn(`Warning: Could not process file ${filePath}:`, error$1.message);
|
|
2326
|
+
}
|
|
2327
|
+
return components;
|
|
2328
|
+
} catch (error$1) {
|
|
2329
|
+
console.warn(`Warning: Could not read file ${filePath}:`, error$1.message);
|
|
2330
|
+
return [];
|
|
2331
|
+
}
|
|
2332
|
+
}
|
|
2333
|
+
async function processPageTypeFile(filePath, projectRoot) {
|
|
2334
|
+
try {
|
|
2335
|
+
const content = await readFile(filePath, "utf-8");
|
|
2336
|
+
const pageTypes = [];
|
|
2337
|
+
if (!content.includes("@PageType")) return pageTypes;
|
|
2338
|
+
try {
|
|
2339
|
+
const sourceFile = new Project({
|
|
2340
|
+
useInMemoryFileSystem: true,
|
|
2341
|
+
skipAddingFilesFromTsConfig: true
|
|
2342
|
+
}).createSourceFile(filePath, content);
|
|
2343
|
+
const classes = sourceFile.getClasses();
|
|
2344
|
+
for (const classDeclaration of classes) {
|
|
2345
|
+
const pageTypeDecorator = classDeclaration.getDecorator("PageType");
|
|
2346
|
+
if (!pageTypeDecorator) continue;
|
|
2347
|
+
const className = classDeclaration.getName();
|
|
2348
|
+
if (!className) continue;
|
|
2349
|
+
const pageTypeConfig = parseDecoratorArgs(pageTypeDecorator);
|
|
2350
|
+
const attributes = extractAttributesFromSource(sourceFile, className);
|
|
2351
|
+
const regionDefinitions = extractRegionDefinitionsFromSource(sourceFile, className);
|
|
2352
|
+
const route = filePathToRoute(filePath, projectRoot);
|
|
2353
|
+
const pageTypeMetadata = {
|
|
2354
|
+
typeId: pageTypeConfig.id || className.toLowerCase(),
|
|
2355
|
+
name: pageTypeConfig.name || toHumanReadableName(className),
|
|
2356
|
+
description: pageTypeConfig.description || `Custom page type: ${className}`,
|
|
2357
|
+
regionDefinitions,
|
|
2358
|
+
supportedAspectTypes: pageTypeConfig.supportedAspectTypes || [],
|
|
2359
|
+
attributes,
|
|
2360
|
+
route
|
|
2361
|
+
};
|
|
2362
|
+
pageTypes.push(pageTypeMetadata);
|
|
2363
|
+
}
|
|
2364
|
+
} catch (error$1) {
|
|
2365
|
+
console.warn(`Warning: Could not process file ${filePath}:`, error$1.message);
|
|
2366
|
+
}
|
|
2367
|
+
return pageTypes;
|
|
2368
|
+
} catch (error$1) {
|
|
2369
|
+
console.warn(`Warning: Could not read file ${filePath}:`, error$1.message);
|
|
2370
|
+
return [];
|
|
2371
|
+
}
|
|
2372
|
+
}
|
|
2373
|
+
async function processAspectFile(filePath, _projectRoot) {
|
|
2374
|
+
try {
|
|
2375
|
+
const content = await readFile(filePath, "utf-8");
|
|
2376
|
+
const aspects = [];
|
|
2377
|
+
if (!filePath.endsWith(".json") || !content.trim().startsWith("{")) return aspects;
|
|
2378
|
+
if (!filePath.includes("/aspects/") && !filePath.includes("\\aspects\\")) return aspects;
|
|
2379
|
+
try {
|
|
2380
|
+
const aspectData = JSON.parse(content);
|
|
2381
|
+
const fileName = basename(filePath, ".json");
|
|
2382
|
+
if (!aspectData.name || !aspectData.attribute_definitions) return aspects;
|
|
2383
|
+
const aspectMetadata = {
|
|
2384
|
+
id: fileName,
|
|
2385
|
+
name: aspectData.name,
|
|
2386
|
+
description: aspectData.description || `Aspect type: ${aspectData.name}`,
|
|
2387
|
+
attributeDefinitions: aspectData.attribute_definitions || [],
|
|
2388
|
+
supportedObjectTypes: aspectData.supported_object_types || []
|
|
2389
|
+
};
|
|
2390
|
+
aspects.push(aspectMetadata);
|
|
2391
|
+
} catch (parseError) {
|
|
2392
|
+
console.warn(`Warning: Could not parse JSON in file ${filePath}:`, parseError.message);
|
|
2393
|
+
}
|
|
2394
|
+
return aspects;
|
|
2395
|
+
} catch (error$1) {
|
|
2396
|
+
console.warn(`Warning: Could not read file ${filePath}:`, error$1.message);
|
|
2397
|
+
return [];
|
|
2398
|
+
}
|
|
2399
|
+
}
|
|
2400
|
+
async function generateComponentCartridge(component, outputDir, dryRun = false) {
|
|
2401
|
+
const fileName = toCamelCaseFileName(component.typeId);
|
|
2402
|
+
const groupDir = join(outputDir, component.group);
|
|
2403
|
+
const outputPath = join(groupDir, `${fileName}.json`);
|
|
2404
|
+
if (!dryRun) {
|
|
2405
|
+
try {
|
|
2406
|
+
await mkdir(groupDir, { recursive: true });
|
|
2407
|
+
} catch {}
|
|
2408
|
+
const attributeDefinitionGroups = [{
|
|
2409
|
+
id: component.typeId,
|
|
2410
|
+
name: component.name,
|
|
2411
|
+
description: component.description,
|
|
2412
|
+
attribute_definitions: component.attributes
|
|
2413
|
+
}];
|
|
2414
|
+
const cartridgeData = {
|
|
2415
|
+
name: component.name,
|
|
2416
|
+
description: component.description,
|
|
2417
|
+
group: component.group,
|
|
2418
|
+
arch_type: ARCH_TYPE_HEADLESS,
|
|
2419
|
+
region_definitions: component.regionDefinitions || [],
|
|
2420
|
+
attribute_definition_groups: attributeDefinitionGroups
|
|
2421
|
+
};
|
|
2422
|
+
await writeFile(outputPath, JSON.stringify(cartridgeData, null, 2));
|
|
2423
|
+
}
|
|
2424
|
+
const prefix = dryRun ? " - [DRY RUN]" : " -";
|
|
2425
|
+
console.log(`${prefix} ${String(component.typeId)}: ${String(component.name)} (${String(component.attributes.length)} attributes) → ${fileName}.json`);
|
|
2426
|
+
}
|
|
2427
|
+
async function generatePageTypeCartridge(pageType, outputDir, dryRun = false) {
|
|
2428
|
+
const fileName = toCamelCaseFileName(pageType.name);
|
|
2429
|
+
const outputPath = join(outputDir, `${fileName}.json`);
|
|
2430
|
+
if (!dryRun) {
|
|
2431
|
+
const cartridgeData = {
|
|
2432
|
+
name: pageType.name,
|
|
2433
|
+
description: pageType.description,
|
|
2434
|
+
arch_type: ARCH_TYPE_HEADLESS,
|
|
2435
|
+
region_definitions: pageType.regionDefinitions || []
|
|
2436
|
+
};
|
|
2437
|
+
if (pageType.attributes && pageType.attributes.length > 0) cartridgeData.attribute_definition_groups = [{
|
|
2438
|
+
id: pageType.typeId || fileName,
|
|
2439
|
+
name: pageType.name,
|
|
2440
|
+
description: pageType.description,
|
|
2441
|
+
attribute_definitions: pageType.attributes
|
|
2442
|
+
}];
|
|
2443
|
+
if (pageType.supportedAspectTypes) cartridgeData.supported_aspect_types = pageType.supportedAspectTypes;
|
|
2444
|
+
if (pageType.route) cartridgeData.route = pageType.route;
|
|
2445
|
+
await writeFile(outputPath, JSON.stringify(cartridgeData, null, 2));
|
|
2446
|
+
}
|
|
2447
|
+
const prefix = dryRun ? " - [DRY RUN]" : " -";
|
|
2448
|
+
console.log(`${prefix} ${String(pageType.name)}: ${String(pageType.description)} (${String(pageType.attributes.length)} attributes) → ${fileName}.json`);
|
|
2449
|
+
}
|
|
2450
|
+
async function generateAspectCartridge(aspect, outputDir, dryRun = false) {
|
|
2451
|
+
const fileName = toCamelCaseFileName(aspect.id);
|
|
2452
|
+
const outputPath = join(outputDir, `${fileName}.json`);
|
|
2453
|
+
if (!dryRun) {
|
|
2454
|
+
const cartridgeData = {
|
|
2455
|
+
name: aspect.name,
|
|
2456
|
+
description: aspect.description,
|
|
2457
|
+
arch_type: ARCH_TYPE_HEADLESS,
|
|
2458
|
+
attribute_definitions: aspect.attributeDefinitions || []
|
|
2459
|
+
};
|
|
2460
|
+
if (aspect.supportedObjectTypes) cartridgeData.supported_object_types = aspect.supportedObjectTypes;
|
|
2461
|
+
await writeFile(outputPath, JSON.stringify(cartridgeData, null, 2));
|
|
2462
|
+
}
|
|
2463
|
+
const prefix = dryRun ? " - [DRY RUN]" : " -";
|
|
2464
|
+
console.log(`${prefix} ${String(aspect.name)}: ${String(aspect.description)} (${String(aspect.attributeDefinitions.length)} attributes) → ${fileName}.json`);
|
|
2465
|
+
}
|
|
2466
|
+
/**
|
|
2467
|
+
* Runs ESLint with --fix on the specified directory to format JSON files.
|
|
2468
|
+
* This ensures generated JSON files match the project's Prettier/ESLint configuration.
|
|
2469
|
+
*/
|
|
2470
|
+
function lintGeneratedFiles(metadataDir, projectRoot) {
|
|
2471
|
+
try {
|
|
2472
|
+
console.log("🔧 Running ESLint --fix on generated JSON files...");
|
|
2473
|
+
execSync$1(`npx eslint "${metadataDir}/**/*.json" --fix --no-error-on-unmatched-pattern`, {
|
|
2474
|
+
cwd: projectRoot,
|
|
2475
|
+
stdio: "pipe",
|
|
2476
|
+
encoding: "utf-8"
|
|
2477
|
+
});
|
|
2478
|
+
console.log("✅ JSON files formatted successfully");
|
|
2479
|
+
} catch (error$1) {
|
|
2480
|
+
const execError = error$1;
|
|
2481
|
+
if (execError.status === 2) {
|
|
2482
|
+
const errMsg = execError.stderr || execError.stdout || "Unknown error";
|
|
2483
|
+
console.warn(`⚠️ Warning: Could not run ESLint --fix: ${errMsg}`);
|
|
2484
|
+
} else if (execError.stderr && execError.stderr.includes("error")) console.warn(`⚠️ Warning: Some linting issues could not be auto-fixed. Run ESLint manually to review.`);
|
|
2485
|
+
else console.log("✅ JSON files formatted successfully");
|
|
2486
|
+
}
|
|
2487
|
+
}
|
|
2488
|
+
async function generateMetadata(projectDirectory, metadataDirectory, options) {
|
|
2489
|
+
try {
|
|
2490
|
+
const filePaths = options?.filePaths;
|
|
2491
|
+
const isIncrementalMode = filePaths && filePaths.length > 0;
|
|
2492
|
+
const dryRun = options?.dryRun || false;
|
|
2493
|
+
if (dryRun) console.log("🔍 [DRY RUN] Scanning for decorated components and page types...");
|
|
2494
|
+
else if (isIncrementalMode) console.log(`🔍 Generating metadata for ${filePaths.length} specified file(s)...`);
|
|
2495
|
+
else console.log("🔍 Generating metadata for decorated components and page types...");
|
|
2496
|
+
const projectRoot = resolve(projectDirectory);
|
|
2497
|
+
const srcDir = join(projectRoot, "src");
|
|
2498
|
+
const metadataDir = resolve(metadataDirectory);
|
|
2499
|
+
const componentsOutputDir = join(metadataDir, "components");
|
|
2500
|
+
const pagesOutputDir = join(metadataDir, "pages");
|
|
2501
|
+
const aspectsOutputDir = join(metadataDir, "aspects");
|
|
2502
|
+
if (!dryRun) {
|
|
2503
|
+
if (!isIncrementalMode) {
|
|
2504
|
+
console.log("🗑️ Cleaning existing output directories...");
|
|
2505
|
+
for (const outputDir of [
|
|
2506
|
+
componentsOutputDir,
|
|
2507
|
+
pagesOutputDir,
|
|
2508
|
+
aspectsOutputDir
|
|
2509
|
+
]) try {
|
|
2510
|
+
await rm(outputDir, {
|
|
2511
|
+
recursive: true,
|
|
2512
|
+
force: true
|
|
2513
|
+
});
|
|
2514
|
+
console.log(` - Deleted: ${outputDir}`);
|
|
2515
|
+
} catch {
|
|
2516
|
+
console.log(` - Directory not found (skipping): ${outputDir}`);
|
|
2517
|
+
}
|
|
2518
|
+
} else console.log("📝 Incremental mode: existing cartridge files will be preserved/overwritten");
|
|
2519
|
+
console.log("📁 Creating output directories...");
|
|
2520
|
+
for (const outputDir of [
|
|
2521
|
+
componentsOutputDir,
|
|
2522
|
+
pagesOutputDir,
|
|
2523
|
+
aspectsOutputDir
|
|
2524
|
+
]) try {
|
|
2525
|
+
await mkdir(outputDir, { recursive: true });
|
|
2526
|
+
} catch (error$1) {
|
|
2527
|
+
try {
|
|
2528
|
+
await access(outputDir);
|
|
2529
|
+
} catch {
|
|
2530
|
+
console.error(`❌ Error: Failed to create output directory ${outputDir}: ${error$1.message}`);
|
|
2531
|
+
process.exit(1);
|
|
2532
|
+
}
|
|
2533
|
+
}
|
|
2534
|
+
} else if (isIncrementalMode) console.log(`📝 [DRY RUN] Would process ${filePaths.length} specific file(s)`);
|
|
2535
|
+
else console.log("📝 [DRY RUN] Would clean and regenerate all metadata files");
|
|
2536
|
+
let files = [];
|
|
2537
|
+
if (isIncrementalMode && filePaths) {
|
|
2538
|
+
files = filePaths.map((fp) => resolve(projectRoot, fp));
|
|
2539
|
+
console.log(`📂 Processing ${files.length} specified file(s)...`);
|
|
2540
|
+
} else {
|
|
2541
|
+
const scanDirectory = async (dir) => {
|
|
2542
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
2543
|
+
for (const entry of entries) {
|
|
2544
|
+
const fullPath = join(dir, entry.name);
|
|
2545
|
+
if (entry.isDirectory()) {
|
|
2546
|
+
if (!SKIP_DIRECTORIES.includes(entry.name)) await scanDirectory(fullPath);
|
|
2547
|
+
} else if (entry.isFile() && (extname(entry.name) === ".ts" || extname(entry.name) === ".tsx" || extname(entry.name) === ".json")) files.push(fullPath);
|
|
2548
|
+
}
|
|
2549
|
+
};
|
|
2550
|
+
await scanDirectory(srcDir);
|
|
2551
|
+
}
|
|
2552
|
+
const allComponents = [];
|
|
2553
|
+
const allPageTypes = [];
|
|
2554
|
+
const allAspects = [];
|
|
2555
|
+
for (const file of files) {
|
|
2556
|
+
const components = await processComponentFile(file, projectRoot);
|
|
2557
|
+
allComponents.push(...components);
|
|
2558
|
+
const pageTypes = await processPageTypeFile(file, projectRoot);
|
|
2559
|
+
allPageTypes.push(...pageTypes);
|
|
2560
|
+
const aspects = await processAspectFile(file, projectRoot);
|
|
2561
|
+
allAspects.push(...aspects);
|
|
2562
|
+
}
|
|
2563
|
+
if (allComponents.length === 0 && allPageTypes.length === 0 && allAspects.length === 0) {
|
|
2564
|
+
console.log("⚠️ No decorated components, page types, or aspect files found.");
|
|
2565
|
+
return {
|
|
2566
|
+
componentsGenerated: 0,
|
|
2567
|
+
pageTypesGenerated: 0,
|
|
2568
|
+
aspectsGenerated: 0,
|
|
2569
|
+
totalFiles: 0
|
|
2570
|
+
};
|
|
2571
|
+
}
|
|
2572
|
+
if (allComponents.length > 0) {
|
|
2573
|
+
console.log(`✅ Found ${allComponents.length} decorated component(s):`);
|
|
2574
|
+
for (const component of allComponents) await generateComponentCartridge(component, componentsOutputDir, dryRun);
|
|
2575
|
+
if (dryRun) console.log(`📄 [DRY RUN] Would generate ${allComponents.length} component metadata file(s) in: ${componentsOutputDir}`);
|
|
2576
|
+
else console.log(`📄 Generated ${allComponents.length} component metadata file(s) in: ${componentsOutputDir}`);
|
|
2577
|
+
}
|
|
2578
|
+
if (allPageTypes.length > 0) {
|
|
2579
|
+
console.log(`✅ Found ${allPageTypes.length} decorated page type(s):`);
|
|
2580
|
+
for (const pageType of allPageTypes) await generatePageTypeCartridge(pageType, pagesOutputDir, dryRun);
|
|
2581
|
+
if (dryRun) console.log(`📄 [DRY RUN] Would generate ${allPageTypes.length} page type metadata file(s) in: ${pagesOutputDir}`);
|
|
2582
|
+
else console.log(`📄 Generated ${allPageTypes.length} page type metadata file(s) in: ${pagesOutputDir}`);
|
|
2583
|
+
}
|
|
2584
|
+
if (allAspects.length > 0) {
|
|
2585
|
+
console.log(`✅ Found ${allAspects.length} decorated aspect(s):`);
|
|
2586
|
+
for (const aspect of allAspects) await generateAspectCartridge(aspect, aspectsOutputDir, dryRun);
|
|
2587
|
+
if (dryRun) console.log(`📄 [DRY RUN] Would generate ${allAspects.length} aspect metadata file(s) in: ${aspectsOutputDir}`);
|
|
2588
|
+
else console.log(`📄 Generated ${allAspects.length} aspect metadata file(s) in: ${aspectsOutputDir}`);
|
|
2589
|
+
}
|
|
2590
|
+
const shouldLintFix = options?.lintFix !== false;
|
|
2591
|
+
if (!dryRun && shouldLintFix && (allComponents.length > 0 || allPageTypes.length > 0 || allAspects.length > 0)) lintGeneratedFiles(metadataDir, projectRoot);
|
|
2592
|
+
return {
|
|
2593
|
+
componentsGenerated: allComponents.length,
|
|
2594
|
+
pageTypesGenerated: allPageTypes.length,
|
|
2595
|
+
aspectsGenerated: allAspects.length,
|
|
2596
|
+
totalFiles: allComponents.length + allPageTypes.length + allAspects.length
|
|
2597
|
+
};
|
|
2598
|
+
} catch (error$1) {
|
|
2599
|
+
console.error("❌ Error:", error$1.message);
|
|
2600
|
+
process.exit(1);
|
|
2601
|
+
}
|
|
2602
|
+
}
|
|
2603
|
+
|
|
2604
|
+
//#endregion
|
|
2605
|
+
export { createServer, storefrontNextPlugins as default, generateMetadata, loadConfigFromEnv, loadProjectConfig, push, transformPluginPlaceholderPlugin, trimExtensions };
|
|
2606
|
+
//# sourceMappingURL=index.js.map
|