@adobe-commerce/elsie 1.9.0-beta.2 → 1.9.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/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
1
1
  # @adobe-commerce/elsie
2
2
 
3
+ ## 1.9.0
4
+
5
+ ### Minor Changes
6
+
7
+ - af62897: Update minimum Node.js requirement to 22 LTS
8
+
9
+ Packages are now built with Node.js 22. `elsie` requires `>=22`; browser-only packages (`fetch-graphql`, `event-bus`, `recaptcha`, `storefront-design`, `build-tools`) do not declare an `engines` field as they do not run in Node.js.
10
+
11
+ - 62adf1c: Reduce HTTP requests on page load through three bundling optimizations. The preact runtime is isolated in its own vendor chunk so it no longer co-locates into other chunks. Dropin API and internal component modules are consolidated into `chunks/api.js` and `chunks/components.js` respectively, replacing the previous pattern of one chunk file per function or component. All SVG icons are consolidated into a single `chunks/icons.js` chunk instead of one chunk per icon.
12
+
13
+ Drop-ins must be rebuilt against this release to get the reduced request footprint. No source changes are required.
14
+
15
+ ### Patch Changes
16
+
17
+ - d2aacc7: Fix: GraphQL fragment source files are no longer incorrectly bundled into `chunks/api.js`. The `manualChunks` function now walks the full importer graph (with cycle protection) to determine whether an api-directory module is owned by the fragments barrel, so fragment files stay in the fragments output chunk even when accessed through intermediate sub-barrels.
18
+ - 5c64620: Implement a new `fragment-import-redirect` build plugin that automatically detects and redirects any dropin source file that directly imports a fragment source file (bypassing the barrel). The import is silently redirected to the fragments barrel at build time and a warning is emitted identifying the file so it can be corrected in source. This ensures fragment constants always appear as local declarations in `fragments.js` regardless of how dropin source code references them.
19
+
20
+ ## 1.9.0-beta.3
21
+
22
+ ### Patch Changes
23
+
24
+ - 5c64620: Implement a new `fragment-import-redirect` build plugin that automatically detects and redirects any dropin source file that directly imports a fragment source file (bypassing the barrel). The import is silently redirected to the fragments barrel at build time and a warning is emitted identifying the file so it can be corrected in source. This ensures fragment constants always appear as local declarations in `fragments.js` regardless of how dropin source code references them.
25
+
3
26
  ## 1.9.0-beta.2
4
27
 
5
28
  ### Patch Changes
@@ -18,10 +41,6 @@
18
41
 
19
42
  ### Minor Changes
20
43
 
21
- - f55a79c: Migrate to Node.js 24 LTS
22
-
23
- Minimum required Node.js version is now 24. Updated `engines.node` from `>=16`/`>=18` to `>=24` across all packages. Regenerated lockfile under Node 24. Updated CI workflows to use `storefront-workflows@v6` with Node 24 support.
24
-
25
44
  - 62adf1c: Reduce HTTP requests on page load through three bundling optimizations. The preact runtime is isolated in its own vendor chunk so it no longer co-locates into other chunks. Dropin API and internal component modules are consolidated into `chunks/api.js` and `chunks/components.js` respectively, replacing the previous pattern of one chunk file per function or component. All SVG icons are consolidated into a single `chunks/icons.js` chunk instead of one chunk per icon.
26
45
 
27
46
  Drop-ins must be rebuilt against this release to get the reduced request footprint. No source changes are required.
package/config/vite.mjs CHANGED
@@ -55,7 +55,7 @@ const paths = {
55
55
  )
56
56
  : undefined,
57
57
 
58
- components: elsieConfig.components?.map((component) =>
58
+ components: elsieConfig.components?.map((component) =>
59
59
  path.resolve(process.cwd(), component.root)
60
60
  ),
61
61
  };
@@ -93,6 +93,19 @@ if (paths.fragments) {
93
93
  input.fragments = path.resolve(paths.fragments);
94
94
  }
95
95
 
