@salesforce/storefront-next-dev 1.0.0 → 1.1.0-alpha.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/dist/index.js CHANGED
@@ -1,8 +1,9 @@
1
- import path from "node:path";
1
+ import path, { resolve } from "node:path";
2
2
  import fs from "fs-extra";
3
3
  import chalk from "chalk";
4
- import path$1, { dirname, join, relative, resolve } from "path";
5
- import { fileURLToPath, pathToFileURL } from "url";
4
+ import { createRequire } from "module";
5
+ import path$1, { dirname, join, relative, resolve as resolve$1 } from "path";
6
+ import { fileURLToPath } from "url";
6
7
  import { parse } from "@babel/parser";
7
8
  import { booleanLiteral, identifier, importDeclaration, importSpecifier, isArrayPattern, isClassDeclaration, isExportSpecifier, isFunctionDeclaration, isIdentifier, isJSXAttribute, isJSXElement, isJSXFragment, isJSXIdentifier, isMemberExpression, isObjectPattern, isObjectProperty, isRestElement, isStringLiteral, isVariableDeclaration, jsxAttribute, jsxClosingElement, jsxClosingFragment, jsxElement, jsxExpressionContainer, jsxFragment, jsxIdentifier, jsxOpeningElement, jsxOpeningFragment, jsxText, stringLiteral } from "@babel/types";
8
9
  import { generate } from "@babel/generator";
@@ -10,7 +11,7 @@ import traverseModule from "@babel/traverse";
10
11
  import { existsSync, readFileSync, writeFileSync } from "fs";
11
12
  import { glob } from "glob";
12
13
  import { Node, Project, ts } from "ts-morph";
13
- import fs$1 from "node:fs";
14
+ import fs$1, { existsSync as existsSync$1, readFileSync as readFileSync$1 } from "node:fs";
14
15
  import { deadCodeElimination, findReferencedIdentifiers } from "babel-dead-code-elimination";
15
16
  import httpProxy from "http-proxy";
16
17
  import { brotliDecompressSync, gunzipSync, inflateSync } from "zlib";
@@ -605,6 +606,45 @@ function buildTargetRegistry(rootDir, options = {}) {
605
606
  actionHookRegistry
606
607
  };
607
608
  }
609
+ const TARGET_ID_PATTERN = /<UITarget[\s][^>]*targetId=["']([^"']+)["']/g;
610
+ const EXCLUDED_DIRS = new Set(["ui-target-dev-mode", "ui-target-smoke-test"]);
611
+ /**
612
+ * Recursively collect all UITarget IDs declared in template source files.
613
+ * Excludes extension directories so only "real" UITarget placements are counted.
614
+ */
615
+ function collectUITargetIds(sourceDir) {
616
+ const result = /* @__PURE__ */ new Set();
617
+ function walk(dir) {
618
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
619
+ for (const entry of entries) {
620
+ const fullPath = path$1.join(dir, entry.name);
621
+ if (entry.isDirectory()) {
622
+ if (entry.name === "extensions" || EXCLUDED_DIRS.has(entry.name)) continue;
623
+ walk(fullPath);
624
+ } else if (entry.isFile() && /\.(tsx?|jsx?)$/.test(entry.name) && !/\.test\.(tsx?|jsx?)$/.test(entry.name)) {
625
+ const content = fs.readFileSync(fullPath, "utf-8");
626
+ TARGET_ID_PATTERN.lastIndex = 0;
627
+ let match;
628
+ while ((match = TARGET_ID_PATTERN.exec(content)) !== null) result.add(match[1]);
629
+ }
630
+ }
631
+ }
632
+ walk(sourceDir);
633
+ return result;
634
+ }
635
+ /**
636
+ * Validate that all targetIds in the component registry correspond to
637
+ * UITarget declarations in the template source. Returns orphaned entries.
638
+ */
639
+ function validateTargetRegistry(componentRegistry, declaredTargetIds) {
640
+ const orphaned = [];
641
+ for (const targetId in componentRegistry) if (!declaredTargetIds.has(targetId)) for (const entry of componentRegistry[targetId]) orphaned.push({
642
+ targetId,
643
+ extension: entry.namespace,
644
+ componentPath: entry.path
645
+ });
646
+ return orphaned;
647
+ }
608
648
 
609
649
  //#endregion
610
650
  //#region src/plugins/transformTargets.ts
