@salesforce/storefront-next-dev 1.0.1 → 1.1.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/dist/cartridge-services/index.d.ts.map +1 -1
- package/dist/cartridge-services/index.js +4 -0
- package/dist/cartridge-services/index.js.map +1 -1
- package/dist/commands/config/aggregate-extensions.js +279 -0
- package/dist/commands/create-storefront.js +37 -6
- package/dist/commands/locales/aggregate-extensions.js +3 -2
- package/dist/commands/push.js +2 -0
- package/dist/constants.js +29 -0
- package/dist/dependency-utils.js +56 -4
- package/dist/entry/server.d.ts +1 -1
- package/dist/entry/server.js +68 -23
- package/dist/entry/server.js.map +1 -1
- package/dist/generate-cartridge.js +4 -0
- package/dist/index.d.ts +58 -27
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +255 -123
- package/dist/index.js.map +1 -1
- package/dist/logger.js +1 -1
- package/dist/manage-extensions.js +1 -1
- package/dist/mrt/ssr.mjs +53 -53
- package/dist/mrt/ssr.mjs.map +1 -1
- package/dist/mrt/streamingHandler.mjs +59 -59
- package/dist/mrt/streamingHandler.mjs.map +1 -1
- package/dist/server.js +79 -28
- package/package.json +13 -11
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import { t as logger } from "../../logger.js";
|
|
2
|
+
import "../../logger2.js";
|
|
3
|
+
import { r as commonFlags } from "../../flags.js";
|
|
4
|
+
import { t as GENERATED_EXTENSION_DIRS } from "../../constants.js";
|
|
5
|
+
import { Command, Flags } from "@oclif/core";
|
|
6
|
+
import { existsSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { mkdir, readFile, readdir, writeFile } from "fs/promises";
|
|
9
|
+
import { parse } from "@babel/parser";
|
|
10
|
+
|
|
11
|
+
//#region src/config/aggregate-extension-config.ts
|
|
12
|
+
/** Apache License 2.0 header text for generated files. Inlined to avoid path resolution issues in standalone projects. */
|
|
13
|
+
const APACHE_LICENSE_HEADER = [
|
|
14
|
+
`Copyright ${(/* @__PURE__ */ new Date()).getFullYear()} Salesforce, Inc.`,
|
|
15
|
+
"",
|
|
16
|
+
"Licensed under the Apache License, Version 2.0 (the \"License\");",
|
|
17
|
+
"you may not use this file except in compliance with the License.",
|
|
18
|
+
"You may obtain a copy of the License at",
|
|
19
|
+
"",
|
|
20
|
+
" http://www.apache.org/licenses/LICENSE-2.0",
|
|
21
|
+
"",
|
|
22
|
+
"Unless required by applicable law or agreed to in writing, software",
|
|
23
|
+
"distributed under the License is distributed on an \"AS IS\" BASIS,",
|
|
24
|
+
"WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.",
|
|
25
|
+
"See the License for the specific language governing permissions and",
|
|
26
|
+
"limitations under the License."
|
|
27
|
+
].join("\n");
|
|
28
|
+
const OUTPUT_DIR_NAME = GENERATED_EXTENSION_DIRS.config;
|
|
29
|
+
const LOCALES_DIR_NAME = GENERATED_EXTENSION_DIRS.locales;
|
|
30
|
+
/**
|
|
31
|
+
* A camelCased folder name is only usable as a config key if it is also a plain JS identifier:
|
|
32
|
+
* a leading letter followed by letters or digits. Folder names that camelCase to anything else
|
|
33
|
+
* (a dot, a leading digit, an empty string) would emit a barrel that fails to parse.
|
|
34
|
+
*/
|
|
35
|
+
const VALID_CONFIG_KEY = /^[a-zA-Z][a-zA-Z0-9]*$/;
|
|
36
|
+
const CLIENT_MODE = {
|
|
37
|
+
sourceFileName: "config.ts",
|
|
38
|
+
outputFileName: "index.ts",
|
|
39
|
+
barrelFooterComment: [
|
|
40
|
+
"// Each extension's config.ts default export is namespaced by the camelCase of its folder",
|
|
41
|
+
"// name (e.g. loqate-address-verification -> loqateAddressVerification), making it available",
|
|
42
|
+
"// at config.app.extension.<key> and overridable via PUBLIC__app__extension__<key>__<setting>."
|
|
43
|
+
].join("\n")
|
|
44
|
+
};
|
|
45
|
+
const SERVER_MODE = {
|
|
46
|
+
sourceFileName: "server-config.ts",
|
|
47
|
+
outputFileName: "server.ts",
|
|
48
|
+
barrelFooterComment: [
|
|
49
|
+
"// Each extension's server-config.ts default export is namespaced by the camelCase of its",
|
|
50
|
+
"// folder name (e.g. loqate-address-verification -> loqateAddressVerification), making it",
|
|
51
|
+
"// available at config.app.serverExtension.<key> on the server only. Values never reach the",
|
|
52
|
+
"// browser: the client extractor strips app.serverExtension before window.__APP_CONFIG__,",
|
|
53
|
+
"// and a Vite plugin fails the build if any client chunk imports this barrel. There is no",
|
|
54
|
+
"// PUBLIC__ override path by design — the AST validator forbids process.env, so server-only",
|
|
55
|
+
"// secrets must be read from process.env in a route handler instead."
|
|
56
|
+
].join("\n")
|
|
57
|
+
};
|
|
58
|
+
function getDefaultDirs(projectDirectory) {
|
|
59
|
+
const extensionsDir = join(projectDirectory, "src", "extensions");
|
|
60
|
+
return {
|
|
61
|
+
EXTENSIONS_DIR: extensionsDir,
|
|
62
|
+
OUTPUT_DIR: join(extensionsDir, OUTPUT_DIR_NAME)
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
/** Convert kebab-case to PascalCase (e.g. `store-locator` → `StoreLocator`). */
|
|
66
|
+
function toPascalCase(str) {
|
|
67
|
+
return str.split("-").map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join("");
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Convert a kebab-case extension folder name to the camelCase key it occupies under
|
|
71
|
+
* `app.extension` (e.g. `loqate-address-verification` → `loqateAddressVerification`). This
|
|
72
|
+
* is the key merchants reference as `PUBLIC__app__extension__<key>__<setting>`.
|
|
73
|
+
*/
|
|
74
|
+
function toCamelCase(str) {
|
|
75
|
+
const pascal = toPascalCase(str);
|
|
76
|
+
return pascal.charAt(0).toLowerCase() + pascal.slice(1);
|
|
77
|
+
}
|
|
78
|
+
/** Value node types allowed inside a config object: static data only, no computed expressions. */
|
|
79
|
+
const STATIC_VALUE_TYPES = new Set([
|
|
80
|
+
"StringLiteral",
|
|
81
|
+
"NumericLiteral",
|
|
82
|
+
"BooleanLiteral",
|
|
83
|
+
"NullLiteral",
|
|
84
|
+
"ArrayExpression",
|
|
85
|
+
"ObjectExpression",
|
|
86
|
+
"TSAsExpression"
|
|
87
|
+
]);
|
|
88
|
+
/**
|
|
89
|
+
* Assert an extension's config module is a static, default-exported object literal — and nothing
|
|
90
|
+
* more. Generated barrels import each module as a live ES module, evaluated at build, typegen,
|
|
91
|
+
* `pnpm dev`, and server startup; since extensions are third-party and distributable, any
|
|
92
|
+
* top-level statement (an import, a `fetch`, a `process.env` read) would run arbitrary vendor
|
|
93
|
+
* code in those contexts and could leak secrets into the client bundle. Validating the shape
|
|
94
|
+
* at discovery time turns that supply-chain risk into a clear build-time error. The same rule
|
|
95
|
+
* applies to both client `config.ts` and server `server-config.ts` — the server barrel is
|
|
96
|
+
* structurally fenced from the client, but a vendor calling out to a network or reading env
|
|
97
|
+
* at module-eval time is still a build-time hazard we don't want to inherit.
|
|
98
|
+
*/
|
|
99
|
+
function assertStaticConfigModule(filePath, source) {
|
|
100
|
+
const reject = (reason) => {
|
|
101
|
+
throw new Error(`Extension config "${filePath}" must default-export a plain object of static values. ${reason} Config files are imported as live modules at build and server startup, so they may not import, call functions, or read process.env. Move dynamic/server-only values to a route handler.`);
|
|
102
|
+
};
|
|
103
|
+
const ast = parse(source, {
|
|
104
|
+
sourceType: "module",
|
|
105
|
+
plugins: ["typescript"]
|
|
106
|
+
});
|
|
107
|
+
let declaration;
|
|
108
|
+
for (const node of ast.program.body) if (node.type === "ExportDefaultDeclaration") declaration = node.declaration;
|
|
109
|
+
else if (node.type !== "EmptyStatement") reject(`Found a top-level \`${node.type}\` (only \`export default { … }\` is allowed).`);
|
|
110
|
+
if (!declaration) {
|
|
111
|
+
reject("No default export found.");
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
const unwrap = (n) => n.type === "TSAsExpression" && n.expression ? n.expression : n;
|
|
115
|
+
const assertStaticValue = (node) => {
|
|
116
|
+
const v = unwrap(node);
|
|
117
|
+
if (!STATIC_VALUE_TYPES.has(v.type)) reject(`Found a non-static \`${v.type}\` value.`);
|
|
118
|
+
if (v.type === "ObjectExpression") for (const prop of v.properties ?? []) {
|
|
119
|
+
if (prop.type !== "ObjectProperty") reject(`Found a \`${prop.type}\` (spreads and methods are not allowed).`);
|
|
120
|
+
if (prop.value) assertStaticValue(prop.value);
|
|
121
|
+
}
|
|
122
|
+
else if (v.type === "ArrayExpression") {
|
|
123
|
+
for (const el of v.elements ?? []) if (el) assertStaticValue(el);
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
assertStaticValue(unwrap(declaration));
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Find every extension folder that ships the mode's source file. The output `config/`
|
|
130
|
+
* directory and the locale aggregator's `locales/` directory are siblings of real
|
|
131
|
+
* extensions, so both are skipped. Each discovered file is validated as a static object
|
|
132
|
+
* literal before it joins the list (see `assertStaticConfigModule`). Results are sorted
|
|
133
|
+
* by folder name for stable output.
|
|
134
|
+
*
|
|
135
|
+
* Imports in the generated barrel target the source file's basename without extension —
|
|
136
|
+
* `../<ext>/config` for client, `../<ext>/server-config` for server. The server barrel
|
|
137
|
+
* sits alongside the client barrel in `src/extensions/config/`, so both barrels resolve
|
|
138
|
+
* extension paths through the same `../` parent.
|
|
139
|
+
*/
|
|
140
|
+
async function findExtensionsWithConfigByMode(extensionsDir, mode) {
|
|
141
|
+
const extensions = [];
|
|
142
|
+
const importBasename = mode.sourceFileName.replace(/\.ts$/, "");
|
|
143
|
+
try {
|
|
144
|
+
const entries = await readdir(extensionsDir, { withFileTypes: true });
|
|
145
|
+
for (const entry of entries) {
|
|
146
|
+
if (!entry.isDirectory() || entry.name === OUTPUT_DIR_NAME || entry.name === LOCALES_DIR_NAME) continue;
|
|
147
|
+
const sourcePath = join(extensionsDir, entry.name, mode.sourceFileName);
|
|
148
|
+
if (existsSync(sourcePath)) {
|
|
149
|
+
assertStaticConfigModule(`${entry.name}/${mode.sourceFileName}`, await readFile(sourcePath, "utf8"));
|
|
150
|
+
extensions.push({
|
|
151
|
+
name: entry.name,
|
|
152
|
+
path: `../${entry.name}/${importBasename}`
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
} catch (error) {
|
|
157
|
+
if (error.code !== "ENOENT") throw error;
|
|
158
|
+
}
|
|
159
|
+
return extensions.sort((a, b) => a.name.localeCompare(b.name));
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Build the barrel that re-exports each extension's config default under its camelCase key.
|
|
163
|
+
* Imports are widened (no `as const`) so the merged `app.extension` / `app.serverExtension`
|
|
164
|
+
* type stays mutable and a value like `apiKey: ''` types as `string`, not the literal `''`.
|
|
165
|
+
*
|
|
166
|
+
* Throws if two folders collapse to the same camelCase key — config feeds runtime values
|
|
167
|
+
* like API keys, so a silent last-wins could route a setting to the wrong extension.
|
|
168
|
+
*/
|
|
169
|
+
function generateBarrelByMode(extensions, mode) {
|
|
170
|
+
const header = `${`/**\n${APACHE_LICENSE_HEADER.split("\n").map((line) => line ? ` * ${line}` : " *").join("\n")}\n */`}
|
|
171
|
+
|
|
172
|
+
// NOTE: This file is auto-generated. Do not edit manually.
|
|
173
|
+
// Run 'pnpm config:aggregate-extensions' to regenerate this file.
|
|
174
|
+
|
|
175
|
+
`;
|
|
176
|
+
if (extensions.length === 0) return `${header}// No extension config files found\nexport default {};\n`;
|
|
177
|
+
const keyByExtension = /* @__PURE__ */ new Map();
|
|
178
|
+
for (const ext of extensions) {
|
|
179
|
+
const key = toCamelCase(ext.name);
|
|
180
|
+
if (ext.name.includes("_")) throw new Error(`Extension folder "${ext.name}" uses underscores. Use hyphens so the name camelCases to the expected config key (e.g. "store-locator" → "storeLocator", not "store_locator").`);
|
|
181
|
+
if (!VALID_CONFIG_KEY.test(key)) throw new Error(`Extension folder "${ext.name}" maps to an invalid config key "${key}". Use a folder name of letters, digits, and hyphens that starts with a letter (e.g. "loqate-address-verification" → "loqateAddressVerification").`);
|
|
182
|
+
const existing = keyByExtension.get(key);
|
|
183
|
+
if (existing) throw new Error(`Extension config key collision: "${existing}" and "${ext.name}" both map to "${key}". Rename one extension folder so each resolves to a distinct config namespace.`);
|
|
184
|
+
keyByExtension.set(key, ext.name);
|
|
185
|
+
}
|
|
186
|
+
const imports = extensions.map((ext) => `import ${toCamelCase(ext.name)}Config from '${ext.path}';`).join("\n");
|
|
187
|
+
const exports = extensions.map((ext) => {
|
|
188
|
+
const key = toCamelCase(ext.name);
|
|
189
|
+
return ` ${key}: ${key}Config,`;
|
|
190
|
+
}).join("\n");
|
|
191
|
+
return `${header}${imports}
|
|
192
|
+
|
|
193
|
+
${mode.barrelFooterComment}
|
|
194
|
+
export default {
|
|
195
|
+
${exports}
|
|
196
|
+
};
|
|
197
|
+
`;
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Discover every extension's source file under the given mode and write the matching barrel
|
|
201
|
+
* if its content has changed. Skipping the write when content is unchanged keeps the Vite
|
|
202
|
+
* watcher quiet — a no-op rewrite would touch the file's mtime and trigger a self-induced
|
|
203
|
+
* reload loop on every `pnpm dev` prestep.
|
|
204
|
+
*/
|
|
205
|
+
async function aggregateByMode(options, mode) {
|
|
206
|
+
const { projectDirectory = process.cwd(), silent = false } = options;
|
|
207
|
+
const { OUTPUT_DIR, EXTENSIONS_DIR } = options.dirs ?? getDefaultDirs(projectDirectory);
|
|
208
|
+
const log = (message, ...args) => {
|
|
209
|
+
if (!silent) logger.debug(message, ...args);
|
|
210
|
+
};
|
|
211
|
+
try {
|
|
212
|
+
log(`🔍 Scanning for extension ${mode.sourceFileName} files...`);
|
|
213
|
+
const extensions = await findExtensionsWithConfigByMode(EXTENSIONS_DIR, mode);
|
|
214
|
+
const content = generateBarrelByMode(extensions, mode);
|
|
215
|
+
await mkdir(OUTPUT_DIR, { recursive: true });
|
|
216
|
+
const filePath = join(OUTPUT_DIR, mode.outputFileName);
|
|
217
|
+
if (await readFile(filePath, "utf8").catch(() => null) !== content) await writeFile(filePath, content, "utf8");
|
|
218
|
+
log(`✅ Generated: src/extensions/config/${mode.outputFileName} (${extensions.length} extension(s))`);
|
|
219
|
+
return {
|
|
220
|
+
extensions: extensions.map((ext) => toCamelCase(ext.name)),
|
|
221
|
+
filePath
|
|
222
|
+
};
|
|
223
|
+
} catch (error) {
|
|
224
|
+
if (!silent) logger.error(`❌ Error generating extension ${mode.outputFileName}: ${String(error)}`);
|
|
225
|
+
throw error;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Discover every extension's `config.ts` AND `server-config.ts` and regenerate both barrels:
|
|
230
|
+
* `src/extensions/config/index.ts` (merged into `app.extension`, reaches the client) and
|
|
231
|
+
* `src/extensions/config/server.ts` (merged into `app.serverExtension`, server-only — the
|
|
232
|
+
* loader strips it before React Router serializes the hydration payload, and a Vite plugin
|
|
233
|
+
* fails the build if any client chunk imports the server barrel).
|
|
234
|
+
*
|
|
235
|
+
* Runs both modes in parallel: the source files are distinct (`config.ts` vs
|
|
236
|
+
* `server-config.ts`), the output files are distinct (`index.ts` vs `server.ts`), and each
|
|
237
|
+
* mode does its own mtime-aware write — there's no shared state between them. Folding both
|
|
238
|
+
* into one CLI invocation closes a class of footgun where a contributor regenerates one
|
|
239
|
+
* barrel but not the other and ships an inconsistent `app.extension` / `app.serverExtension`
|
|
240
|
+
* pair (see PR #1964 review).
|
|
241
|
+
*
|
|
242
|
+
* Used as the `sfnext config aggregate-extensions` build prestep (mirrors `sfnext locales
|
|
243
|
+
* aggregate-extensions`).
|
|
244
|
+
*/
|
|
245
|
+
async function aggregateExtensionConfig(options = {}) {
|
|
246
|
+
const [client, server] = await Promise.all([aggregateByMode(options, CLIENT_MODE), aggregateByMode(options, SERVER_MODE)]);
|
|
247
|
+
return {
|
|
248
|
+
client,
|
|
249
|
+
server
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
//#endregion
|
|
254
|
+
//#region src/commands/config/aggregate-extensions.ts
|
|
255
|
+
var AggregateExtensions = class AggregateExtensions extends Command {
|
|
256
|
+
static description = "Aggregate extension config.ts and server-config.ts files into the app.extension and app.serverExtension barrels";
|
|
257
|
+
static examples = [
|
|
258
|
+
"<%= config.bin %> <%= command.id %>",
|
|
259
|
+
"<%= config.bin %> <%= command.id %> -d ./my-project",
|
|
260
|
+
"<%= config.bin %> <%= command.id %> --silent"
|
|
261
|
+
];
|
|
262
|
+
static flags = {
|
|
263
|
+
...commonFlags,
|
|
264
|
+
silent: Flags.boolean({
|
|
265
|
+
description: "Suppress output",
|
|
266
|
+
default: false
|
|
267
|
+
})
|
|
268
|
+
};
|
|
269
|
+
async run() {
|
|
270
|
+
const { flags } = await this.parse(AggregateExtensions);
|
|
271
|
+
await aggregateExtensionConfig({
|
|
272
|
+
projectDirectory: flags["project-directory"],
|
|
273
|
+
silent: flags.silent
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
//#endregion
|
|
279
|
+
export { AggregateExtensions as default };
|
|
@@ -12,7 +12,23 @@ import { parseEnv } from "node:util";
|
|
|
12
12
|
|
|
13
13
|
//#region src/create-storefront.ts
|
|
14
14
|
const DEFAULT_STOREFRONT = "sfcc-storefront";
|
|
15
|
-
|
|
15
|
+
/**
|
|
16
|
+
* Available storefront verticals, keyed by the value accepted by the `--vertical` flag.
|
|
17
|
+
* `label` is the human-facing choice shown in the interactive prompt; `url` is the
|
|
18
|
+
* published template repository cloned for that vertical. Extend this map to surface a
|
|
19
|
+
* new vertical in both the prompt and the flag.
|
|
20
|
+
*/
|
|
21
|
+
const VERTICALS = {
|
|
22
|
+
fashion: {
|
|
23
|
+
label: "Salesforce B2C Commerce Retail Storefront (Fashion)",
|
|
24
|
+
url: "https://github.com/SalesforceCommerceCloud/storefront-next-template"
|
|
25
|
+
},
|
|
26
|
+
cosmetic: {
|
|
27
|
+
label: "Salesforce B2C Commerce Beauty Storefront (Cosmetic)",
|
|
28
|
+
url: "https://github.com/SalesforceCommerceCloud/storefront-next-beauty"
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
const DEFAULT_VERTICAL = "fashion";
|
|
16
32
|
const isLocalPath = (template) => template.startsWith("file://") || template.startsWith("/") || template.startsWith("./") || template.startsWith("../");
|
|
17
33
|
const createStorefront = async (options = {}) => {
|
|
18
34
|
try {
|
|
@@ -35,15 +51,23 @@ const createStorefront = async (options = {}) => {
|
|
|
35
51
|
logger.info("\n");
|
|
36
52
|
const outputPath = options.outputDir ? path.join(options.outputDir, storefront) : storefront;
|
|
37
53
|
let template = options.template;
|
|
54
|
+
if (!template && options.vertical) {
|
|
55
|
+
const vertical = VERTICALS[options.vertical];
|
|
56
|
+
if (!vertical) {
|
|
57
|
+
logger.error(`Unknown vertical "${options.vertical}". Available verticals: ${Object.keys(VERTICALS).join(", ")}.`);
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
60
|
+
template = vertical.url;
|
|
61
|
+
} else if (!template && options.defaults) template = VERTICALS[DEFAULT_VERTICAL].url;
|
|
38
62
|
if (!template) {
|
|
39
63
|
template = (await prompts({
|
|
40
64
|
type: "select",
|
|
41
65
|
name: "template",
|
|
42
66
|
message: "📄 Which template would you like to use for your storefront?\n",
|
|
43
|
-
choices: [{
|
|
44
|
-
title:
|
|
45
|
-
value:
|
|
46
|
-
}, {
|
|
67
|
+
choices: [...Object.values(VERTICALS).map((vertical) => ({
|
|
68
|
+
title: vertical.label,
|
|
69
|
+
value: vertical.url
|
|
70
|
+
})), {
|
|
47
71
|
title: "A different template (I will provide the Github URL)",
|
|
48
72
|
value: "custom"
|
|
49
73
|
}]
|
|
@@ -153,7 +177,7 @@ const createStorefront = async (options = {}) => {
|
|
|
153
177
|
logger.warn(`${dependentNames} requires ${addedName}. ${addedName} has been automatically added.`);
|
|
154
178
|
}
|
|
155
179
|
}
|
|
156
|
-
trimExtensions(outputPath, Object.fromEntries(resolvedExtensions.map((ext) => [ext, true])), { extensions: extensionConfig.extensions });
|
|
180
|
+
await trimExtensions(outputPath, Object.fromEntries(resolvedExtensions.map((ext) => [ext, true])), { extensions: extensionConfig.extensions });
|
|
157
181
|
}
|
|
158
182
|
}
|
|
159
183
|
const configMetaPath = fs.existsSync(path.join(outputPath, "config-meta.json")) ? path.join(outputPath, "config-meta.json") : path.join(outputPath, "src", "config", "config-meta.json");
|
|
@@ -198,6 +222,7 @@ var CreateStorefront = class CreateStorefront extends Command {
|
|
|
198
222
|
static description = "Create a storefront project";
|
|
199
223
|
static examples = [
|
|
200
224
|
"<%= config.bin %> <%= command.id %>",
|
|
225
|
+
"<%= config.bin %> <%= command.id %> -n my-storefront -V cosmetic",
|
|
201
226
|
"<%= config.bin %> <%= command.id %> -n my-storefront -t https://github.com/org/template -b release-0.2.x",
|
|
202
227
|
"<%= config.bin %> <%= command.id %> -n my-storefront -t /path/to/local/template",
|
|
203
228
|
"<%= config.bin %> <%= command.id %> -l /path/to/monorepo/packages"
|
|
@@ -211,6 +236,11 @@ var CreateStorefront = class CreateStorefront extends Command {
|
|
|
211
236
|
char: "t",
|
|
212
237
|
description: "Template repository to use for the storefront (GitHub URL or local path)"
|
|
213
238
|
}),
|
|
239
|
+
vertical: Flags.string({
|
|
240
|
+
char: "V",
|
|
241
|
+
description: "Vertical template to generate from. Selects the matching published template repository. Ignored if --template is provided.",
|
|
242
|
+
options: ["fashion", "cosmetic"]
|
|
243
|
+
}),
|
|
214
244
|
"template-branch": Flags.string({
|
|
215
245
|
char: "b",
|
|
216
246
|
description: "Branch or tag to clone from the template repository"
|
|
@@ -234,6 +264,7 @@ var CreateStorefront = class CreateStorefront extends Command {
|
|
|
234
264
|
await createStorefront({
|
|
235
265
|
name: flags.name,
|
|
236
266
|
template: flags.template,
|
|
267
|
+
vertical: flags.vertical,
|
|
237
268
|
templateBranch: flags["template-branch"],
|
|
238
269
|
localPackagesDir: flags["local-packages-dir"],
|
|
239
270
|
defaults: flags.defaults,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { t as logger } from "../../logger.js";
|
|
2
2
|
import "../../logger2.js";
|
|
3
3
|
import { r as commonFlags } from "../../flags.js";
|
|
4
|
+
import { t as GENERATED_EXTENSION_DIRS } from "../../constants.js";
|
|
4
5
|
import { Command, Flags } from "@oclif/core";
|
|
5
6
|
import { existsSync } from "node:fs";
|
|
6
7
|
import { join } from "node:path";
|
|
@@ -54,7 +55,7 @@ async function discoverLocales(dirs) {
|
|
|
54
55
|
try {
|
|
55
56
|
const extensions = await readdir(EXTENSIONS_DIR, { withFileTypes: true });
|
|
56
57
|
for (const extension of extensions) {
|
|
57
|
-
if (!extension.isDirectory() || extension.name ===
|
|
58
|
+
if (!extension.isDirectory() || extension.name === GENERATED_EXTENSION_DIRS.locales || extension.name === GENERATED_EXTENSION_DIRS.config) continue;
|
|
58
59
|
const localesPath = join(EXTENSIONS_DIR, extension.name, "locales");
|
|
59
60
|
if (!existsSync(localesPath)) continue;
|
|
60
61
|
const localeEntries = await readdir(localesPath, { withFileTypes: true });
|
|
@@ -71,7 +72,7 @@ async function findExtensionsWithLocale(locale, extensionsDir) {
|
|
|
71
72
|
try {
|
|
72
73
|
const extensionEntries = await readdir(extensionsDir, { withFileTypes: true });
|
|
73
74
|
for (const entry of extensionEntries) {
|
|
74
|
-
if (!entry.isDirectory() || entry.name ===
|
|
75
|
+
if (!entry.isDirectory() || entry.name === GENERATED_EXTENSION_DIRS.locales || entry.name === GENERATED_EXTENSION_DIRS.config) continue;
|
|
75
76
|
if (existsSync(join(extensionsDir, entry.name, "locales", locale, "translations.json"))) extensions.push({
|
|
76
77
|
name: entry.name,
|
|
77
78
|
path: `@/extensions/${entry.name}/locales/${locale}/translations.json`
|
package/dist/commands/push.js
CHANGED
|
@@ -92,6 +92,8 @@ var Push = class Push extends MrtCommand {
|
|
|
92
92
|
this.log(`Uploading bundle to ${origin}`);
|
|
93
93
|
const result = await uploadBundle(client, projectSlug, bundle, target);
|
|
94
94
|
this.log(`Bundle ${result.bundleId} uploaded`);
|
|
95
|
+
const warnings = result.warnings ?? [];
|
|
96
|
+
for (const w of warnings) this.warn(w);
|
|
95
97
|
if (flags.wait && target) {
|
|
96
98
|
this.log(`Waiting for deployment to ${target}...`);
|
|
97
99
|
let lastState = "";
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
//#region src/extensibility/constants.ts
|
|
2
|
+
/**
|
|
3
|
+
* Copyright 2026 Salesforce, Inc.
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* Names of the generated barrel directories under `src/extensions/`. Each aggregator writes
|
|
19
|
+
* into one (`locales/`, `config/`) and both must skip both during discovery, so neither
|
|
20
|
+
* treats the other's output — or its own — as an extension. Shared here so the skip lists in
|
|
21
|
+
* the locale and config aggregators can't drift apart.
|
|
22
|
+
*/
|
|
23
|
+
const GENERATED_EXTENSION_DIRS = {
|
|
24
|
+
locales: "locales",
|
|
25
|
+
config: "config"
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
//#endregion
|
|
29
|
+
export { GENERATED_EXTENSION_DIRS as t };
|
package/dist/dependency-utils.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { t as logger } from "./logger.js";
|
|
2
2
|
import path from "path";
|
|
3
|
+
import { createRequire } from "module";
|
|
3
4
|
import fs from "fs";
|
|
4
5
|
|
|
5
6
|
//#region src/extensibility/path-util.ts
|
|
@@ -18,7 +19,7 @@ const SINGLE_LINE_MARKER = "@sfdc-extension-line";
|
|
|
18
19
|
const BLOCK_MARKER_START = "@sfdc-extension-block-start";
|
|
19
20
|
const BLOCK_MARKER_END = "@sfdc-extension-block-end";
|
|
20
21
|
const FILE_MARKER = "@sfdc-extension-file";
|
|
21
|
-
function trimExtensions(directory, selectedExtensions, extensionConfig) {
|
|
22
|
+
async function trimExtensions(directory, selectedExtensions, extensionConfig) {
|
|
22
23
|
const startTime = Date.now();
|
|
23
24
|
const configuredExtensions = extensionConfig?.extensions || {};
|
|
24
25
|
const extensions = {};
|
|
@@ -41,8 +42,8 @@ function trimExtensions(directory, selectedExtensions, extensionConfig) {
|
|
|
41
42
|
};
|
|
42
43
|
processDirectory(directory);
|
|
43
44
|
if (extensionConfig?.extensions) {
|
|
45
|
+
await updateExtensionConfig(directory, extensions);
|
|
44
46
|
deleteExtensionFolders(directory, extensions, extensionConfig);
|
|
45
|
-
updateExtensionConfig(directory, extensions);
|
|
46
47
|
}
|
|
47
48
|
const endTime = Date.now();
|
|
48
49
|
logger.debug(`Trim extensions took ${endTime - startTime}ms`);
|
|
@@ -52,13 +53,64 @@ function trimExtensions(directory, selectedExtensions, extensionConfig) {
|
|
|
52
53
|
* @param projectDirectory - The project directory
|
|
53
54
|
* @param extensionSelections - The selected extensions
|
|
54
55
|
*/
|
|
55
|
-
function updateExtensionConfig(projectDirectory, extensionSelections) {
|
|
56
|
+
async function updateExtensionConfig(projectDirectory, extensionSelections) {
|
|
56
57
|
const extensionConfigPath = path.join(projectDirectory, "src", "extensions", "config.json");
|
|
57
58
|
const extensionConfig = JSON.parse(fs.readFileSync(extensionConfigPath, "utf8"));
|
|
58
59
|
Object.keys(extensionConfig.extensions).forEach((extensionKey) => {
|
|
59
60
|
if (!extensionSelections[extensionKey]) delete extensionConfig.extensions[extensionKey];
|
|
60
61
|
});
|
|
61
|
-
|
|
62
|
+
const json = JSON.stringify({ extensions: extensionConfig.extensions }, null, 4);
|
|
63
|
+
fs.writeFileSync(extensionConfigPath, await formatWithProjectPrettier(json, extensionConfigPath), "utf8");
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Format generated JSON/JS so the written file matches what the project's
|
|
67
|
+
* `prettier --write` / `pnpm lint` would produce.
|
|
68
|
+
*
|
|
69
|
+
* `trimExtensions` runs during `create-storefront`, BEFORE the generated project's
|
|
70
|
+
* `pnpm install` — so the project's own Prettier is usually not on disk yet. We therefore
|
|
71
|
+
* prefer the consumer's Prettier when it happens to be present (re-runs, manage-extensions
|
|
72
|
+
* after install), but fall back to the SDK-bundled `prettier` (a hard dependency of this
|
|
73
|
+
* package, pinned to the template's version) so formatting is deterministic and available
|
|
74
|
+
* at generate time. Relying on the consumer's copy alone resolved only by accident in the
|
|
75
|
+
* monorepo harness, where the generated dir is nested under the monorepo and `createRequire`
|
|
76
|
+
* walks up to the monorepo's Prettier; a customer in a clean directory would get unformatted
|
|
77
|
+
* output and fail lint on first run (W-23074938).
|
|
78
|
+
*
|
|
79
|
+
* NOTE: mirror of the helper in
|
|
80
|
+
* packages/template-retail-rsc-app/scripts/generate-eslint-config.js — kept separate because
|
|
81
|
+
* the two live in different packages/module systems. Keep the parser/config-resolution
|
|
82
|
+
* behavior in sync; the fallback chains INTENTIONALLY differ — this copy has a two-level
|
|
83
|
+
* fallback (consumer Prettier → SDK-bundled `import('prettier')` → unformatted) because it
|
|
84
|
+
* runs pre-install, while the generator copy has a single fallback (consumer Prettier →
|
|
85
|
+
* unformatted). Don't "fix" that asymmetry or you reintroduce the pre-install bug (W-23074938).
|
|
86
|
+
*
|
|
87
|
+
* @param content - The serialized file content to format.
|
|
88
|
+
* @param filePath - The file's path (drives parser selection + config resolution).
|
|
89
|
+
* @returns The Prettier-formatted content. Returns the content unchanged only if no Prettier
|
|
90
|
+
* can be resolved at all; a genuine format/config error throws rather than silently shipping
|
|
91
|
+
* unformatted output.
|
|
92
|
+
*/
|
|
93
|
+
async function formatWithProjectPrettier(content, filePath) {
|
|
94
|
+
let prettier;
|
|
95
|
+
try {
|
|
96
|
+
prettier = createRequire(filePath)("prettier");
|
|
97
|
+
} catch {
|
|
98
|
+
try {
|
|
99
|
+
prettier = (await import("prettier")).default;
|
|
100
|
+
} catch {
|
|
101
|
+
logger.warn("⚠️ Prettier could not be resolved; extension config.json will be written unformatted.");
|
|
102
|
+
return content;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
try {
|
|
106
|
+
const config = await prettier.resolveConfig(filePath, { editorconfig: true });
|
|
107
|
+
return await prettier.format(content, {
|
|
108
|
+
...config,
|
|
109
|
+
filepath: filePath
|
|
110
|
+
});
|
|
111
|
+
} catch (error) {
|
|
112
|
+
throw new Error(`Prettier formatting failed for ${path.basename(filePath)}: ${error.message}`);
|
|
113
|
+
}
|
|
62
114
|
}
|
|
63
115
|
/**
|
|
64
116
|
* Process a file to trim extension-specific code based on markers.
|
package/dist/entry/server.d.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { ServerEntryModule } from "react-router";
|
|
|
7
7
|
*
|
|
8
8
|
* - Spreads all app module properties to forward unknown/future exports
|
|
9
9
|
* - Wraps the default handler for platform-level processing
|
|
10
|
-
* - Prepends a platform instrumentation to
|
|
10
|
+
* - Prepends a platform instrumentation to instrumentations
|
|
11
11
|
*/
|
|
12
12
|
declare function composeServerEntry(appModule: ServerEntryModule): ServerEntryModule;
|
|
13
13
|
//#endregion
|