@salesforce/storefront-next-dev 1.1.0-alpha.3 → 1.2.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.
@@ -1,9 +1,10 @@
1
1
  import { t as logger } from "./logger.js";
2
+ import { t as formatDirectoryWithProjectBiome } from "./format-with-project-biome.js";
2
3
  import { existsSync, readFileSync, unlinkSync } from "node:fs";
3
4
  import { basename, extname, join, resolve } from "node:path";
4
5
  import { access, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
5
- import { execSync } from "node:child_process";
6
6
  import { Node, Project } from "ts-morph";
7
+ import { execSync } from "node:child_process";
7
8
  import { tmpdir } from "node:os";
8
9
  import { randomUUID } from "node:crypto";
9
10
  import { npmRunPathEnv } from "npm-run-path";
@@ -159,6 +160,62 @@ function resolveAttributeType(decoratorType, tsMorphType, fieldName) {
159
160
  if (tsMorphType && TYPE_MAPPING[tsMorphType]) return TYPE_MAPPING[tsMorphType];
160
161
  return "string";
161
162
  }
163
+ const SEARCHING_FORBIDDEN_TYPES = new Set([
164
+ "integer",
165
+ "boolean",
166
+ "file",
167
+ "page",
168
+ "image",
169
+ "url",
170
+ "enum"
171
+ ]);
172
+ /**
173
+ * Validate an authored `searching` config against the resolved attribute type and map it to the
174
+ * snake_case schema shape. The metadefinition schema gates `searching` by type; an invalid
175
+ * combination fails cartridge generation (matching the strict behavior of `resolveAttributeType`).
176
+ *
177
+ * Rules (from attributedefinition.json):
178
+ * - Forbidden entirely on integer/boolean/file/page/image/url/enum.
179
+ * - `markup`: `sortable` must be false.
180
+ * - `custom`/`cms_record`: `refinable` must be false; `boost_factor`/`sortable` not allowed.
181
+ * - `string`/`text`/`product`/`category`: all fields allowed.
182
+ */
183
+ function mapAttributeSearching(searching, resolvedType, fieldName) {
184
+ const fail = (reason) => {
185
+ logger.error(`Invalid 'searching' config for attribute '${fieldName}' (type '${resolvedType}'): ${reason}`);
186
+ process.exit(1);
187
+ };
188
+ if (SEARCHING_FORBIDDEN_TYPES.has(resolvedType)) fail(`the '${resolvedType}' attribute type does not support searching.`);
189
+ const { searchable, refinable, boostFactor, sortable } = searching;
190
+ if (typeof searchable !== "boolean" || typeof refinable !== "boolean") fail(`'searchable' and 'refinable' are required booleans.`);
191
+ if (resolvedType === "markup" && sortable === true) fail(`'sortable' must be false for markup attributes.`);
192
+ if (resolvedType === "custom" || resolvedType === "cms_record") {
193
+ if (refinable === true) fail(`'refinable' must be false for ${resolvedType} attributes.`);
194
+ if (boostFactor !== void 0) fail(`'boostFactor' is not supported for ${resolvedType} attributes.`);
195
+ if (sortable === true) fail(`'sortable' must be false for ${resolvedType} attributes.`);
196
+ }
197
+ const mapped = {
198
+ searchable,
199
+ refinable
200
+ };
201
+ if (boostFactor !== void 0) mapped.boost_factor = boostFactor;
202
+ if (sortable !== void 0) mapped.sortable = sortable;
203
+ return mapped;
204
+ }
205
+ /**
206
+ * Validate an authored `dynamicLookup` config and map it to the snake_case schema shape.
207
+ * The metadefinition schema allows `dynamic_lookup` on all attribute types but requires a
208
+ * single `aspect_attribute_alias` field (`additionalProperties: false`); a missing alias fails
209
+ * cartridge generation (matching the strict behavior of `resolveAttributeType`).
210
+ */
211
+ function mapAttributeDynamicLookup(dynamicLookup, fieldName) {
212
+ const { aspectAttributeAlias } = dynamicLookup;
213
+ if (typeof aspectAttributeAlias !== "string" || aspectAttributeAlias.length === 0) {
214
+ logger.error(`Invalid 'dynamicLookup' config for attribute '${fieldName}': 'aspectAttributeAlias' is required and must be a non-empty string.`);
215
+ process.exit(1);
216
+ }
217
+ return { aspect_attribute_alias: aspectAttributeAlias };
218
+ }
162
219
  function toHumanReadableName(fieldName) {
163
220
  return fieldName.replace(/([A-Z])/g, " $1").replace(/^./, (str) => str.toUpperCase()).trim();
164
221
  }
@@ -310,6 +367,8 @@ function extractAttributesFromSource(sourceFile, className) {
310
367
  if (config.values) attribute.values = config.values;
311
368
  if (config.defaultValue !== void 0) attribute.default_value = config.defaultValue;
312
369
  if (config.editorDefinition !== void 0) attribute.editor_definition = config.editorDefinition;
370
+ if (config.searching !== void 0) attribute.searching = mapAttributeSearching(config.searching, attribute.type, String(attribute.id));
371
+ if (config.dynamicLookup !== void 0) attribute.dynamic_lookup = mapAttributeDynamicLookup(config.dynamicLookup, String(attribute.id));
313
372
  attributes.push(attribute);
314
373
  }
315
374
  } catch (error) {
@@ -339,13 +398,19 @@ function extractRegionDefinitionsFromSource(sourceFile, className, defaultCompon
339
398
  id: regionConfig.id || "region",
340
399
  name: regionConfig.name || "Region"
341
400
  };
401
+ if (regionConfig.description !== void 0) regionDefinition.description = regionConfig.description;
342
402
  if (regionConfig.componentTypes) regionDefinition.component_types = regionConfig.componentTypes;
343
403
  if (Array.isArray(regionConfig.componentTypeInclusions)) regionDefinition.component_type_inclusions = regionConfig.componentTypeInclusions.map((incl) => ({ type_id: normalizeComponentTypeId(String(incl), defaultComponentGroup) }));
344
404
  if (Array.isArray(regionConfig.componentTypeExclusions)) regionDefinition.component_type_exclusions = regionConfig.componentTypeExclusions.map((excl) => ({ type_id: normalizeComponentTypeId(String(excl), defaultComponentGroup) }));
345
405
  if (regionConfig.maxComponents !== void 0) regionDefinition.max_components = regionConfig.maxComponents;
346
406
  if (regionConfig.minComponents !== void 0) regionDefinition.min_components = regionConfig.minComponents;
347
407
  if (regionConfig.allowMultiple !== void 0) regionDefinition.allow_multiple = regionConfig.allowMultiple;
348
- if (regionConfig.defaultComponentConstructors) regionDefinition.default_component_constructors = regionConfig.defaultComponentConstructors;
408
+ if (Array.isArray(regionConfig.defaultComponentConstructors)) regionDefinition.default_component_constructors = regionConfig.defaultComponentConstructors.map((constructor) => {
409
+ const { typeId, name } = constructor;
410
+ const mapped = { type_id: normalizeComponentTypeId(String(typeId), defaultComponentGroup) };
411
+ if (name !== void 0) mapped.name = name;
412
+ return mapped;
413
+ });
349
414
  regionDefinitions.push(regionDefinition);
350
415
  }