@@ -622,6 +662,12 @@ function transformTargetPlaceholderPlugin() {
622
662
  },
623
663
  buildStart() {
624
664
  ({componentRegistry, contextProviders} = buildTargetRegistry(sourceDir, { isProduction }));
665
+ const declaredTargetIds = collectUITargetIds(sourceDir);
666
+ const orphaned = validateTargetRegistry(componentRegistry, declaredTargetIds);
667
+ if (orphaned.length > 0) {
668
+ const lines = orphaned.map((o) => ` • "${o.targetId}" (extension: ${o.extension}, component: ${o.componentPath})`);
669
+ throw new Error(`[storefront-next] ${orphaned.length} extension component(s) target UITarget IDs that do not exist in the template:\n${lines.join("\n")}\n\nEither add a <UITarget targetId="..."> to the template or remove/disable the component in target-config.json.`);
670
+ }
625
671
  },
626
672
  transform(code, id) {
627
673
  if (process.env.VITE_UI_TARGET_DEV_MODE === "true") return null;
@@ -833,7 +879,7 @@ async function scanComponents(project, projectRoot, componentPath, registryPath)
833
879
  });
834
880
  logger.debug(`🔍 Scanning ${componentFiles.length} files in ${componentPath}...`);
835
881
  const components = [];
836
- const registryDir = dirname(resolve(projectRoot, registryPath));
882
+ const registryDir = dirname(resolve$1(projectRoot, registryPath));
837
883
  for (const filePath of componentFiles) try {
838
884
  const content = readFileSync(filePath, "utf-8");
839
885
  const sourceFile = project.createSourceFile(filePath, content, { overwrite: true });
@@ -876,11 +922,10 @@ async function scanComponents(project, projectRoot, componentPath, registryPath)
876
922
  function generateRegistryCode(components, registryIdentifier = "registry") {
877
923
  const sorted = [...components].sort((a, b) => a.id.localeCompare(b.id) || a.relativePath.localeCompare(b.relativePath));
878
924
  if (sorted.length === 0) return `
879
- /* eslint-disable */
880
925
  /**
881
926
  * Initialize the static component registry.
882
927
  * This function is auto-generated by the staticRegistry Vite plugin.
883
- *
928
+ *
884
929
  * DO NOT EDIT THIS FUNCTION MANUALLY - it will be overwritten on next build.
885
930
  */
886
931
  export function initializeRegistry(targetRegistry = ${registryIdentifier}): void {
@@ -897,13 +942,12 @@ export function initializeRegistry(targetRegistry = ${registryIdentifier}): void
897
942
  } else return ` targetRegistry.registerImporter('${id}', () => import('${relativePath}'));`;
898
943
  }).join("\n");
899
944
  return `
900
- /* eslint-disable */
901
945
  /**
902
946
  * Initialize the static component registry.
903
947
  * This function is auto-generated by the staticRegistry Vite plugin.
904
- *
948
+ *
905
949
  * DO NOT EDIT THIS FUNCTION MANUALLY - it will be overwritten on next build.
906
- *
950
+ *
907
951
  * Components registered: ${sorted.map((c) => c.id).join(", ")}
908
952
  */
909
953
  export function initializeRegistry(targetRegistry = ${registryIdentifier}): void {
@@ -912,9 +956,42 @@ ${registrations}
912
956
  `;
913
957
  }
914
958
  /**
959
+ * Formats registry file content with the project's own Prettier so the written file
960
+ * matches what the project's formatter would produce.
961
+ *
962
+ * Without this, a standalone `prettier --write` (pre-commit hook, format-on-save) rewrites
963
+ * the generated file on every commit: the generator emits one `registerImporter` call per
964
+ * line and cannot anticipate an arbitrary `printWidth`, so Prettier re-wraps long lines and
965
+ * the regenerate -> format -> regenerate loop never settles.
966
+ *
967
+ * Prettier is resolved from the registry file's own location — i.e. the consuming project's
968
+ * `node_modules`, not the SDK bundle (which keeps `node_modules` external). It is the project's
969
+ * Prettier version and config whose output must be matched. Returns the content unchanged when
970
+ * Prettier is absent or fails, so registry generation never breaks a build over formatting.
971
+ */
972
+ let warnedNoPrettier = false;
973
+ async function formatWithProjectPrettier(content, registryFilePath) {
974
+ try {
975
+ const prettier = createRequire(registryFilePath)("prettier");
976
+ const config = await prettier.resolveConfig(registryFilePath, { editorconfig: true });
977
+ return await prettier.format(content, {
978
+ ...config,
979
+ filepath: registryFilePath
980
+ });
981
+ } catch (error) {
982
+ if (error.code === "MODULE_NOT_FOUND") {
983
+ if (!warnedNoPrettier) {
984
+ logger.warn("⚠️ Prettier not found in the project; static registry will be written unformatted.");
985
+ warnedNoPrettier = true;
986
+ }
987
+ } else logger.warn(`⚠️ Skipping Prettier formatting for registry file: ${error.message}`);
988
+ return content;
989
+ }
990
+ }
991
+ /**
915
992
  * Updates the registry.ts file with the generated code
916
993
  */
917
- function updateRegistryFile(registryFilePath, generatedCode) {
994
+ async function updateRegistryFile(registryFilePath, generatedCode) {
918
995
  let existingContent;
919
996
  if (!existsSync(registryFilePath)) {
920
997
  logger.debug("📝 Creating new registry file...");
@@ -939,7 +1016,7 @@ export const registry = new ComponentRegistry();
939
1016
  const startIndex = existingContent.indexOf(startMarker);
940
1017
  const endIndex = existingContent.indexOf(endMarker);
941
1018
  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.`);
942
- const updatedContent = `${existingContent.slice(0, startIndex + 24)}\n${generatedCode}\n${existingContent.slice(endIndex)}`;
1019
+ const updatedContent = await formatWithProjectPrettier(`${existingContent.slice(0, startIndex + 24)}\n${generatedCode}\n${existingContent.slice(endIndex)}`, registryFilePath);
943
1020
  if (updatedContent === existingContent) {
944
1021
  logger.debug(`⏭️ Registry unchanged, skipping write: ${registryFilePath}`);
945
1022
  return false;
@@ -990,8 +1067,8 @@ const staticRegistryPlugin = (config = {}) => {
990
1067
  } }), projectRoot, componentPath, registryPath);
