@pvh-afl/core 1.1.19 → 1.1.21

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,4 +1,5 @@
1
1
  "use strict";
2
+ var ThemeContentService_1;
2
3
  Object.defineProperty(exports, "__esModule", { value: true });
3
4
  exports.ThemeContentService = void 0;
4
5
  const tslib_1 = require("tslib");
@@ -42,11 +43,15 @@ const FAQ_METAOBJECT_QUERY = `
42
43
  }
43
44
  `;
44
45
  let ThemeContentService = class ThemeContentService {
46
+ static { ThemeContentService_1 = this; }
45
47
  cacheService;
46
48
  configService;
47
49
  shopifyThemeService;
48
50
  collectionService;
49
51
  productService;
52
+ // Number of filenames to resolve per Files API request when matching
53
+ // `shopify://` media references by filename.
54
+ static MEDIA_QUERY_BATCH_SIZE = 25;
50
55
  refreshInProgress = new Set();
51
56
  constructor(cacheService, configService, shopifyThemeService, collectionService, productService) {
52
57
  this.cacheService = cacheService;
@@ -77,7 +82,7 @@ let ThemeContentService = class ThemeContentService {
77
82
  let isStale = false;
78
83
  let triggeredRefresh = false;
79
84
  if (!cached) {
80
- logger_1.logger.info('Theme content cache miss, fetching fresh data', {
85
+ logger_1.logger.info("Theme content cache miss, fetching fresh data", {
81
86
  brand,
82
87
  path,
83
88
  cacheKey,
@@ -88,7 +93,7 @@ let ThemeContentService = class ThemeContentService {
88
93
  themeStructure = cached.data;
89
94
  isStale = cached.isStale;
90
95
  if (cached.isStale) {
91
- logger_1.logger.info('Theme content is stale, triggering background refresh', {
96
+ logger_1.logger.info("Theme content is stale, triggering background refresh", {
92
97
  brand,
93
98
  path,
94
99
  cacheKey,
@@ -114,7 +119,7 @@ let ThemeContentService = class ThemeContentService {
114
119
  */
115
120
  async refreshContentInBackground(brand, path, cacheKey) {
116
121
  if (this.refreshInProgress.has(cacheKey)) {
117
- logger_1.logger.info('Refresh already in progress, skipping', { cacheKey });
122
+ logger_1.logger.info("Refresh already in progress, skipping", { cacheKey });
118
123
  return;
119
124
  }
120
125
  this.refreshInProgress.add(cacheKey);
@@ -130,7 +135,7 @@ let ThemeContentService = class ThemeContentService {
130
135
  */
131
136
  async fetchAndCacheContent(brand, path, options) {
132
137
  const cacheKey = this.buildCacheKey(brand, path);
133
- logger_1.logger.info('Fetching fresh theme content', {
138
+ logger_1.logger.info("Fetching fresh theme content", {
134
139
  brand,
135
140
  path,
136
141
  cacheKey,
@@ -138,7 +143,7 @@ let ThemeContentService = class ThemeContentService {
138
143
  try {
139
144
  const parsed = await this.shopifyThemeService.fetchThemeFile(brand, path);
140
145
  if (!parsed) {
141
- logger_1.logger.warn('No content found for theme file', { brand, path });
146
+ logger_1.logger.warn("No content found for theme file", { brand, path });
142
147
  return null;
143
148
  }
144
149
  let curated = await this.curateThemeContent(brand, parsed);
@@ -147,14 +152,14 @@ let ThemeContentService = class ThemeContentService {
147
152
  const defaultTTL = options?.defaultTTL ?? configDefaultTTL;
148
153
  const staleTTL = options?.staleTTL ?? configStaleTTL;
149
154
  await this.cacheService.set(cacheKey, curated, defaultTTL, staleTTL);
150
- logger_1.logger.info('Theme content cached', { brand, cacheKey });
155
+ logger_1.logger.info("Theme content cached", { brand, cacheKey });
151
156
  return curated;
152
157
  }
153
158
  catch (error) {
154
- logger_1.logger.error('Failed to fetch and curate theme content', {
159
+ logger_1.logger.error("Failed to fetch and curate theme content", {
155
160
  brand,
156
161
  path,
157
- error: error instanceof Error ? error.message : 'Unknown',
162
+ error: error instanceof Error ? error.message : "Unknown",
158
163
  });
159
164
  return null;
160
165
  }
@@ -166,21 +171,26 @@ let ThemeContentService = class ThemeContentService {
166
171
  * @param brand - Brand identifier
167
172
  * @param handle - Metaobject handle (default: "main")
168
173
  */
169
- async getFaqMetaobject(brand, handle = 'main') {
174
+ async getFaqMetaobject(brand, handle = "main") {
170
175
  const cacheKey = this.buildFaqCacheKey(brand, handle);
171
176
  const cached = await this.cacheService.get(cacheKey);
172
177
  let faqData = null;
173
178
  let isStale = false;
174
179
  let triggeredRefresh = false;
175
180
  if (!cached) {
176
- logger_1.logger.info('FAQ metaobject cache miss, fetching fresh data', {
181
+ logger_1.logger.info("FAQ metaobject cache miss, fetching fresh data", {
177
182
  brand,
178
183
  handle,
179
184
  cacheKey,
180
185
  });
181
186
  const result = await this.fetchAndCacheFaqMetaobject(brand, handle);
182
187
  if (result.error) {
183
- return { data: null, isStale: false, triggeredRefresh: false, error: result.error };
188
+ return {
189
+ data: null,
190
+ isStale: false,
191
+ triggeredRefresh: false,
192
+ error: result.error,
193
+ };
184
194
  }
185
195
  faqData = result.data;
186
196
  }
@@ -188,7 +198,7 @@ let ThemeContentService = class ThemeContentService {
188
198
  faqData = cached.data;
189
199
  isStale = cached.isStale;
190
200
  if (cached.isStale) {
191
- logger_1.logger.info('FAQ metaobject is stale, triggering background refresh', {
201
+ logger_1.logger.info("FAQ metaobject is stale, triggering background refresh", {
192
202
  brand,
193
203
  handle,
194
204
  cacheKey,
@@ -208,7 +218,7 @@ let ThemeContentService = class ThemeContentService {
208
218
  */
209
219
  async refreshFaqInBackground(brand, handle, cacheKey) {
210
220
  if (this.refreshInProgress.has(cacheKey)) {
211
- logger_1.logger.info('FAQ refresh already in progress, skipping', { cacheKey });
221
+ logger_1.logger.info("FAQ refresh already in progress, skipping", { cacheKey });
212
222
  return;
213
223
  }
214
224
  this.refreshInProgress.add(cacheKey);
@@ -222,33 +232,33 @@ let ThemeContentService = class ThemeContentService {
222
232
  /**
223
233
  * Fetch FAQ metaobject from Storefront API and cache it.
224
234
  */
225
- async fetchAndCacheFaqMetaobject(brand, handle = 'main') {
235
+ async fetchAndCacheFaqMetaobject(brand, handle = "main") {
226
236
  const cacheKey = this.buildFaqCacheKey(brand, handle);
227
237
  const storefrontToken = this.configService.getShopifyStorefrontToken(brand);
228
238
  const graphqlUrl = this.configService.getShopifyGraphqlUrl(brand);
229
239
  if (!storefrontToken || !graphqlUrl) {
230
- logger_1.logger.error('Missing Storefront API configuration for FAQ metaobject', {
240
+ logger_1.logger.error("Missing Storefront API configuration for FAQ metaobject", {
231
241
  brand,
232
242
  hasToken: !!storefrontToken,
233
243
  hasUrl: !!graphqlUrl,
234
244
  });
235
- return { data: null, error: 'Missing Storefront API configuration' };
245
+ return { data: null, error: "Missing Storefront API configuration" };
236
246
  }
237
247
  try {
238
248
  const response = await fetch(graphqlUrl, {
239
- method: 'POST',
249
+ method: "POST",
240
250
  headers: {
241
- 'Content-Type': 'application/json',
242
- 'X-Shopify-Storefront-Access-Token': storefrontToken,
251
+ "Content-Type": "application/json",
252
+ "X-Shopify-Storefront-Access-Token": storefrontToken,
243
253
  },
244
254
  body: JSON.stringify({
245
255
  query: FAQ_METAOBJECT_QUERY,
246
- variables: { type: 'faq_page', handle },
256
+ variables: { type: "faq_page", handle },
247
257
  }),
248
258
  });
249
259
  const result = (await response.json());
250
260
  if (result.errors?.length) {
251
- logger_1.logger.error('Storefront API errors fetching FAQ metaobject', {
261
+ logger_1.logger.error("Storefront API errors fetching FAQ metaobject", {
252
262
  brand,
253
263
  handle,
254
264
  errors: result.errors.map((e) => e.message),
@@ -257,14 +267,14 @@ let ThemeContentService = class ThemeContentService {
257
267
  }
258
268
  const faqPage = result.data?.metaobject;
259
269
  if (!faqPage) {
260
- logger_1.logger.warn('No FAQ page metaobject found', { brand, handle });
261
- return { data: null, error: 'FAQ page not found' };
270
+ logger_1.logger.warn("No FAQ page metaobject found", { brand, handle });
271
+ return { data: null, error: "FAQ page not found" };
262
272
  }
263
273
  const parsed = this.parseFaqMetaobject(faqPage);
264
274
  // Cache with same TTL as landing pages
265
275
  const { defaultTTL, staleTTL } = this.configService.getLandingPageCacheTTL(brand);
266
276
  await this.cacheService.set(cacheKey, parsed, defaultTTL, staleTTL);
267
- logger_1.logger.info('FAQ metaobject fetched and cached', {
277
+ logger_1.logger.info("FAQ metaobject fetched and cached", {
268
278
  brand,
269
279
  handle,
270
280
  cacheKey,
@@ -274,12 +284,15 @@ let ThemeContentService = class ThemeContentService {
274
284
  return { data: parsed };
275
285
  }
276
286
  catch (error) {
277
- logger_1.logger.error('Failed to fetch FAQ metaobject', {
287
+ logger_1.logger.error("Failed to fetch FAQ metaobject", {
278
288
  brand,
279
289
  handle,
280
- error: error instanceof Error ? error.message : 'Unknown',
290
+ error: error instanceof Error ? error.message : "Unknown",
281
291
  });
282
- return { data: null, error: error instanceof Error ? error.message : 'Unknown error' };
292
+ return {
293
+ data: null,
294
+ error: error instanceof Error ? error.message : "Unknown error",
295
+ };
283
296
  }
284
297
  }
285
298
  /**
@@ -292,20 +305,24 @@ let ThemeContentService = class ThemeContentService {
292
305
  async invalidateFaqCache(brand) {
293
306
  const pattern = `faq:${brand}:*`;
294
307
  const count = await this.cacheService.markStale(pattern);
295
- logger_1.logger.info('FAQ cache invalidated', { brand, pattern, keysMarkedStale: count });
308
+ logger_1.logger.info("FAQ cache invalidated", {
309
+ brand,
310
+ pattern,
311
+ keysMarkedStale: count,
312
+ });
296
313
  return count;
297
314
  }
298
315
  /**
299
316
  * Parse FAQ page metaobject response into structured data.
300
317
  */
301
318
  parseFaqMetaobject(faqPage) {
302
- let title = '';
319
+ let title = "";
303
320
  const categories = [];
304
321
  for (const field of faqPage.fields) {
305
- if (field.key === 'title') {
322
+ if (field.key === "title") {
306
323
  title = field.value;
307
324
  }
308
- else if (field.key === 'categories' && field.references?.nodes) {
325
+ else if (field.key === "categories" && field.references?.nodes) {
309
326
  for (const categoryNode of field.references.nodes) {
310
327
  const category = this.parseFaqCategory(categoryNode);
311
328
  if (category) {
@@ -320,13 +337,13 @@ let ThemeContentService = class ThemeContentService {
320
337
  * Parse a single FAQ category from metaobject fields.
321
338
  */
322
339
  parseFaqCategory(categoryNode) {
323
- let categoryTitle = '';
340
+ let categoryTitle = "";
324
341
  const items = [];
325
342
  for (const field of categoryNode.fields) {
326
- if (field.key === 'title') {
343
+ if (field.key === "title") {
327
344
  categoryTitle = field.value;
328
345
  }
329
- else if (field.key === 'items' && field.references?.nodes) {
346
+ else if (field.key === "items" && field.references?.nodes) {
330
347
  for (const itemNode of field.references.nodes) {
331
348
  const item = this.parseFaqItem(itemNode.fields);
332
349
  if (item) {
@@ -345,13 +362,13 @@ let ThemeContentService = class ThemeContentService {
345
362
  * Converts rich_text_field answer from Portable Text JSON to HTML.
346
363
  */
347
364
  parseFaqItem(fields) {
348
- let question = '';
349
- let answer = '';
365
+ let question = "";
366
+ let answer = "";
350
367
  for (const field of fields) {
351
- if (field.key === 'question') {
368
+ if (field.key === "question") {
352
369
  question = field.value;
353
370
  }
354
- else if (field.key === 'answer') {
371
+ else if (field.key === "answer") {
355
372
  // Rich text field - value is JSON (Portable Text format)
356
373
  // Convert to HTML for easy frontend rendering
357
374
  answer = this.portableTextToHtml(field.value);
@@ -371,15 +388,15 @@ let ThemeContentService = class ThemeContentService {
371
388
  */
372
389
  portableTextToHtml(jsonString) {
373
390
  if (!jsonString) {
374
- return '';
391
+ return "";
375
392
  }
376
393
  try {
377
394
  const doc = JSON.parse(jsonString);
378
395
  return this.renderPortableTextNode(doc);
379
396
  }
380
397
  catch (error) {
381
- logger_1.logger.warn('Failed to parse Portable Text JSON, returning raw value', {
382
- error: error instanceof Error ? error.message : 'Unknown',
398
+ logger_1.logger.warn("Failed to parse Portable Text JSON, returning raw value", {
399
+ error: error instanceof Error ? error.message : "Unknown",
383
400
  value: jsonString.substring(0, 100),
384
401
  });
385
402
  return jsonString;
@@ -389,34 +406,36 @@ let ThemeContentService = class ThemeContentService {
389
406
  * Recursively render a Portable Text node to HTML.
390
407
  */
391
408
  renderPortableTextNode(node) {
392
- if (!node || typeof node !== 'object') {
393
- return '';
409
+ if (!node || typeof node !== "object") {
410
+ return "";
394
411
  }
395
412
  const children = node.children
396
- ? node.children.map((child) => this.renderPortableTextNode(child)).join('')
397
- : '';
413
+ ? node.children
414
+ .map((child) => this.renderPortableTextNode(child))
415
+ .join("")
416
+ : "";
398
417
  switch (node.type) {
399
- case 'root':
418
+ case "root":
400
419
  return children;
401
- case 'paragraph':
420
+ case "paragraph":
402
421
  return `<p>${children}</p>`;
403
- case 'heading': {
422
+ case "heading": {
404
423
  const level = node.level || 2;
405
424
  return `<h${level}>${children}</h${level}>`;
406
425
  }
407
- case 'list': {
408
- const tag = node.listType === 'ordered' ? 'ol' : 'ul';
426
+ case "list": {
427
+ const tag = node.listType === "ordered" ? "ol" : "ul";
409
428
  return `<${tag}>${children}</${tag}>`;
410
429
  }
411
- case 'list-item':
430
+ case "list-item":
412
431
  return `<li>${children}</li>`;
413
- case 'link': {
414
- const href = node.url || node.href || '#';
415
- const target = node.target || '_blank';
432
+ case "link": {
433
+ const href = node.url || node.href || "#";
434
+ const target = node.target || "_blank";
416
435
  return `<a href="${this.escapeHtml(href)}" target="${target}" rel="noopener noreferrer">${children}</a>`;
417
436
  }
418
- case 'text': {
419
- let text = this.escapeHtml(node.value || '');
437
+ case "text": {
438
+ let text = this.escapeHtml(node.value || "");
420
439
  // Apply marks (bold, italic, etc.)
421
440
  if (node.bold) {
422
441
  text = `<strong>${text}</strong>`;
@@ -436,11 +455,11 @@ let ThemeContentService = class ThemeContentService {
436
455
  */
437
456
  escapeHtml(str) {
438
457
  return str
439
- .replace(/&/g, '&amp;')
440
- .replace(/</g, '&lt;')
441
- .replace(/>/g, '&gt;')
442
- .replace(/"/g, '&quot;')
443
- .replace(/'/g, '&#039;');
458
+ .replace(/&/g, "&amp;")
459
+ .replace(/</g, "&lt;")
460
+ .replace(/>/g, "&gt;")
461
+ .replace(/"/g, "&quot;")
462
+ .replace(/'/g, "&#039;");
444
463
  }
445
464
  /**
446
465
  * Curates parsed theme content
@@ -448,7 +467,7 @@ let ThemeContentService = class ThemeContentService {
448
467
  async curateThemeContent(brand, parsed) {
449
468
  const { sections, order } = parsed;
450
469
  if (!sections || !order) {
451
- logger_1.logger.warn('Invalid theme content structure', {
470
+ logger_1.logger.warn("Invalid theme content structure", {
452
471
  hasSections: !!sections,
453
472
  hasOrder: !!order,
454
473
  });
@@ -483,7 +502,7 @@ let ThemeContentService = class ThemeContentService {
483
502
  }
484
503
  // Handle featured-collection section type
485
504
  const collectionHandle = section.settings.collection;
486
- if (section.type === 'featured-collection' && collectionHandle) {
505
+ if (section.type === "featured-collection" && collectionHandle) {
487
506
  const productsCount = section.settings.products_to_show || 8;
488
507
  curatedSection.collectionRef = {
489
508
  handle: collectionHandle,
@@ -527,7 +546,7 @@ let ThemeContentService = class ThemeContentService {
527
546
  */
528
547
  async resolveCollectionReferences(brand, content) {
529
548
  if (!this.collectionService || !this.productService) {
530
- logger_1.logger.warn('Collection/Product services not provided, skipping resolution');
549
+ logger_1.logger.warn("Collection/Product services not provided, skipping resolution");
531
550
  return content;
532
551
  }
533
552
  const sectionsWithRefs = content
@@ -538,10 +557,10 @@ let ThemeContentService = class ThemeContentService {
538
557
  }
539
558
  // Fetch all collections in parallel
540
559
  const collectionPromises = sectionsWithRefs.map(({ section }) => this.collectionService.getCollectionByHandle(brand, section.collectionRef.handle, section.collectionRef.productsCount).catch((error) => {
541
- logger_1.logger.error('Failed to fetch collection for theme section', {
560
+ logger_1.logger.error("Failed to fetch collection for theme section", {
542
561
  brand,
543
562
  handle: section.collectionRef.handle,
544
- error: error instanceof Error ? error.message : 'Unknown',
563
+ error: error instanceof Error ? error.message : "Unknown",
545
564
  });
546
565
  return null;
547
566
  }));
@@ -554,10 +573,10 @@ let ThemeContentService = class ThemeContentService {
554
573
  // Fetch all products in parallel
555
574
  const productHandleArray = Array.from(allProductHandles);
556
575
  const productPromises = productHandleArray.map((handle) => this.productService.getProductByHandle(brand, handle).catch((error) => {
557
- logger_1.logger.error('Failed to fetch product for collection', {
576
+ logger_1.logger.error("Failed to fetch product for collection", {
558
577
  brand,
559
578
  handle,
560
- error: error instanceof Error ? error.message : 'Unknown',
579
+ error: error instanceof Error ? error.message : "Unknown",
561
580
  });
562
581
  return null;
563
582
  }));
@@ -601,7 +620,7 @@ let ThemeContentService = class ThemeContentService {
601
620
  */
602
621
  async resolveProductReferences(brand, content) {
603
622
  if (!this.productService) {
604
- logger_1.logger.warn('Product service not provided, skipping resolution');
623
+ logger_1.logger.warn("Product service not provided, skipping resolution");
605
624
  return content;
606
625
  }
607
626
  const sectionsWithRefs = content
@@ -612,10 +631,10 @@ let ThemeContentService = class ThemeContentService {
612
631
  }
613
632
  // Fetch all products in parallel
614
633
  const productPromises = sectionsWithRefs.map(({ section }) => this.productService.getProductByHandle(brand, section.productRef.handle).catch((error) => {
615
- logger_1.logger.error('Failed to fetch product for theme section', {
634
+ logger_1.logger.error("Failed to fetch product for theme section", {
616
635
  brand,
617
636
  handle: section.productRef.handle,
618
- error: error instanceof Error ? error.message : 'Unknown',
637
+ error: error instanceof Error ? error.message : "Unknown",
619
638
  });
620
639
  return null;
621
640
  }));
@@ -678,13 +697,13 @@ let ThemeContentService = class ThemeContentService {
678
697
  if (references.size === 0) {
679
698
  return curated;
680
699
  }
681
- logger_1.logger.info('Resolving media references', {
700
+ logger_1.logger.info("Resolving media references", {
682
701
  brand,
683
702
  count: references.size,
684
703
  });
685
704
  const urlMap = await this.fetchMediaUrls(brand, Array.from(references));
686
705
  if (urlMap.size === 0) {
687
- logger_1.logger.warn('No media URLs resolved');
706
+ logger_1.logger.warn("No media URLs resolved");
688
707
  }
689
708
  return this.replaceMediaReferences(curated, urlMap);
690
709
  }
@@ -695,13 +714,13 @@ let ThemeContentService = class ThemeContentService {
695
714
  for (const [key, value] of Object.entries(obj)) {
696
715
  if (skipKeys.includes(key))
697
716
  continue;
698
- if (typeof value === 'string' && shopifyPattern.test(value)) {
717
+ if (typeof value === "string" && shopifyPattern.test(value)) {
699
718
  references.add(value);
700
719
  }
701
- else if (typeof value === 'object' && value !== null) {
720
+ else if (typeof value === "object" && value !== null) {
702
721
  if (Array.isArray(value)) {
703
722
  value.forEach((item) => {
704
- if (typeof item === 'object' && item !== null) {
723
+ if (typeof item === "object" && item !== null) {
705
724
  extractFromObject(item, skipKeys);
706
725
  }
707
726
  });
@@ -737,7 +756,7 @@ let ThemeContentService = class ThemeContentService {
737
756
  const adminToken = this.configService.getShopifyAdminToken(brand);
738
757
  const storeDomain = this.configService.getShopifyStoreDomain(brand);
739
758
  if (!adminToken || !storeDomain) {
740
- logger_1.logger.warn('Missing Admin API configuration for media resolution', {
759
+ logger_1.logger.warn("Missing Admin API configuration for media resolution", {
741
760
  brand,
742
761
  });
743
762
  return urlMap;
@@ -745,10 +764,10 @@ let ThemeContentService = class ThemeContentService {
745
764
  const shopImageRefs = [];
746
765
  const filesApiRefs = [];
747
766
  for (const ref of references) {
748
- if (ref.startsWith('shopify://shop_images/')) {
767
+ if (ref.startsWith("shopify://shop_images/")) {
749
768
  shopImageRefs.push(ref);
750
769
  }
751
- else if (ref.startsWith('shopify://files/')) {
770
+ else if (ref.startsWith("shopify://files/")) {
752
771
  filesApiRefs.push(ref);
753
772
  }
754
773
  }
@@ -761,15 +780,31 @@ let ThemeContentService = class ThemeContentService {
761
780
  return urlMap;
762
781
  }
763
782
  async resolveShopImages(refs, urlMap, adminToken, storeDomain) {
783
+ // Map lowercased filename -> original ref so returned files can be matched
784
+ // back to the theme reference regardless of case.
764
785
  const filenameMap = new Map();
765
786
  for (const ref of refs) {
766
- const filename = ref.split('/').pop();
787
+ const filename = ref.split("/").pop();
767
788
  if (filename) {
768
- filenameMap.set(filename, ref);
789
+ filenameMap.set(filename.toLowerCase(), ref);
769
790
  }
770
791
  }
771
- const query = `{
772
- files(first: 250, query: "media_type:IMAGE status:READY", sortKey: CREATED_AT, reverse: true) {
792
+ const uniqueFilenames = Array.from(new Set(refs.map((ref) => ref.split("/").pop()).filter((f) => !!f)));
793
+ const extractFilenameFromUrl = (url) => {
794
+ const urlWithoutQuery = url.split("?")[0];
795
+ const segments = urlWithoutQuery.split("/");
796
+ return segments[segments.length - 1].toLowerCase();
797
+ };
798
+ try {
799
+ // Query the Files API by exact filename in batches. Fetching the newest N
800
+ // files and matching in memory silently drops any image older than that
801
+ // window; a targeted `filename:` search resolves refs regardless of how
802
+ // many files the store has.
803
+ for (let i = 0; i < uniqueFilenames.length; i += ThemeContentService_1.MEDIA_QUERY_BATCH_SIZE) {
804
+ const batch = uniqueFilenames.slice(i, i + ThemeContentService_1.MEDIA_QUERY_BATCH_SIZE);
805
+ const searchQuery = this.buildFilenameSearchQuery("IMAGE", batch);
806
+ const query = `{
807
+ files(first: ${batch.length * 2}, query: ${JSON.stringify(searchQuery)}) {
773
808
  nodes {
774
809
  ... on MediaImage {
775
810
  id
@@ -782,94 +817,68 @@ let ThemeContentService = class ThemeContentService {
782
817
  }
783
818
  }
784
819
  }`;
785
- try {
786
- const response = await fetch(`https://${storeDomain}/admin/api/2024-01/graphql.json`, {
787
- method: 'POST',
788
- headers: {
789
- 'Content-Type': 'application/json',
790
- 'X-Shopify-Access-Token': adminToken,
791
- },
792
- body: JSON.stringify({ query }),
793
- });
794
- const result = (await response.json());
795
- if (result.errors?.length) {
796
- logger_1.logger.error('Shopify Files API errors for shop_images', {
797
- errors: result.errors.map((e) => e.message),
820
+ const response = await fetch(`https://${storeDomain}/admin/api/2024-01/graphql.json`, {
821
+ method: "POST",
822
+ headers: {
823
+ "Content-Type": "application/json",
824
+ "X-Shopify-Access-Token": adminToken,
825
+ },
826
+ body: JSON.stringify({ query }),
798
827
  });
799
- return;
800
- }
801
- const files = result.data?.files?.nodes || [];
802
- const extractFilenameFromUrl = (url) => {
803
- const urlWithoutQuery = url.split('?')[0];
804
- const segments = urlWithoutQuery.split('/');
805
- return segments[segments.length - 1].toLowerCase();
806
- };
807
- // First pass: exact filename match
808
- for (const file of files) {
809
- const image = file.image;
810
- if (!image?.url)
828
+ const result = (await response.json());
829
+ if (result.errors?.length) {
830
+ logger_1.logger.error("Shopify Files API errors for shop_images", {
831
+ errors: result.errors.map((e) => e.message),
832
+ });
811
833
  continue;
812
- const cdnFilename = extractFilenameFromUrl(image.url);
813
- for (const [filename, ref] of filenameMap.entries()) {
814
- const lookupFilename = filename.toLowerCase();
815
- if (cdnFilename === lookupFilename) {
816
- urlMap.set(ref, {
817
- url: image.url,
818
- width: image.width,
819
- height: image.height,
820
- });
821
- filenameMap.delete(filename);
822
- break;
823
- }
824
834
  }
825
- if (filenameMap.size === 0)
826
- break;
827
- }
828
- // Second pass: fuzzy match for remaining
829
- if (filenameMap.size > 0) {
835
+ const files = result.data?.files?.nodes || [];
830
836
  for (const file of files) {
831
837
  const image = file.image;
832
838
  if (!image?.url)
833
839
  continue;
834
- const cdnUrlLower = image.url.toLowerCase();
835
- for (const [filename, ref] of filenameMap.entries()) {
836
- const nameWithoutExt = filename.replace(/\.[^/.]+$/, '').toLowerCase();
837
- if (cdnUrlLower.includes(nameWithoutExt)) {
838
- urlMap.set(ref, {
839
- url: image.url,
840
- width: image.width,
841
- height: image.height,
842
- });
843
- filenameMap.delete(filename);
844
- break;
845
- }
840
+ const cdnFilename = extractFilenameFromUrl(image.url);
841
+ const ref = filenameMap.get(cdnFilename);
842
+ if (ref && !urlMap.has(ref)) {
843
+ urlMap.set(ref, {
844
+ url: image.url,
845
+ width: image.width,
846
+ height: image.height,
847
+ });
848
+ filenameMap.delete(cdnFilename);
846
849
  }
847
- if (filenameMap.size === 0)
848
- break;
849
850
  }
850
851
  }
851
852
  if (filenameMap.size > 0) {
852
- logger_1.logger.warn('Unresolved shop_images', {
853
+ logger_1.logger.warn("Unresolved shop_images", {
853
854
  filenames: Array.from(filenameMap.keys()),
854
855
  });
855
856
  }
856
857
  }
857
858
  catch (error) {
858
- logger_1.logger.error('Failed to fetch shop_images from Files API', {
859
- error: error instanceof Error ? error.message : 'Unknown',
859
+ logger_1.logger.error("Failed to fetch shop_images from Files API", {
860
+ error: error instanceof Error ? error.message : "Unknown",
860
861
  });
861
862
  }
862
863
  }
863
864
  async resolveFilesApiMedia(refs, urlMap, adminToken, storeDomain) {
865
+ // Map lowercased filename (and its extension-less form) -> original ref.
864
866
  const videoFilenameMap = new Map();
865
867
  for (const ref of refs) {
866
- const filename = ref.split('/').pop();
868
+ const filename = ref.split("/").pop();
867
869
  if (filename) {
868
- videoFilenameMap.set(filename, ref);
870
+ videoFilenameMap.set(filename.toLowerCase(), ref);
869
871
  }
870
872
  }
871
- const query = `{
872
- files(first: 100, query: "media_type:VIDEO status:READY", sortKey: CREATED_AT, reverse: true) {
873
+ const uniqueFilenames = Array.from(new Set(refs.map((ref) => ref.split("/").pop()).filter((f) => !!f)));
874
+ try {
875
+ // Query the Files API by exact filename in batches (see resolveShopImages
876
+ // for why the newest-N approach silently drops older media).
877
+ for (let i = 0; i < uniqueFilenames.length; i += ThemeContentService_1.MEDIA_QUERY_BATCH_SIZE) {
878
+ const batch = uniqueFilenames.slice(i, i + ThemeContentService_1.MEDIA_QUERY_BATCH_SIZE);
879
+ const searchQuery = this.buildFilenameSearchQuery("VIDEO", batch);
880
+ const query = `{
881
+ files(first: ${batch.length * 2}, query: ${JSON.stringify(searchQuery)}) {
873
882
  nodes {
874
883
  ... on Video {
875
884
  id
@@ -884,78 +893,85 @@ let ThemeContentService = class ThemeContentService {
884
893
  }
885
894
  }
886
895
  }`;
887
- try {
888
- const response = await fetch(`https://${storeDomain}/admin/api/2024-01/graphql.json`, {
889
- method: 'POST',
890
- headers: {
891
- 'Content-Type': 'application/json',
892
- 'X-Shopify-Access-Token': adminToken,
893
- },
894
- body: JSON.stringify({ query }),
895
- });
896
- const result = (await response.json());
897
- if (result.errors?.length) {
898
- logger_1.logger.error('Shopify Files API errors', {
899
- errors: result.errors.map((e) => e.message),
896
+ const response = await fetch(`https://${storeDomain}/admin/api/2024-01/graphql.json`, {
897
+ method: "POST",
898
+ headers: {
899
+ "Content-Type": "application/json",
900
+ "X-Shopify-Access-Token": adminToken,
901
+ },
902
+ body: JSON.stringify({ query }),
900
903
  });
901
- return;
902
- }
903
- const files = result.data?.files?.nodes || [];
904
- for (const file of files) {
905
- if (!file.sources || file.sources.length === 0)
906
- continue;
907
- const mp4Source = file.sources.find((s) => s.mimeType.includes('mp4'));
908
- const selectedSource = mp4Source || file.sources[0];
909
- if (!selectedSource?.url)
904
+ const result = (await response.json());
905
+ if (result.errors?.length) {
906
+ logger_1.logger.error("Shopify Files API errors", {
907
+ errors: result.errors.map((e) => e.message),
908
+ });
910
909
  continue;
911
- const videoIdentifier = (file.filename || selectedSource.url).toLowerCase();
912
- for (const [filename, ref] of videoFilenameMap.entries()) {
913
- const nameWithoutExt = filename.replace(/\.[^/.]+$/, '').toLowerCase();
914
- if (videoIdentifier.includes(nameWithoutExt)) {
910
+ }
911
+ const files = result.data?.files?.nodes || [];
912
+ for (const file of files) {
913
+ if (!file.sources || file.sources.length === 0)
914
+ continue;
915
+ const mp4Source = file.sources.find((s) => s.mimeType.includes("mp4"));
916
+ const selectedSource = mp4Source || file.sources[0];
917
+ if (!selectedSource?.url)
918
+ continue;
919
+ if (!file.filename)
920
+ continue;
921
+ const ref = videoFilenameMap.get(file.filename.toLowerCase());
922
+ if (ref && !urlMap.has(ref)) {
915
923
  urlMap.set(ref, {
916
924
  url: selectedSource.url,
917
925
  width: selectedSource.width,
918
926
  height: selectedSource.height,
919
927
  });
920
- videoFilenameMap.delete(filename);
921
- break;
928
+ videoFilenameMap.delete(file.filename.toLowerCase());
922
929
  }
923
930
  }
924
- if (videoFilenameMap.size === 0)
925
- break;
926
931
  }
927
932
  if (videoFilenameMap.size > 0) {
928
- logger_1.logger.warn('Unresolved videos', {
933
+ logger_1.logger.warn("Unresolved videos", {
929
934
  filenames: Array.from(videoFilenameMap.keys()),
930
935
  });
931
936
  }
932
937
  }
933
938
  catch (error) {
934
- logger_1.logger.error('Failed to fetch Files API media', {
935
- error: error instanceof Error ? error.message : 'Unknown',
939
+ logger_1.logger.error("Failed to fetch Files API media", {
940
+ error: error instanceof Error ? error.message : "Unknown",
936
941
  });
937
942
  }
938
943
  }
944
+ /**
945
+ * Build a Shopify Files search query that matches any of the given filenames
946
+ * for a media type, e.g.
947
+ * media_type:IMAGE status:READY (filename:'a.png' OR filename:'b.jpg')
948
+ */
949
+ buildFilenameSearchQuery(mediaType, filenames) {
950
+ const orClause = filenames
951
+ .map((f) => `filename:'${f.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`)
952
+ .join(" OR ");
953
+ return `media_type:${mediaType} status:READY (${orClause})`;
954
+ }
939
955
  mediaFieldNames = new Set([
940
- 'image',
941
- 'image_web',
942
- 'image_mweb',
943
- 'image_mobile',
944
- 'video',
945
- 'video_web',
946
- 'video_mweb',
947
- 'video_mobile',
956
+ "image",
957
+ "image_web",
958
+ "image_mweb",
959
+ "image_mobile",
960
+ "video",
961
+ "video_web",
962
+ "video_mweb",
963
+ "video_mobile",
948
964
  ]);
949
965
  isMediaUrl(value) {
950
- return (value.startsWith('https://') ||
951
- value.startsWith('http://') ||
952
- value.startsWith('//'));
966
+ return (value.startsWith("https://") ||
967
+ value.startsWith("http://") ||
968
+ value.startsWith("//"));
953
969
  }
954
970
  replaceMediaReferences(curated, urlMap) {
955
971
  const replaceInObject = (obj) => {
956
972
  const result = { ...obj };
957
973
  for (const [key, value] of Object.entries(result)) {
958
- if (typeof value === 'string') {
974
+ if (typeof value === "string") {
959
975
  if (urlMap.has(value)) {
960
976
  const media = urlMap.get(value);
961
977
  result[key] = {
@@ -972,18 +988,18 @@ let ThemeContentService = class ThemeContentService {
972
988
  };
973
989
  }
974
990
  else if (this.mediaFieldNames.has(key) &&
975
- value.startsWith('shopify://')) {
976
- logger_1.logger.warn('Unresolved shopify:// media reference', {
991
+ value.startsWith("shopify://")) {
992
+ logger_1.logger.warn("Unresolved shopify:// media reference", {
977
993
  key,
978
994
  value,
979
995
  });
980
996
  result[key] = null;
981
997
  }
982
998
  }
983
- else if (typeof value === 'object' && value !== null) {
999
+ else if (typeof value === "object" && value !== null) {
984
1000
  if (Array.isArray(value)) {
985
1001
  result[key] = value.map((item) => {
986
- if (typeof item === 'object' && item !== null) {
1002
+ if (typeof item === "object" && item !== null) {
987
1003
  return replaceInObject(item);
988
1004
  }
989
1005
  return item;
@@ -1019,7 +1035,7 @@ let ThemeContentService = class ThemeContentService {
1019
1035
  }
1020
1036
  };
1021
1037
  exports.ThemeContentService = ThemeContentService;
1022
- exports.ThemeContentService = ThemeContentService = tslib_1.__decorate([
1038
+ exports.ThemeContentService = ThemeContentService = ThemeContentService_1 = tslib_1.__decorate([
1023
1039
  (0, common_1.Injectable)(),
1024
1040
  tslib_1.__param(3, (0, common_1.Optional)()),
1025
1041
  tslib_1.__param(3, (0, common_1.Inject)(theme_content_interfaces_1.THEME_COLLECTION_SERVICE)),