96
+ /**
97
+ * Recursively walks the Rollup importer graph upward to determine whether
98
+ * `id` is reachable from `targetPath` at any depth.
99
+ *
100
+ * Used by `manualChunks` to exclude fragment source files from the api chunk:
101
+ * any module that is (transitively) imported by the fragments barrel must stay
102
+ * in the fragments output so the gql-extend override mechanism can replace it
103
+ * independently of api.js.
104
+ *
105
+ * A `visited` Set is threaded through each recursive call to short-circuit
106
+ * cycles in the module graph. It is created fresh per `manualChunks` invocation
107
+ * so no state leaks between module evaluations.
108
+ */
96
109
  function isReachableFrom(targetPath, id, getModuleInfo, visited = new Set()) {
97
110
  if (visited.has(id)) return false;
98
111
  visited.add(id);
@@ -102,6 +115,223 @@ function isReachableFrom(targetPath, id, getModuleInfo, visited = new Set()) {
102
115
  );
103
116
  }
104
117
 
118
+ /**
119
+ * Vite plugin that enforces the fragments barrel import contract at build time.
120
+ *
121
+ * Fragment source files (those re-exported by the fragments barrel) must be
122
+ * imported exclusively through the barrel. This guarantees Rollup emits their
123
+ * GraphQL string constants as local `const` declarations inside `fragments.js`.
124
+ * The gql-extend tool reads and rewrites those declarations at boilerplate build
125
+ * time; if the constants end up in `api.js` instead, the override mechanism
126
+ * silently produces incorrect output.
127
+ *
128
+ * When a dropin source file imports a fragment file directly (bypassing the
129
+ * barrel), this plugin:
130
+ * 1. Transparently redirects the import to the barrel at resolve time, so the
131
+ * build output is always correct regardless of dropin source conventions.
132
+ * 2. Emits a build warning per offending file listing every direct importer,
133
+ * so project maintainers know exactly which imports to correct in source.
134
+ *
135
+ * IMPORTANT — placement: this plugin must be listed BEFORE tsconfigPaths() in
136
+ * the plugins array. Both use `enforce: 'pre'`; within that group Vite respects
137
+ * array order. Being first ensures our resolveId hook intercepts the alias-form
138
+ * import before tsconfigPaths claims it — once tsconfigPaths resolves the alias
139
+ * and returns, our hook is never reached.
140
+ */
141
+ function fragmentImportRedirectPlugin(paths, elsieConfig) {
142
+ // Per-build state. Both are cleared in buildStart so watch-mode rebuilds
143
+ // always start from a clean slate.
144
+ const fragmentSourceFiles = new Set();
145
+ const fragmentViolations = new Map(); // fragment file → [direct importers]
146
+
147
+ // Pre-compute alias info once so buildStart and buildEnd share the same
148
+ // values without re-reading elsieConfig and without unsafe property access
149
+ // inside hook callbacks (where elsieConfig.api could be undefined).
150
+ const apiAliasRoot = elsieConfig.api?.importAliasRoot;
151
+ const apiRoot = path.resolve(process.cwd(), elsieConfig.api?.root ?? 'src/api');
152
+ // Alias specifier for the barrel itself, e.g. '@/cart/api/fragments'
153
+ const barrelAlias = apiAliasRoot
154
+ ? `${apiAliasRoot}/${path.basename(paths.fragments, path.extname(paths.fragments))}`
155
+ : path.relative(process.cwd(), paths.fragments);
156
+
157
+ return {
158
+ name: 'fragment-import-redirect',
159
+ enforce: 'pre',
160
+
161
+ buildStart() {
162
+ fragmentSourceFiles.clear();
163
+ fragmentViolations.clear();
164
+
165
+ // Read the barrel and resolve each re-exported specifier to an absolute
166
+ // path. The resulting set is what resolveId checks against.
167
+ const barrelContent = fs.readFileSync(paths.fragments, 'utf-8');
168
+ const barrelDir = path.dirname(paths.fragments);
169
+ const importRegex = /from\s+['"]([^'"]+)['"]/g;
170
+ let match;
171
+
172
+ while ((match = importRegex.exec(barrelContent)) !== null) {
173
+ let specifier = match[1];
174
+
175
+ // Resolve alias-prefixed specifiers (e.g. '@/cart/api/graphql/CartFragment')
176
+ // using the dropin's importAliasRoot config before probing the filesystem.
177
+ if (apiAliasRoot && specifier.startsWith(`${apiAliasRoot}/`)) {
178
+ specifier = path.join(apiRoot, specifier.slice(apiAliasRoot.length + 1));
179
+ } else if (specifier.startsWith('.')) {
180
+ specifier = path.resolve(barrelDir, specifier);
181
+ } else {
182
+ continue; // skip bare specifiers (node_modules re-exports)
183
+ }
184
+
185
+ // Barrel specifiers rarely include file extensions; probe common ones.
186
+ for (const ext of ['', '.ts', '.tsx', '.js']) {
187
+ const candidate = specifier + ext;
188
+ if (fs.existsSync(candidate)) {
189
+ fragmentSourceFiles.add(candidate);
190
+ break;
191
+ }
192
+ }
193
+ }
194
+ },
195
+
196
+ async resolveId(source, importer) {
197
+ if (!importer || fragmentSourceFiles.size === 0) return null;
198
+ // Allow the barrel to import fragment files (its own re-exports) and
199
+ // allow fragment files to import each other (shared helper fragments).
200
+ if (importer === paths.fragments || fragmentSourceFiles.has(importer)) return null;
201
+
202
+ // Delegate to subsequent plugins (including tsconfigPaths) so alias
203
+ // specifiers are fully resolved to an absolute path before we check.
204
+ // skipSelf prevents infinite recursion back into this hook.
205
+ const resolved = await this.resolve(source, importer, { skipSelf: true });
206
+ if (!resolved || !fragmentSourceFiles.has(resolved.id)) return null;
207
+
208
+ // Track the violation for the buildEnd warning.
209
+ if (!fragmentViolations.has(resolved.id)) fragmentViolations.set(resolved.id, []);
210
+ fragmentViolations.get(resolved.id).push(importer);
211
+
212
+ // Redirect to the barrel. Rollup treats the barrel as the resolved module,
213
+ // so the fragment constants become local declarations in fragments.js.
214
+ return { id: paths.fragments };
215
+ },
216
+
217
+ buildEnd() {
218
+ for (const [fragmentFile, importers] of fragmentViolations) {
219
+ // Derive the wrong specifier from the actual fragment file path so the
220
+ // suggestion shows the real import, not a generic placeholder.
221
+ const relFragment = path.relative(apiRoot, fragmentFile).replace(/\.(ts|tsx|js)$/, '');
222
+ const wrongSpecifier = apiAliasRoot ? `${apiAliasRoot}/${relFragment}` : relFragment;
223
+
224
+ this.warn(
225
+ `\n"${path.relative(process.cwd(), fragmentFile)}" belongs to the fragments barrel ` +
226
+ `but was directly imported by:\n${
227
+ importers.map((imp) => ` - ${path.relative(process.cwd(), imp)}`).join('\n')
228
+ }\n\nThe import was automatically redirected through the fragments barrel. ` +
229
+ `\nUpdate the import in source to silence this warning: '${wrongSpecifier}' → '${barrelAlias}'\n`
230
+ );
231
+ }
232
+ },
233
+ };
234
+ }
235
+
236
+ /**
237
+ * Vite plugin that rewrites relative `../src/` paths in generated sourcemaps
238
+ * to package-qualified paths (e.g. `../../src/foo.ts` → `/@scope/pkg/src/foo.ts`).
239
+ *
240
+ * Predictable, package-namespaced paths let browser devtools and error-tracking
241
+ * services (Sentry, Datadog, etc.) resolve source files back to the correct
242
+ * package across CDN-delivered builds where multiple dropin sourcemaps are
243
+ * loaded in the same session.
244
+ */
245
+ function rewriteSourcemapSourcesPlugin(packageJSON) {
246
+ return {
247
+ name: 'rewrite-sourcemap-sources',
248
+ generateBundle(options, bundle) {
249
+ for (const fileName in bundle) {
250
+ const chunk = bundle[fileName];
251
+
252
+ // Process both standalone .map asset files and inline chunk maps.
253
+ if (
254
+ (chunk.type === 'asset' && fileName.endsWith('.map')) ||
255
+ (chunk.type === 'chunk' && chunk.map)
256
+ ) {
257
+ try {
258
+ const map =
259
+ chunk.type === 'asset' ? JSON.parse(chunk.source) : chunk.map;
260
+
261
+ if (map.sources) {
262
+ map.sources = map.sources.map((input) => {
263
+ // Replace any leading `../src/` or `../../src/` traversal with
264
+ // the package name so the path is stable and globally unique.
265
+ return input.replace(
266
+ /(?:\.\.?\/)+src\//,
267
+ `/${packageJSON.name}/src/`
268
+ );
269
+ });
270
+
271
+ if (chunk.type === 'asset') {
272
+ chunk.source = JSON.stringify(map);
273
+ } else {
274
+ chunk.map = map;
275
+ }
276
+ }
277
+ } catch (e) {
278
+ console.error('Error transforming sourcemap:', e);
279
+ }
280
+ }
281
+ }
282
+ },
283
+ };
284
+ }
285
+
286
+ /**
287
+ * Vite plugin that emits a minimal `package.json`, `LICENSE.md`, and
288
+ * `CHANGELOG.md` into the `dist/` directory alongside the compiled output.
289
+ *
290
+ * The minimal package.json carries only `name`, `version`, and `license` so
291
+ * CDN consumers and package tooling can identify a build artifact without
292
+ * pulling in the full development-time manifest (devDependencies, scripts, etc.).
293
+ * LICENSE.md is required; the build fails early if it is missing.
294
+ */
295
+ function generateDistPackageJsonPlugin(packageJSON) {
296
+ return {
297
+ name: 'generate-dist-package-json',
298
+ generateBundle() {
299
+ this.emitFile({
300
+ type: 'asset',
301
+ fileName: 'package.json',
302
+ source: JSON.stringify(
303
+ {
304
+ name: packageJSON.name,
305
+ version: packageJSON.version,
306
+ license: packageJSON.license,
307
+ },
308
+ null,
309
+ 2
310
+ ),
311
+ });
312
+
313
+ const licensePath = path.resolve(process.cwd(), 'LICENSE.md');
314
+ if (!fs.existsSync(licensePath)) {
315
+ this.error(`LICENSE.md not found at ${licensePath}`);
316
+ }
317
+ this.emitFile({
318
+ type: 'asset',
319
+ fileName: 'LICENSE.md',
320
+ source: fs.readFileSync(licensePath, 'utf-8'),
321
+ });
322
+
323
+ const changelogPath = path.resolve(process.cwd(), 'CHANGELOG.md');
324
+ if (fs.existsSync(changelogPath)) {
325
+ this.emitFile({
326
+ type: 'asset',
327
+ fileName: 'CHANGELOG.md',
328
+ source: fs.readFileSync(changelogPath, 'utf-8'),
329
+ });
330
+ }
331
+ },
332
+ };
333
+ }
334
+
105
335
  export default {
106
336
  preview: {
107
337
  host: true,
@@ -221,6 +451,9 @@ export default {
221
451
  plugins: [
222
452
  banner(`Copyright ${currentYear} Adobe\nAll Rights Reserved.`),
223
453
 
454
+ // Must come before tsconfigPaths — see fragmentImportRedirectPlugin for why.
455
+ paths.fragments ? fragmentImportRedirectPlugin(paths, elsieConfig) : null,
456
+
224
457
  tsconfigPaths(),
225
458
 
226
459
  cssInjectedByJsPlugin({
@@ -320,82 +553,9 @@ export default {
320
553
  },
321
554
  }),
322
555
 
323
- {
324
- name: 'rewrite-sourcemap-sources',
325
- generateBundle(options, bundle) {
326
- for (const fileName in bundle) {
327
- const chunk = bundle[fileName];
328
-
329
- // Process both .map files and JS/TS files with sourcemaps
330
- if (
331
- (chunk.type === 'asset' && fileName.endsWith('.map')) ||
332
- (chunk.type === 'chunk' && chunk.map)
333
- ) {
334
- try {
335
- // Get the sourcemap object - either from the asset source or the chunk's map
336
- const map =
337
- chunk.type === 'asset' ? JSON.parse(chunk.source) : chunk.map;
338
-
339
- if (map.sources) {
340
- map.sources = map.sources.map((input) => {
341
- return input.replace(
342
- /(?:\.\.?\/)+src\//,
343
- `/${packageJSON.name}/src/`
344
- );
345
- });
346
-
347
- // Update the sourcemap in the appropriate place
348
- if (chunk.type === 'asset') {
349
- chunk.source = JSON.stringify(map);
350
- } else {
351
- chunk.map = map;
352
- }
353
- }
354
- } catch (e) {
355
- console.error('Error transforming sourcemap:', e);
356
- }
357
- }
358
- }
359
- },
360
- },
361
-
362
- {
363
- name: 'generate-dist-package-json',
364
- generateBundle() {
365
- this.emitFile({
366
- type: 'asset',
367
- fileName: 'package.json',
368
- source: JSON.stringify(
369
- {
370
- name: packageJSON.name,
371
- version: packageJSON.version,
372
- license: packageJSON.license,
373
- },
374
- null,
375
- 2
376
- ),
377
- });
556
+ rewriteSourcemapSourcesPlugin(packageJSON),
378
557
 
379
- const licensePath = path.resolve(process.cwd(), 'LICENSE.md');
380
- if (!fs.existsSync(licensePath)) {
381
- this.error(`LICENSE.md not found at ${licensePath}`);
382
- }
383
- this.emitFile({
384
- type: 'asset',
385
- fileName: 'LICENSE.md',
386
- source: fs.readFileSync(licensePath, 'utf-8'),
387
- });
388
-
389
- const changelogPath = path.resolve(process.cwd(), 'CHANGELOG.md');
390
- if (fs.existsSync(changelogPath)) {
391
- this.emitFile({
392
- type: 'asset',
393
- fileName: 'CHANGELOG.md',
394
- source: fs.readFileSync(changelogPath, 'utf-8'),
395
- });
396
- }
397
- },
398
- },
558
+ generateDistPackageJsonPlugin(packageJSON),
399
559
 
400
560
  process.env.ANALYZE
401
561
  ? visualizer({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adobe-commerce/elsie",
3
- "version": "1.9.0-beta.2",
3
+ "version": "1.9.0",
4
4
  "license": "SEE LICENSE IN LICENSE.md",
5
5
  "description": "Domain Package SDK",
6
6
  "engines": {
@@ -28,11 +28,11 @@
28
28
  "postpublish": "node ./scripts/publish-tools.mjs"
29
29
  },
30
30
  "devDependencies": {
31
- "@adobe-commerce/event-bus": "~1.1.0-beta.1",
32
- "@adobe-commerce/fetch-graphql": "~1.3.0-beta.1",
33
- "@adobe-commerce/recaptcha": "1.2.0-beta.1",
34
- "@adobe-commerce/storefront-design": "~1.1.0-beta.1",
35
- "@dropins/build-tools": "~1.2.0-beta.1",
31
+ "@adobe-commerce/event-bus": "~1.1.0",
32
+ "@adobe-commerce/fetch-graphql": "~1.3.0",
33
+ "@adobe-commerce/recaptcha": "1.2.0",
34
+ "@adobe-commerce/storefront-design": "~1.1.0",
35
+ "@dropins/build-tools": "~1.2.0",
36
36
  "preact": "~10.22.1",
37
37
  "vite-plugin-banner": "^0.8.0"
38
38
  },