351
416
  }
@@ -426,7 +491,8 @@ async function processPageTypeFile(filePath, projectRoot) {
426
491
  regionDefinitions,
427
492
  supportedAspectTypes: pageTypeConfig.supportedAspectTypes || [],
428
493
  attributes,
429
- route
494
+ route,
495
+ ...pageTypeConfig.preview !== void 0 && { preview: pageTypeConfig.preview }
430
496
  };
431
497
  pageTypes.push(pageTypeMetadata);
432
498
  }
@@ -513,6 +579,7 @@ async function generatePageTypeCartridge(pageType, outputDir, dryRun = false) {
513
579
  }];
514
580
  if (pageType.supportedAspectTypes) cartridgeData.supported_aspect_types = pageType.supportedAspectTypes;
515
581
  if (pageType.route) cartridgeData.route = pageType.route;
582
+ if (pageType.preview !== void 0) cartridgeData.preview = pageType.preview;
516
583
  await writeFile(outputPath, JSON.stringify(cartridgeData, null, 2));
517
584
  }
518
585
  const prefix = dryRun ? " - [DRY RUN]" : " -";
@@ -535,26 +602,14 @@ async function generateAspectCartridge(aspect, outputDir, dryRun = false) {
535
602
  logger.debug(`${prefix} ${String(aspect.name)}: ${String(aspect.description)} (${String(aspect.attributeDefinitions.length)} attributes) → ${fileName}.json`);
536
603
  }
537
604
  /**
538
- * Runs ESLint with --fix on the specified directory to format JSON files.
539
- * This ensures generated JSON files match the project's Prettier/ESLint configuration.
605
+ * Formats the generated JSON files in place so they match the project's `biome format` output
606
+ * (and therefore pass `pnpm lint` out of the box). Delegates to the shared Biome helper, which
607
+ * prefers the consuming project's Biome and is fail-safe (logs and leaves files valid-but-
608
+ * unformatted rather than throwing) when Biome can't run.
540
609
  */
