@adobe-commerce/elsie 1.9.0-beta.1 → 1.9.0-beta.3

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,17 @@
1
1
  # @adobe-commerce/elsie
2
2
 
3
+ ## 1.9.0-beta.3
4
+
5
+ ### Patch Changes
6
+
7
+ - 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.
8
+
9
+ ## 1.9.0-beta.2
10
+
11
+ ### Patch Changes
12
+
13
+ - 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. Boilerplate GraphQL overrides work correctly in all dropin barrel structures.
14
+
3
15
  ## 1.9.0-beta.1
4
16
 
5
17
  ### Minor Changes
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,245 @@ 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
+ */
109
+ function isReachableFrom(targetPath, id, getModuleInfo, visited = new Set()) {
110
+ if (visited.has(id)) return false;
111
+ visited.add(id);
112
+ const info = getModuleInfo(id);
113
+ return info?.importers?.some(
114
+ (imp) => imp === targetPath || isReachableFrom(targetPath, imp, getModuleInfo, visited)
115
+ );
116
+ }
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
+
96
335
  export default {
97
336
  preview: {
98
337
  host: true,
@@ -128,16 +367,24 @@ export default {
128
367
  entryFileNames: '[name].js',
129
368
  assetFileNames: '[name].[ext]',
130
369
  chunkFileNames: 'chunks/[name].js',
131
- manualChunks: (id) => {
132
- // Fragments file does not accept chunking
133
- if (id.includes(paths.fragments)) return 'no-chunk';
370
+ manualChunks: (id, { getModuleInfo }) => {
371
+ // Fragments barrel entry does not accept chunking
372
+ if (paths.fragments && id.includes(paths.fragments)) return 'no-chunk';
134
373
 
135
374
  // API functions → chunks/api.js
136
- if (id.includes(paths.api)) return 'api';
375
+ // All modules under paths.api go to the api chunk, except those imported
376
+ // by the fragments barrel — those must stay in the fragments output so the
377
+ // boilerplate GraphQL override mechanism can replace them independently.
378
+ if (paths.api && id.includes(paths.api)) {
379
+ if (paths.fragments && isReachableFrom(paths.fragments, id, getModuleInfo)) {
380
+ return undefined;
381
+ }
382
+ return 'api';
383
+ }
137
384
 
138
385
  // components → chunks/components.js
139
386
  if (paths.components?.some((component) => id.includes(component))) return 'components';
140
-
387
+
141
388
  return undefined;
142
389
  },
143
390
 
@@ -204,6 +451,9 @@ export default {
204
451
  plugins: [
205
452
  banner(`Copyright ${currentYear} Adobe\nAll Rights Reserved.`),
206
453
 
454
+ // Must come before tsconfigPaths — see fragmentImportRedirectPlugin for why.
455
+ paths.fragments ? fragmentImportRedirectPlugin(paths, elsieConfig) : null,
456
+
207
457
  tsconfigPaths(),
208
458
 
209
459
  cssInjectedByJsPlugin({
@@ -303,82 +553,9 @@ export default {
303
553
  },
304
554
  }),
305
555
 
306
- {
307
- name: 'rewrite-sourcemap-sources',
308
- generateBundle(options, bundle) {
309
- for (const fileName in bundle) {
310
- const chunk = bundle[fileName];
311
-
312
- // Process both .map files and JS/TS files with sourcemaps
313
- if (
314
- (chunk.type === 'asset' && fileName.endsWith('.map')) ||
315
- (chunk.type === 'chunk' && chunk.map)
316
- ) {
317
- try {
318
- // Get the sourcemap object - either from the asset source or the chunk's map
319
- const map =
320
- chunk.type === 'asset' ? JSON.parse(chunk.source) : chunk.map;
321
-
322
- if (map.sources) {
323
- map.sources = map.sources.map((input) => {
324
- return input.replace(
325
- /(?:\.\.?\/)+src\//,
326
- `/${packageJSON.name}/src/`
327
- );
328
- });
329
-
330
- // Update the sourcemap in the appropriate place
331
- if (chunk.type === 'asset') {
332
- chunk.source = JSON.stringify(map);
333
- } else {
334
- chunk.map = map;
335
- }
336
- }
337
- } catch (e) {
338
- console.error('Error transforming sourcemap:', e);
339
- }
340
- }
341
- }
342
- },
343
- },
344
-
345
- {
346
- name: 'generate-dist-package-json',
347
- generateBundle() {
348
- this.emitFile({
349
- type: 'asset',
350
- fileName: 'package.json',
351
- source: JSON.stringify(
352
- {
353
- name: packageJSON.name,
354
- version: packageJSON.version,
355
- license: packageJSON.license,
356
- },
357
- null,
358
- 2
359
- ),
360
- });
361
-
362
- const licensePath = path.resolve(process.cwd(), 'LICENSE.md');
363
- if (!fs.existsSync(licensePath)) {
364
- this.error(`LICENSE.md not found at ${licensePath}`);
365
- }
366
- this.emitFile({
367
- type: 'asset',
368
- fileName: 'LICENSE.md',
369
- source: fs.readFileSync(licensePath, 'utf-8'),
370
- });
556
+ rewriteSourcemapSourcesPlugin(packageJSON),
371
557
 
372
- const changelogPath = path.resolve(process.cwd(), 'CHANGELOG.md');
373
- if (fs.existsSync(changelogPath)) {
374
- this.emitFile({
375
- type: 'asset',
376
- fileName: 'CHANGELOG.md',
377
- source: fs.readFileSync(changelogPath, 'utf-8'),
378
- });
379
- }
380
- },
381
- },
558
+ generateDistPackageJsonPlugin(packageJSON),
382
559
 
383
560
  process.env.ANALYZE
384
561
  ? visualizer({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adobe-commerce/elsie",
3
- "version": "1.9.0-beta.1",
3
+ "version": "1.9.0-beta.3",
4
4
  "license": "SEE LICENSE IN LICENSE.md",
5
5
  "description": "Domain Package SDK",
6
6
  "engines": {