991
1068
  logger.debug(`📦 Found ${components.length} components with @Component decorators`);
992
1069
  const generatedCode = generateRegistryCode(components, registryIdentifier);
993
- const registryFilePath = resolve(projectRoot, registryPath);
994
- const changed = updateRegistryFile(registryFilePath, generatedCode);
1070
+ const registryFilePath = resolve$1(projectRoot, registryPath);
1071
+ const changed = await updateRegistryFile(registryFilePath, generatedCode);
995
1072
  logger.debug("✅ Static registry generation complete!");
996
1073
  return {
997
1074
  registryFilePath,
@@ -1033,15 +1110,68 @@ const staticRegistryPlugin = (config = {}) => {
1033
1110
  };
1034
1111
  };
1035
1112
 
1113
+ //#endregion
1114
+ //#region src/server/ts-import.ts
1115
+ /**
1116
+ * Parse TypeScript paths from tsconfig.json and convert to jiti alias format.
1117
+ *
1118
+ * @param tsconfigPath - Path to tsconfig.json
1119
+ * @param projectDirectory - Project root directory for resolving relative paths
1120
+ * @returns Record of alias mappings for jiti
1121
+ *
1122
+ * @example
1123
+ * // tsconfig.json: { "compilerOptions": { "paths": { "@/*": ["./src/*"] } } }
1124
+ * // Returns: { "@/": "/absolute/path/to/src/" }
1125
+ */
1126
+ function parseTsconfigPaths(tsconfigPath, projectDirectory) {
1127
+ const alias = {};
1128
+ if (!existsSync$1(tsconfigPath)) return alias;
1129
+ try {
1130
+ const tsconfigContent = readFileSync$1(tsconfigPath, "utf-8");
1131
+ const tsconfig = JSON.parse(tsconfigContent);
1132
+ const paths = tsconfig.compilerOptions?.paths;
1133
+ const baseUrl = tsconfig.compilerOptions?.baseUrl || ".";
1134
+ if (paths) {
1135
+ for (const [key, values] of Object.entries(paths)) if (values && values.length > 0) {
1136
+ const aliasKey = key.replace(/\/\*$/, "/");
1137
+ alias[aliasKey] = resolve(projectDirectory, baseUrl, values[0].replace(/\/\*$/, "/").replace(/^\.\//, ""));
1138
+ }
1139
+ }
1140
+ } catch {}
1141
+ const sortedAlias = {};
1142
+ Object.keys(alias).sort((a, b) => b.length - a.length).forEach((key) => {
1143
+ sortedAlias[key] = alias[key];
1144
+ });
1145
+ return sortedAlias;
1146
+ }
1147
+ /**
1148
+ * Import a TypeScript file using jiti with proper path alias resolution.
1149
+ * This is a cross-platform alternative to tsx that works on Windows.
1150
+ *
1151
+ * @param filePath - Absolute path to the TypeScript file to import
1152
+ * @param options - Import options including project directory
1153
+ * @returns The imported module
1154
+ */
1155
+ async function importTypescript(filePath, options) {
1156
+ const { projectDirectory, tsconfigPath = resolve(projectDirectory, "tsconfig.json") } = options;
1157
+ const { createJiti } = await import("jiti");
1158
+ const alias = parseTsconfigPaths(tsconfigPath, projectDirectory);
1159
+ return createJiti(import.meta.url, {
1160
+ fsCache: false,
1161
+ interopDefault: true,
1162
+ alias
1163
+ }).import(filePath);
1164
+ }
1165
+
1036
1166
  //#endregion
1037
1167
  //#region src/plugins/configLoader.ts
1038
1168
  /**
1039
1169
  * Load the engagement config from config.server.ts
1040
1170
  */
1041
1171
  async function loadEngagementConfig(projectRoot, configPath) {
1042
- const absoluteConfigPath = resolve(projectRoot, configPath);
1172
+ const absoluteConfigPath = resolve$1(projectRoot, configPath);
1043
1173
  try {
1044
- const config = (await import(pathToFileURL(absoluteConfigPath).href)).default;
1174
+ const config = (await importTypescript(absoluteConfigPath, { projectDirectory: projectRoot })).default;
1045
1175
  logger.debug(`📄 Loaded config from ${configPath}`);
1046
1176
  const engagement = config?.app?.engagement;
1047
1177
  if (!engagement) {
@@ -1066,7 +1196,7 @@ async function scanForInstrumentedEvents(projectRoot, scanPaths) {
1066
1196
  const sendViewPagePattern = /sendViewPageEvent\s*\(/g;
1067
1197
  const createEventPattern = /createEvent\s*\(\s*['"]([^'"]+)['"]/g;
1068
1198
  for (const scanPath of scanPaths) {
1069
- const files = await glob(join(resolve(projectRoot, scanPath), "**/*.{ts,tsx}"), { ignore: [
1199
+ const files = await glob(join(resolve$1(projectRoot, scanPath), "**/*.{ts,tsx}"), { ignore: [
1070
1200
  "**/*.test.ts",
1071
1201
  "**/*.test.tsx",
1072
1202
  "**/*.spec.ts",
@@ -1218,17 +1348,17 @@ const buildMiddlewareRegistryPlugin = () => {
1218
1348
  configResolved(config) {
1219
1349
  resolvedConfig = config;
1220
1350
  const rr = config.__reactRouterPluginContext?.reactRouterConfig ?? {};
1221
- buildDirectory = rr.buildDirectory ?? resolve(config.root, "build");
1351
+ buildDirectory = rr.buildDirectory ?? resolve$1(config.root, "build");
1222
1352
  appDirectory = rr.appDirectory ?? "src";
1223
1353
  },
1224
1354
  buildApp: {
1225
1355
  order: "post",
1226
1356
  handler: async () => {
1227
1357
  const projectRoot = resolvedConfig.root;
1228
- const middlewareRegistryPath = resolve(projectRoot, appDirectory, SERVER_OUT_SUBDIR, MIDDLEWARE_REGISTRY_SOURCE_FILE);
1358
+ const middlewareRegistryPath = resolve$1(projectRoot, appDirectory, SERVER_OUT_SUBDIR, MIDDLEWARE_REGISTRY_SOURCE_FILE);
1229
1359
  if (!existsSync(middlewareRegistryPath)) return;
1230
1360
  const { build } = await import("tsdown");
1231
- const serverOutDir = resolve(projectRoot, buildDirectory, SERVER_OUT_SUBDIR);
1361
+ const serverOutDir = resolve$1(projectRoot, buildDirectory, SERVER_OUT_SUBDIR);
1232
1362
  await build({
1233
1363
  cwd: projectRoot,
1234
1364
  entry: { [MIDDLEWARE_REGISTRY_SOURCE_FILE.replace(/\.ts$/, "")]: middlewareRegistryPath },