541
- function lintGeneratedFiles(metadataDir, projectRoot) {
542
- try {
543
- logger.debug("🔧 Running ESLint --fix on generated JSON files...");
544
- execSync(`npx eslint "${metadataDir}/**/*.json" --fix --no-error-on-unmatched-pattern`, {
545
- cwd: projectRoot,
546
- stdio: "pipe",
547
- encoding: "utf-8"
548
- });
549
- logger.debug("✅ JSON files formatted successfully");
550
- } catch (error) {
551
- const execError = error;
552
- if (execError.status === 2) {
553
- const errMsg = execError.stderr || execError.stdout || "Unknown error";
554
- logger.warn(`⚠️ Could not run ESLint --fix: ${errMsg}`);
555
- } else if (execError.stderr && execError.stderr.includes("error")) logger.warn(`⚠️ Some linting issues could not be auto-fixed. Run ESLint manually to review.`);
556
- else logger.debug("✅ JSON files formatted successfully");
557
- }
610
+ function lintGeneratedFiles(metadataDir) {
611
+ logger.debug("🔧 Formatting generated JSON files with Biome...");
612
+ formatDirectoryWithProjectBiome(metadataDir);
558
613
  }
559
614
  async function generateMetadata(projectDirectory, metadataDirectory, options) {
560
615
  try {
@@ -669,7 +724,7 @@ async function generateMetadata(projectDirectory, metadataDirectory, options) {
669
724
  else logger.info(`Generated ${allAspects.length} aspect metadata file(s)`);
670
725
  }
671
726
  const shouldLintFix = options?.lintFix !== false;
672
- if (!dryRun && shouldLintFix && (allComponents.length > 0 || allPageTypes.length > 0 || allAspects.length > 0)) lintGeneratedFiles(metadataDir, projectRoot);
727
+ if (!dryRun && shouldLintFix && (allComponents.length > 0 || allPageTypes.length > 0 || allAspects.length > 0)) lintGeneratedFiles(metadataDir);
673
728
  return {
674
729
  componentsGenerated: allComponents.length,
675
730
  pageTypesGenerated: allPageTypes.length,
@@ -34,6 +34,7 @@ const BUILT_IN_NAMESPACE_BY_KEY = {
34
34
  shopperConsents: "ShopperConsents",
35
35
  shopperContext: "ShopperContext",
36
36
  shopperCustomers: "ShopperCustomers",
37
+ shopperDeliveryEstimates: "ShopperDeliveryEstimates",
37
38
  shopperExperience: "ShopperExperience",
38
39
  shopperGiftCertificates: "ShopperGiftCertificates",
39
40
  shopperLogin: "ShopperLogin",
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@ import path, { resolve } from "node:path";
2
2
  import fs from "fs-extra";
3
3
  import chalk from "chalk";
4
4
  import { createRequire } from "module";
5
- import path$1, { dirname, join, relative, resolve as resolve$1 } from "path";
5
+ import path$1, { basename, dirname, join, relative, resolve as resolve$1 } from "path";
6
6
  import { fileURLToPath } from "url";
7
7
  import { parse } from "@babel/parser";
8
8
  import { isArrayPattern, isClassDeclaration, isExportSpecifier, isFunctionDeclaration, isIdentifier, isJSXAttribute, isJSXElement, isJSXFragment, isJSXIdentifier, isMemberExpression, isObjectPattern, isObjectProperty, isRestElement, isVariableDeclaration, jsxClosingElement, jsxClosingFragment, jsxElement, jsxFragment, jsxIdentifier, jsxOpeningElement, jsxOpeningFragment, jsxText } from "@babel/types";
@@ -11,6 +11,7 @@ import traverseModule from "@babel/traverse";
11
11
  import { existsSync, readFileSync, writeFileSync } from "fs";
12
12
  import { glob } from "glob";
13
13
  import { Node, Project, ts } from "ts-morph";
14
+ import { spawnSync } from "child_process";
14
15
  import fs$1, { existsSync as existsSync$1, readFileSync as readFileSync$1 } from "node:fs";
15
16
  import { deadCodeElimination, findReferencedIdentifiers } from "babel-dead-code-elimination";
16
17
  import httpProxy from "http-proxy";
@@ -805,6 +806,72 @@ const watchConfigFilesPlugin = () => {
805
806
  };
806
807
  };
807
808
 
809
+ //#endregion
810
+ //#region src/utils/format-with-project-biome.ts
811
+ /** This module's own path — the resolution root for the SDK-bundled Biome fallback. */
812
+ const HERE = fileURLToPath(import.meta.url);
813
+ /**
814
+ * Format generated file content with the *consuming project's* Biome so the written file
815
+ * matches what the project's own `biome format` / `pnpm lint:fix` would produce.
816
+ *
817
+ * The SDK generates two kinds of files into a customer project — the extension `config.json`
818
+ * (`trim-extensions.ts`) and the static component registry (`staticRegistry.ts`). Both must
819
+ * be emitted in the exact byte shape the project's formatter produces, or the generated file
820
+ * fails `pnpm lint` on a fresh project / churns on every `biome format --write` (W-23074938).
821
+ *
822
+ * Biome is preferred from the target file's own location — i.e. the consuming project's
823
+ * `node_modules` — so it's the project's Biome version whose output is matched. When that
824
+ * isn't installed yet (e.g. `create-storefront` runs `trim-extensions` BEFORE the generated
825
+ * project's first `pnpm install`), we fall back to the SDK-bundled `@biomejs/biome` (a hard
826
+ * dependency of this package, pinned to the template's version). Either way the file's
827
+ * committed `biome.json` governs the output: Biome discovers its config by walking up from
828
+ * the working directory, so we spawn it with `cwd` set to the file's directory and pass the
829
+ * file's basename via `--stdin-file-path` (which drives both parser selection and formatting).
830
+ *
831
+ * Returns the content unchanged when no Biome can be resolved at all or the format fails, so
832
+ * generation never breaks over formatting. A genuine format error is logged but non-fatal —
833
+ * an unformatted-but-valid file is recoverable by the customer running `biome format --write`.
834
+ *
835
+ * @param content - The serialized file content to format.
836
+ * @param filePath - The file's path (drives parser selection + config resolution + cwd).
837
+ * @returns The Biome-formatted content, or the original content if Biome is unavailable.
838
+ */
839
+ function formatWithProjectBiome(content, filePath) {
840
+ const biomeBin = resolveBiomeBin(filePath);
841
+ if (!biomeBin) {
842
+ logger.warn(`⚠️ Biome could not be resolved; ${basename(filePath)} will be written unformatted.`);
843
+ return content;
844
+ }
845
+ const result = spawnSync(process.execPath, [
846
+ biomeBin,
847
+ "format",
848
+ `--stdin-file-path=${basename(filePath)}`
849
+ ], {
850
+ cwd: dirname(filePath),
851
+ input: content,
852
+ encoding: "utf8"
853
+ });
854
+ if (result.status !== 0 || typeof result.stdout !== "string") {
855
+ const detail = result.stderr?.trim() || `exit code ${result.status}`;
856
+ logger.warn(`⚠️ Skipping Biome formatting for ${basename(filePath)}: ${detail}`);
857
+ return content;
858
+ }
859
+ return result.stdout;
860
+ }
861
+ /**
862
+ * Resolve a Biome CLI binary path, preferring the consuming project's install and falling back
863
+ * to the SDK-bundled copy (available pre-install). Returns null when neither resolves.
864
+ */
865
+ function resolveBiomeBin(filePath) {
866
+ for (const fromPath of [filePath, HERE]) try {
867
+ const req = createRequire(fromPath);
868
+ const biomePkgJson = req.resolve("@biomejs/biome/package.json");
869
+ const { bin } = req(biomePkgJson);
870
+ return join(dirname(biomePkgJson), bin.biome);
871
+ } catch {}
872
+ return null;
873
+ }
874
+
808
875
  //#endregion
809
876
  //#region src/plugins/staticRegistry.ts
810
877
  const DEFAULT_COMPONENT_GROUP = "storefrontnext_base";
@@ -958,42 +1025,9 @@ ${registrations}
958
1025
  `;
959
1026
  }
960
1027
  /**
961
- * Formats registry file content with the project's own Prettier so the written file
962
- * matches what the project's formatter would produce.
963
- *
964
- * Without this, a standalone `prettier --write` (pre-commit hook, format-on-save) rewrites
965
- * the generated file on every commit: the generator emits one `registerImporter` call per
966
- * line and cannot anticipate an arbitrary `printWidth`, so Prettier re-wraps long lines and
967
- * the regenerate -> format -> regenerate loop never settles.
968
- *
969
- * Prettier is resolved from the registry file's own location — i.e. the consuming project's
970
- * `node_modules`, not the SDK bundle (which keeps `node_modules` external). It is the project's
971
- * Prettier version and config whose output must be matched. Returns the content unchanged when
972
- * Prettier is absent or fails, so registry generation never breaks a build over formatting.
973
- */
974
- let warnedNoPrettier = false;
975
- async function formatWithProjectPrettier(content, registryFilePath) {
976
- try {
977
- const prettier = createRequire(registryFilePath)("prettier");
978
- const config = await prettier.resolveConfig(registryFilePath, { editorconfig: true });
979
- return await prettier.format(content, {
980
- ...config,
981
- filepath: registryFilePath
982
- });
983
- } catch (error) {
984
- if (error.code === "MODULE_NOT_FOUND") {
985
- if (!warnedNoPrettier) {
986
- logger.warn("⚠️ Prettier not found in the project; static registry will be written unformatted.");
987
- warnedNoPrettier = true;
988
- }
989
- } else logger.warn(`⚠️ Skipping Prettier formatting for registry file: ${error.message}`);
990
- return content;
991
- }
992
- }
993
- /**
994
1028
  * Updates the registry.ts file with the generated code
995
1029
  */
996
- async function updateRegistryFile(registryFilePath, generatedCode) {
1030
+ function updateRegistryFile(registryFilePath, generatedCode) {
997
1031
  let existingContent;
998
1032
  if (!existsSync(registryFilePath)) {
999
1033
  logger.debug("📝 Creating new registry file...");
@@ -1018,7 +1052,7 @@ export const registry = new ComponentRegistry();
1018
1052
  const startIndex = existingContent.indexOf(startMarker);
1019
1053
  const endIndex = existingContent.indexOf(endMarker);
1020
1054
  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.`);
1021
- const updatedContent = await formatWithProjectPrettier(`${existingContent.slice(0, startIndex + 24)}\n${generatedCode}\n${existingContent.slice(endIndex)}`, registryFilePath);
1055
+ const updatedContent = formatWithProjectBiome(`${existingContent.slice(0, startIndex + 24)}\n${generatedCode}\n${existingContent.slice(endIndex)}`, registryFilePath);
1022
1056
  if (updatedContent === existingContent) {
1023
1057
  logger.debug(`⏭️ Registry unchanged, skipping write: ${registryFilePath}`);
1024
1058
  return false;
@@ -1070,7 +1104,7 @@ const staticRegistryPlugin = (config = {}) => {
1070
1104
  logger.debug(`📦 Found ${components.length} components with @Component decorators`);
1071
1105
  const generatedCode = generateRegistryCode(components, registryIdentifier);
1072
1106
  const registryFilePath = resolve$1(projectRoot, registryPath);
1073
- const changed = await updateRegistryFile(registryFilePath, generatedCode);
1107
+ const changed = updateRegistryFile(registryFilePath, generatedCode);
1074
1108
  logger.debug("✅ Static registry generation complete!");
1075
1109
  return {
1076
1110
  registryFilePath,
@@ -1410,9 +1444,9 @@ const PASSTHROUGH_QUERY = "?platform-passthrough";
1410
1444
  * Finds a user-ejected entry file in the app directory.
1411
1445
  * Returns the absolute path if found, undefined otherwise.
1412
1446
  */
1413
- function findUserEntry(appDirectory, basename) {
1447
+ function findUserEntry(appDirectory, basename$1) {
1414
1448
  for (const ext of ENTRY_EXTENSIONS) {
1415
- const filePath = path.resolve(appDirectory, basename + ext);
1449
+ const filePath = path.resolve(appDirectory, basename$1 + ext);
1416
1450
  if (fs$1.existsSync(filePath)) return filePath;
1417
1451
  }
1418
1452
  }
@@ -1513,8 +1547,8 @@ function platformEntryPlugin() {
1513
1547
  const watcher = server.watcher;
1514
1548
  const checkEntryChange = (filePath) => {
1515
1549
  const relative$1 = path.relative(appDir, filePath);
1516
- const basename = path.basename(relative$1, path.extname(relative$1));
1517
- if (path.dirname(relative$1) !== "." || basename !== "entry.server" && basename !== "entry.client") return;
1550
+ const basename$1 = path.basename(relative$1, path.extname(relative$1));
1551
+ if (path.dirname(relative$1) !== "." || basename$1 !== "entry.server" && basename$1 !== "entry.client") return;
1518
1552
  const ext = path.extname(relative$1);
1519
1553
  if (!ENTRY_EXTENSIONS.includes(ext)) return;
1520
1554
  const nowHasServer = findUserEntry(appDir, "entry.server") !== void 0;