@salesforce/storefront-next-dev 1.1.0 → 1.2.0-alpha.1

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/README.md CHANGED
@@ -102,7 +102,7 @@ pnpm sfnext create-instructions --project-directory /path/to/your/project --exte
102
102
 
103
103
  #### Cartridge generation and deployment instructions
104
104
 
105
- Run these commands from your project directory (e.g., `template-retail-rsc-app`):
105
+ Run these commands from your project directory:
106
106
 
107
107
  ```bash
108
108
  # Generate cartridge metadata for your site
@@ -11,7 +11,7 @@ interface GenerateMetadataOptions {
11
11
  */
12
12
  filePaths?: string[];
13
13
  /**
14
- * Whether to run ESLint with --fix on generated JSON files to format them according to project settings.
14
+ * Whether to format generated JSON files with Biome so they match the project's formatting.
15
15
  * Defaults to true.
16
16
  */
17
17
  lintFix?: boolean;
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","names":[],"sources":["../../src/cartridge-services/generate-cartridge.ts"],"sourcesContent":[],"mappings":";;AA21BA;;;AAIG,UA1Ec,uBAAA,CA0Ed;EAAO;;;;;;;;;;;;;;;;;;;;UAlDO,sBAAA;;;;;;iBA8CK,gBAAA,gEAGR,0BACX,QAAQ"}
1
+ {"version":3,"file":"index.d.ts","names":[],"sources":["../../src/cartridge-services/generate-cartridge.ts"],"sourcesContent":[],"mappings":";;AAk8BA;;;AAIG,UA/Cc,uBAAA,CA+Cd;EAAO;;;;;;;;;;;;;;;;;;;;UAvBO,sBAAA;;;;;;iBAmBK,gBAAA,gEAGR,0BACX,QAAQ"}
@@ -1,12 +1,16 @@
1
1
  import { access, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
2
2
  import { basename, extname, join, resolve } from "node:path";
3
- import { execSync } from "node:child_process";
4
3
  import { Node, Project } from "ts-morph";
5
4
  import { existsSync, readFileSync, unlinkSync } from "node:fs";
5
+ import { execSync } from "node:child_process";
6
6
  import { tmpdir } from "node:os";
7
7
  import { randomUUID } from "node:crypto";
8
8
  import { npmRunPathEnv } from "npm-run-path";
9
9
  import chalk from "chalk";
10
+ import { createRequire } from "module";
11
+ import { spawnSync } from "child_process";
12
+ import { dirname, join as join$1 } from "path";
13
+ import { fileURLToPath } from "url";
10
14
 
11
15
  //#region src/utils/logger.ts
12
16
  const LEVEL_PRIORITY = {
@@ -174,6 +178,59 @@ function flattenRoutes(routes, parentPath = "") {
174
178
  return result;
175
179
  }
176
180
 
181
+ //#endregion
182
+ //#region src/utils/format-with-project-biome.ts
183
+ /** This module's own path — the resolution root for the SDK-bundled Biome fallback. */
184
+ const HERE = fileURLToPath(import.meta.url);
185
+ /**
186
+ * Format every file Biome recognizes under `directory` in place with the consuming project's
187
+ * Biome, so generated files (e.g. cartridge metadata JSON) match `biome format` / `pnpm lint:fix`.
188
+ *
189
+ * Unlike {@link formatWithProjectBiome} (single file via stdin), this runs `biome format --write`
190
+ * against the directory so Biome discovers the project's `biome.json` by walking up from it and
191
+ * applies the project's formatting to the written files directly.
192
+ *
193
+ * Fail-safe: when Biome can't be resolved or the run fails, the files are left as written (valid,
194
+ * just unformatted) and a warning is logged — generation never breaks over formatting.
195
+ *
196
+ * @param directory - Directory whose Biome-recognized files should be formatted in place.
197
+ */
198
+ function formatDirectoryWithProjectBiome(directory) {
199
+ const biomeBin = resolveBiomeBin(join$1(directory, "biome-resolution-root.js"));
200
+ if (!biomeBin) {
201
+ logger.warn(`⚠️ Biome could not be resolved; generated files in ${directory} were left unformatted.`);
202
+ return;
203
+ }
204
+ const result = spawnSync(process.execPath, [
205
+ biomeBin,
206
+ "format",
207
+ "--write",
208
+ "."
209
+ ], {
210
+ cwd: directory,
211
+ encoding: "utf8"
212
+ });
213
+ if (result.status !== 0) {
214
+ const detail = result.stderr?.trim() || `exit code ${result.status}`;
215
+ logger.warn(`⚠️ Some generated files in ${directory} could not be formatted by Biome: ${detail}`);
216
+ return;
217
+ }
218
+ logger.debug(`✅ Formatted generated files in ${directory} with Biome`);
219
+ }
220
+ /**
221
+ * Resolve a Biome CLI binary path, preferring the consuming project's install and falling back
222
+ * to the SDK-bundled copy (available pre-install). Returns null when neither resolves.
223
+ */
224
+ function resolveBiomeBin(filePath) {
225
+ for (const fromPath of [filePath, HERE]) try {
226
+ const req = createRequire(fromPath);
227
+ const biomePkgJson = req.resolve("@biomejs/biome/package.json");
228
+ const { bin } = req(biomePkgJson);
229
+ return join$1(dirname(biomePkgJson), bin.biome);
230
+ } catch {}
231
+ return null;
232
+ }
233
+
177
234
  //#endregion
178
235
  //#region src/cartridge-services/generate-cartridge.ts
179
236
  const SKIP_DIRECTORIES = [
@@ -224,6 +281,62 @@ function resolveAttributeType(decoratorType, tsMorphType, fieldName) {
224
281
  if (tsMorphType && TYPE_MAPPING[tsMorphType]) return TYPE_MAPPING[tsMorphType];
225
282
  return "string";
226
283
  }
284
+ const SEARCHING_FORBIDDEN_TYPES = new Set([
285
+ "integer",
286
+ "boolean",
287
+ "file",
288
+ "page",
289
+ "image",
290
+ "url",
291
+ "enum"
292
+ ]);
293
+ /**
294
+ * Validate an authored `searching` config against the resolved attribute type and map it to the
295
+ * snake_case schema shape. The metadefinition schema gates `searching` by type; an invalid
296
+ * combination fails cartridge generation (matching the strict behavior of `resolveAttributeType`).
297
+ *
298
+ * Rules (from attributedefinition.json):
299
+ * - Forbidden entirely on integer/boolean/file/page/image/url/enum.
300
+ * - `markup`: `sortable` must be false.
301
+ * - `custom`/`cms_record`: `refinable` must be false; `boost_factor`/`sortable` not allowed.
302
+ * - `string`/`text`/`product`/`category`: all fields allowed.
303
+ */
304
+ function mapAttributeSearching(searching, resolvedType, fieldName) {
305
+ const fail = (reason) => {
306
+ logger.error(`Invalid 'searching' config for attribute '${fieldName}' (type '${resolvedType}'): ${reason}`);
307
+ process.exit(1);
308
+ };
309
+ if (SEARCHING_FORBIDDEN_TYPES.has(resolvedType)) fail(`the '${resolvedType}' attribute type does not support searching.`);
310
+ const { searchable, refinable, boostFactor, sortable } = searching;
311
+ if (typeof searchable !== "boolean" || typeof refinable !== "boolean") fail(`'searchable' and 'refinable' are required booleans.`);
312
+ if (resolvedType === "markup" && sortable === true) fail(`'sortable' must be false for markup attributes.`);
313
+ if (resolvedType === "custom" || resolvedType === "cms_record") {
314
+ if (refinable === true) fail(`'refinable' must be false for ${resolvedType} attributes.`);
315
+ if (boostFactor !== void 0) fail(`'boostFactor' is not supported for ${resolvedType} attributes.`);
316
+ if (sortable === true) fail(`'sortable' must be false for ${resolvedType} attributes.`);
317
+ }
318
+ const mapped = {
319
+ searchable,
320
+ refinable
321
+ };
322
+ if (boostFactor !== void 0) mapped.boost_factor = boostFactor;
323
+ if (sortable !== void 0) mapped.sortable = sortable;
324
+ return mapped;
325
+ }
326
+ /**
327
+ * Validate an authored `dynamicLookup` config and map it to the snake_case schema shape.
328
+ * The metadefinition schema allows `dynamic_lookup` on all attribute types but requires a
329
+ * single `aspect_attribute_alias` field (`additionalProperties: false`); a missing alias fails
330
+ * cartridge generation (matching the strict behavior of `resolveAttributeType`).
331
+ */
332
+ function mapAttributeDynamicLookup(dynamicLookup, fieldName) {
333
+ const { aspectAttributeAlias } = dynamicLookup;
334
+ if (typeof aspectAttributeAlias !== "string" || aspectAttributeAlias.length === 0) {
335
+ logger.error(`Invalid 'dynamicLookup' config for attribute '${fieldName}': 'aspectAttributeAlias' is required and must be a non-empty string.`);
336
+ process.exit(1);
337
+ }
338
+ return { aspect_attribute_alias: aspectAttributeAlias };
339
+ }
227
340
  function toHumanReadableName(fieldName) {
228
341
  return fieldName.replace(/([A-Z])/g, " $1").replace(/^./, (str) => str.toUpperCase()).trim();
229
342
  }
@@ -375,6 +488,8 @@ function extractAttributesFromSource(sourceFile, className) {
375
488
  if (config.values) attribute.values = config.values;
376
489
  if (config.defaultValue !== void 0) attribute.default_value = config.defaultValue;
377
490
  if (config.editorDefinition !== void 0) attribute.editor_definition = config.editorDefinition;
491
+ if (config.searching !== void 0) attribute.searching = mapAttributeSearching(config.searching, attribute.type, String(attribute.id));
492
+ if (config.dynamicLookup !== void 0) attribute.dynamic_lookup = mapAttributeDynamicLookup(config.dynamicLookup, String(attribute.id));
378
493
  attributes.push(attribute);
379
494
  }
380
495
  } catch (error) {
@@ -404,13 +519,19 @@ function extractRegionDefinitionsFromSource(sourceFile, className, defaultCompon
404
519
  id: regionConfig.id || "region",
405
520
  name: regionConfig.name || "Region"
406
521
  };
522
+ if (regionConfig.description !== void 0) regionDefinition.description = regionConfig.description;
407
523
  if (regionConfig.componentTypes) regionDefinition.component_types = regionConfig.componentTypes;
408
524
  if (Array.isArray(regionConfig.componentTypeInclusions)) regionDefinition.component_type_inclusions = regionConfig.componentTypeInclusions.map((incl) => ({ type_id: normalizeComponentTypeId(String(incl), defaultComponentGroup) }));
409
525
  if (Array.isArray(regionConfig.componentTypeExclusions)) regionDefinition.component_type_exclusions = regionConfig.componentTypeExclusions.map((excl) => ({ type_id: normalizeComponentTypeId(String(excl), defaultComponentGroup) }));
410
526
  if (regionConfig.maxComponents !== void 0) regionDefinition.max_components = regionConfig.maxComponents;
411
527
  if (regionConfig.minComponents !== void 0) regionDefinition.min_components = regionConfig.minComponents;
412
528
  if (regionConfig.allowMultiple !== void 0) regionDefinition.allow_multiple = regionConfig.allowMultiple;
413
- if (regionConfig.defaultComponentConstructors) regionDefinition.default_component_constructors = regionConfig.defaultComponentConstructors;
529
+ if (Array.isArray(regionConfig.defaultComponentConstructors)) regionDefinition.default_component_constructors = regionConfig.defaultComponentConstructors.map((constructor) => {
530
+ const { typeId, name } = constructor;
531
+ const mapped = { type_id: normalizeComponentTypeId(String(typeId), defaultComponentGroup) };
532
+ if (name !== void 0) mapped.name = name;
533
+ return mapped;
534
+ });
414
535
  regionDefinitions.push(regionDefinition);
415
536
  }
416
537
  }
@@ -491,7 +612,8 @@ async function processPageTypeFile(filePath, projectRoot) {
491
612
  regionDefinitions,
492
613
  supportedAspectTypes: pageTypeConfig.supportedAspectTypes || [],
493
614
  attributes,
494
- route
615
+ route,
616
+ ...pageTypeConfig.preview !== void 0 && { preview: pageTypeConfig.preview }
495
617
  };
496
618
  pageTypes.push(pageTypeMetadata);
497
619
  }
@@ -578,6 +700,7 @@ async function generatePageTypeCartridge(pageType, outputDir, dryRun = false) {
578
700
  }];
579
701
  if (pageType.supportedAspectTypes) cartridgeData.supported_aspect_types = pageType.supportedAspectTypes;
580
702
  if (pageType.route) cartridgeData.route = pageType.route;
703
+ if (pageType.preview !== void 0) cartridgeData.preview = pageType.preview;
581
704
  await writeFile(outputPath, JSON.stringify(cartridgeData, null, 2));
582
705
  }
583
706
  const prefix = dryRun ? " - [DRY RUN]" : " -";
@@ -600,26 +723,14 @@ async function generateAspectCartridge(aspect, outputDir, dryRun = false) {
600
723
  logger.debug(`${prefix} ${String(aspect.name)}: ${String(aspect.description)} (${String(aspect.attributeDefinitions.length)} attributes) → ${fileName}.json`);
601
724
  }
602
725
  /**
603
- * Runs ESLint with --fix on the specified directory to format JSON files.
604
- * This ensures generated JSON files match the project's Prettier/ESLint configuration.
726
+ * Formats the generated JSON files in place so they match the project's `biome format` output
727
+ * (and therefore pass `pnpm lint` out of the box). Delegates to the shared Biome helper, which
728
+ * prefers the consuming project's Biome and is fail-safe (logs and leaves files valid-but-
729
+ * unformatted rather than throwing) when Biome can't run.
605
730
  */
606
- function lintGeneratedFiles(metadataDir, projectRoot) {
607
- try {
608
- logger.debug("🔧 Running ESLint --fix on generated JSON files...");
609
- execSync(`npx eslint "${metadataDir}/**/*.json" --fix --no-error-on-unmatched-pattern`, {
610
- cwd: projectRoot,
611
- stdio: "pipe",
612
- encoding: "utf-8"
613
- });
614
- logger.debug("✅ JSON files formatted successfully");
615
- } catch (error) {
616
- const execError = error;
617
- if (execError.status === 2) {
618
- const errMsg = execError.stderr || execError.stdout || "Unknown error";
619
- logger.warn(`⚠️ Could not run ESLint --fix: ${errMsg}`);
620
- } else if (execError.stderr && execError.stderr.includes("error")) logger.warn(`⚠️ Some linting issues could not be auto-fixed. Run ESLint manually to review.`);
621
- else logger.debug("✅ JSON files formatted successfully");
622
- }
731
+ function lintGeneratedFiles(metadataDir) {
732
+ logger.debug("🔧 Formatting generated JSON files with Biome...");
733
+ formatDirectoryWithProjectBiome(metadataDir);
623
734
  }
624
735
  async function generateMetadata(projectDirectory, metadataDirectory, options) {
625
736
  try {
@@ -734,7 +845,7 @@ async function generateMetadata(projectDirectory, metadataDirectory, options) {
734
845
  else logger.info(`Generated ${allAspects.length} aspect metadata file(s)`);
735
846
  }
736
847
  const shouldLintFix = options?.lintFix !== false;
737
- if (!dryRun && shouldLintFix && (allComponents.length > 0 || allPageTypes.length > 0 || allAspects.length > 0)) lintGeneratedFiles(metadataDir, projectRoot);
848
+ if (!dryRun && shouldLintFix && (allComponents.length > 0 || allPageTypes.length > 0 || allAspects.length > 0)) lintGeneratedFiles(metadataDir);
738
849
  return {
739
850
  componentsGenerated: allComponents.length,
740
851
  pageTypesGenerated: allPageTypes.length,