PageSourceSearch

https://www.onestopwineshop.com/wp-content/themes/osws-2022/inc/sc…product-collection-schema.js?ver=1787614713

js onestopwineshop.com collected 2026-09-29 03:07:55 UTC 36,237 bytes, 1,276 lines download raw bytes

1/**
2 * Dynamic Commerce7/Algolia collection schema integration.
3 *
4 * Product grids can appear on collection templates, search pages, the home
5 * page, and recommendation widgets. They are rendered and updated in the
6 * browser, so this script is enqueued on product-grid contexts only (never on
7 * product detail pages) and still activates only when it finds real cards.
8 *
9 * It generates one ItemList in visual order, detailed Product/ListItem data,
10 * Offers with shared shipping/return references, and Brand nodes for brands
11 * actually present. The ItemList uses a separate JSON-LD script so long grids
12 * are not lost to roughly 10,000-character display limits. Mutation observers
13 * rebuild it after Algolia filters, pagination, or Commerce7 updates.
14 */
15(function () {
16  'use strict';
17
18  const graphElementId = 'schema-master-graph';
19  const itemListElementId = 'schema-product-itemlist';
20  const canonicalOrigin = 'https://www.onestopwineshop.com';
21  const oswsOrganizationId = canonicalOrigin + '/#organization';
22  let generatedProductIds = [];
23  let generatedShippingIds = [];
24  let updateTimer = null;
25  const brandResolutionState = {
26    warningKeys: new Set(),
27  };
28
29  function brandDiagnosticsEnabled() {
30    if (window && window.oswsSchemaDebug === true) {
31      return true;
32    }
33
34    return /(?:\?|&)(?:schema_debug|schemaDebug)=1(?:&|$)/.test(window.location.search);
35  }
36
37  function emitBrandResolutionWarning(category, details) {
38    if (!brandDiagnosticsEnabled()) {
39      return;
40    }
41
42    const payload = Object.assign({}, details || {});
43    const key = [
44      category,
45      payload.productUrl || '',
46      payload.bundleKeys || '',
47      payload.reason || '',
48    ].join('|');
49
50    if (brandResolutionState.warningKeys.has(key)) {
51      return;
52    }
53
54    brandResolutionState.warningKeys.add(key);
55
56    if (window.console && typeof window.console.warn === 'function') {
57      window.console.warn('[Schema Brand]', Object.assign({
58        category: category,
59        pathname: window.location.pathname,
60      }, payload));
61    }
62  }
63
64  function brandIndex() {
65    // Product cards expose data-schema-brand-key. Match that stable ACF key
66    // instead of guessing a Brand from the product's marketing name.
67    const brands =
68        window.oswsSchemaBrands &&
69        window.oswsSchemaBrands.brands
70            ? window.oswsSchemaBrands.brands
71            : [];
72
73    const index = {};
74
75    brands.forEach((brand) => {
76
77        if (!brand.schema_brand_key) {
78            return;
79        }
80
81        index[
82            String(
83                brand.schema_brand_key
84            ).trim().toLowerCase()
85        ] = brand;
86    });
87
88    return index;
89  }
90
91  function normalizeBrandKey(value) {
92    return cleanText(value)
93      .toLowerCase()
94      .trim();
95  }
96
97  function isReservedOrganizationBrandKey(brandKey) {
98    return normalizeBrandKey(brandKey) === 'osws';
99  }
100
101  function graphHasCanonicalOrganizationNode(graph) {
102    if (!Array.isArray(graph)) {
103      return false;
104    }
105
106    return graph.some((node) => {
107      if (!node || node['@id'] !== oswsOrganizationId) {
108        return false;
109      }
110
111      const types = Array.isArray(node['@type'])
112        ? node['@type']
113        : [node['@type']];
114
115      return types.includes('Organization');
116    });
117  }
118
119  function organizationBrandEntity(root, graph) {
120    const productUrlValue = root ? productUrl(root) : '';
121
122    if (!graphHasCanonicalOrganizationNode(graph)) {
123      emitBrandResolutionWarning('osws_organization_reference_unavailable', {
124        productUrl: productUrlValue,
125        reason: 'Canonical OSWS Organization node was not found in the active graph.',
126      });
127    }
128
129    return {
130      entityType: 'Organization',
131      id: oswsOrganizationId,
132      brandRecord: null,
133    };
134  }
135
136  function canonicalBrandEntity(brandRecord) {
137    if (!brandRecord || !brandRecord.id) {
138      return null;
139    }
140
141    return {
142      entityType: 'Brand',
143      id: brandRecord.id,
144      brandRecord: brandRecord,
145    };
146  }
147
148  function brandRecordFromRoot(root) {
149    if (!root) {
150      return null;
151    }
152
153    const key = normalizeBrandKey(
154      root.getAttribute('data-schema-brand-key')
155    );
156
157    if (!key) {
158      return null;
159    }
160
161    return brandIndex()[key] || null;
162  }
163
164  function bundleBrandKeysFromRoot(root) {
165    if (!root) {
166      return [];
167    }
168
169    const raw = root.getAttribute('data-schema-bundle-key');
170
171    if (!raw) {
172      return [];
173    }
174
175    const text = cleanText(raw);
176
177    if (!text) {
178      return [];
179    }
180
181    if (text.charAt(0) === '[' && text.charAt(text.length - 1) === ']') {
182      try {
183        const parsed = JSON.parse(text);
184
185        if (Array.isArray(parsed)) {
186          return Array.from(new Set(
187            parsed
188              .map((value) => normalizeBrandKey(value))
189              .filter(Boolean)
190          ));
191        }
192      } catch (error) {
193        // Fallback to delimiter parsing below.
194      }
195    }
196
197    return Array.from(new Set(
198      text
199        .split(/[,;|]+/)
200        .map((value) => normalizeBrandKey(value))
201        .filter(Boolean)
202    ));
203  }
204
205  function brandEntitiesFromRoot(root, graph) {
206    if (!root) {
207      return [];
208    }
209
210    const key = normalizeBrandKey(
211      root.getAttribute('data-schema-brand-key')
212    );
213
214    if (!key) {
215      return [];
216    }
217
218    if (key !== 'bundle') {
219      if (isReservedOrganizationBrandKey(key)) {
220        return [organizationBrandEntity(root, graph)];
221      }
222
223      const record = brandRecordFromRoot(root);
224      const entity = canonicalBrandEntity(record);
225
226      return entity ? [entity] : [];
227    }
228
229    const bundleKeys = bundleBrandKeysFromRoot(root);
230    const seenIds = new Set();
231    const entities = [];
232
233    bundleKeys.forEach((bundleKey) => {
234      if (isReservedOrganizationBrandKey(bundleKey)) {
235        const organizationEntity = organizationBrandEntity(root, graph);
236
237        if (!seenIds.has(organizationEntity.id)) {
238          seenIds.add(organizationEntity.id);
239          entities.push(organizationEntity);
240        }
241
242        return;
243      }
244
245      const record = brandIndex()[bundleKey];
246      const entity = canonicalBrandEntity(record);
247
248      if (!entity || seenIds.has(entity.id)) {
249        return;
250      }
251
252      seenIds.add(entity.id);
253      entities.push(entity);
254    });
255
256    return entities;
257  }
258
259  function brandRecordsFromRoot(root, graph) {
260    return brandEntitiesFromRoot(root, graph)
261      .filter((entity) => entity && entity.entityType === 'Brand' && entity.brandRecord)
262      .map((entity) => entity.brandRecord);
263  }
264
265  function buildBrandNode(brand) {
266    // ACF supplies the canonical @id and optional entity URLs. `subjectOf`
267    // connects a Brand to its OSWS collection without claiming an ItemList is
268    // a valid value for WebPage.hasPart or ItemList.isPartOf.
269    if (!brand || !brand.id || !brand.name) {
270      return null;
271    }
272
273    const node = {
274      '@type': 'Brand',
275      '@id': brand.id,
276      'name': brand.name,
277    };
278
279    if (brand.official_url) {
280      node.url = brand.official_url;
281    }
282
283    if (brand.tfe_entity_url) {
284      node.mainEntityOfPage = {
285        '@id':
286          brand.tfe_entity_url.replace(/\/+$/, '') +
287          '/#webpage',
288      };
289    }
290
291    if (brand.osws_url) {
292      node.subjectOf = {
293        '@id':
294          brand.osws_url.replace(/\/+$/, '') +
295          '/#webpage',
296      };
297    }
298
299    const sameAsSource = Array.isArray(brand.same_as)
300      ? brand.same_as
301      : Array.isArray(brand.sameAs)
302        ? brand.sameAs
303        : (typeof brand.sameas === 'string' || typeof brand.same_as === 'string' || typeof brand.sameAs === 'string')
304          ? [brand.sameas || brand.same_as || brand.sameAs]
305          : [];
306    const sameAs = Array.from(
307      new Set(
308        sameAsSource
309          .map((value) => String(value || '').trim())
310          .filter(Boolean)
311      )
312    );
313
314    if (sameAs.length) {
315      node.sameAs = sameAs;
316    }
317
318    return node;
319  }
320
321  function calledBrandNodes(roots, graph) {
322    const nodes = [];
323    const seenIds = new Set();
324
325    roots.forEach((root) => {
326      brandEntitiesFromRoot(root, graph).forEach((entity) => {
327        if (
328          !entity ||
329          entity.entityType !== 'Brand' ||
330          !entity.brandRecord ||
331          seenIds.has(entity.id)
332        ) {
333          return;
334        }
335
336        const node = buildBrandNode(entity.brandRecord);
337
338        if (!node) {
339          return;
340        }
341
342        seenIds.add(entity.id);
343        nodes.push(node);
344      });
345    });
346
347    return nodes;
348  }
349
350  function shippingProfileKeyFromRoot(root) {
351    // Cards carry a short profile key. Offers reference the shared shipping
352    // entity by @id so the complete policy is defined once, not per product.
353    if (!root) {
354      return '';
355    }
356
357    return cleanText(
358      root.getAttribute(
359        'data-schema-shipping-profile-key'
360      )
361    )
362      .toLowerCase()
363      .replace(/_/g, '-')
364      .replace(/\s+/g, '-')
365      .replace(/-+/g, '-')
366      .replace(/^-|-$/g, '');
367  }
368
369function shippingProfileId(profileKey) {
370
371  if (!profileKey) {
372    return '';
373  }
374
375  return (
376    canonicalOrigin +
377    '/#shipping-destinations-' +
378    profileKey
379  );
380}
381
382  function shippingNodeRegistry() {
383    // The server localizes the complete OfferShippingDetails definitions.
384    // Index them by @id so only the nodes referenced by visible Offers are
385    // emitted into the graph, each exactly once.
386    if (shippingNodeRegistry.cache) {
387      return shippingNodeRegistry.cache;
388    }
389
390    const source = Array.isArray(window.oswsSchemaShipping)
391      ? window.oswsSchemaShipping
392      : [];
393    const registry = {};
394
395    source.forEach((node) => {
396      if (node && node['@id']) {
397        registry[node['@id']] = node;
398      }
399    });
400
401    shippingNodeRegistry.cache = registry;
402    return registry;
403  }
404
405  function shippingIdsFromProducts(products) {
406    // Collect the unique shipping @ids referenced by the Offers on this page.
407    const ids = new Set();
408
409    products.forEach((product) => {
410      const offers = product.offers
411        ? (Array.isArray(product.offers) ? product.offers : [product.offers])
412        : [];
413
414      offers.forEach((offer) => {
415        const details = offer && offer.shippingDetails;
416        const detailList = Array.isArray(details)
417          ? details
418          : (details ? [details] : []);
419
420        detailList.forEach((detail) => {
421          if (detail && detail['@id']) {
422            ids.add(detail['@id']);
423          }
424        });
425      });
426    });
427
428    return Array.from(ids);
429  }
430
431
432  function cleanText(value) {
433    if (value === null || value === undefined) {
434      return '';
435    }
436
437    if (value && typeof value === 'object' && value.nodeType) {
438      value = value.textContent || '';
439    } else {
440      const element = document.createElement('div');
441      element.innerHTML = String(value);
442      value = element.textContent || '';
443    }
444
445    return String(value)
446      .replace(/\u00a0/g, ' ')
447      .replace(/\s+/g, ' ')
448      .trim();
449  }
450
451  function absoluteUrl(value) {
452    if (!value) {
453      return '';
454    }
455
456    try {
457      const url = new URL(String(value), window.location.origin);
458
459      if (url.origin === window.location.origin) {
460        return canonicalOrigin + url.pathname + url.search + url.hash;
461      }
462
463      return url.href;
464    } catch (error) {
465      return '';
466    }
467  }
468
469  function canonicalUrl() {
470    const canonical = document.querySelector('link[rel="canonical"]');
471    const value = canonical ? canonical.href : window.location.href;
472
473    return absoluteUrl(value).split('#')[0].split('?')[0];
474  }
475
476  function priceValue(value) {
477    const match = String(value || '')
478      .replace(/,/g, '')
479      .match(/(?:USD\s*|\$\s*)?(\d+(?:\.\d{1,2})?)/i);
480
481    return match ? Number(match[1]).toFixed(2) : '';
482  }
483
484  function firstElement(root, selectors) {
485    for (const selector of selectors) {
486      const element = root.querySelector(selector);
487
488      if (element) {
489        return element;
490      }
491    }
492
493    return null;
494  }
495
496  function productUrl(root) {
497    const dataUrl = root.getAttribute('data-product-url');
498
499    if (dataUrl) {
500      return absoluteUrl(dataUrl);
501    }
502
503    const links = Array.from(root.querySelectorAll('a[href]'));
504    const productLink = links.find((link) => {
505      try {
506        return new URL(link.href, window.location.origin).pathname.includes('/product/');
507      } catch (error) {
508        return false;
509      }
510    });
511
512    return productLink ? absoluteUrl(productLink.href) : '';
513  }
514
515  function productName(root) {
516    const dataName = cleanText(root.getAttribute('data-product-name'));
517
518    if (dataName) {
519      return dataName;
520    }
521
522    return cleanText(firstElement(root, [
523      '.product_name',
524      '.title',
525      '.c7-product__title',
526      '[class*="product"][class*="title"]',
527      'h2',
528      'h3',
529    ]));
530  }
531
532  function productImage(root) {
533    // A rating badge can render before Commerce7's bottle image. Prefer known
534    // product-media containers and never use a decorative badge as Product.image.
535    const dataImage = root.getAttribute('data-product-image');
536
537    if (dataImage && !isDecorativeProductImage(dataImage, '')) {
538      return absoluteUrl(dataImage);
539    }
540
541    const preferred = Array.from(root.querySelectorAll(
542      '.image c7-product-image img, .image > img, ' +
543      'c7-product-image img, [class*="product-image"] img, ' +
544      'img[itemprop="image"]'
545    ));
546    const fallback = Array.from(root.querySelectorAll('img'));
547    const image = preferred.concat(fallback).find((candidate) => {
548      const source = candidate.currentSrc || candidate.src || '';
549      const alt = candidate.getAttribute('alt') || '';
550
551      return !candidate.closest('.badge, [class*="badge"], [class*="rating"]') &&
552        !isDecorativeProductImage(source, alt);
553    });
554
555    return image ? absoluteUrl(image.currentSrc || image.src) : '';
556  }
557
558  function isDecorativeProductImage(source, alt) {
559    const value = String(source || '') + ' ' + String(alt || '');
560
561    return /(?:\/badges?\/|[-_/](?:9\d|100)-badge\b|top\s*seller|new\s*(?:item|arrival|vintage)|award\s*winner|\b\d{2,3}\s*points?\b)/i
562      .test(value);
563  }
564
565  function productPrice(root) {
566    const dataPrice = root.getAttribute('data-product-price');
567
568    if (dataPrice) {
569      return priceValue(dataPrice);
570    }
571
572    const price = firstElement(root, [
573      '[itemprop="price"]',
574      '.c7-product__price',
575      '.c7-price',
576      '.price',
577      '[class*="price"]',
578    ]);
579
580    return priceValue(
581      price ? price.getAttribute('content') || cleanText(price) : ''
582    );
583  }
584
585  function pricesFromText(value) {
586    // Require a currency marker in visible text. This deliberately rejects
587    // bottle volumes such as "750 mL", which are not monetary amounts.
588    const matches = String(value || '')
589      .replace(/,/g, '')
590      .match(/(?:USD\s*|\$\s*)\d+(?:\.\d{1,2})?/gi) || [];
591
592    return Array.from(new Set(
593      matches.map(priceValue).filter(Boolean)
594    ));
595  }
596
597  function visibleProductPrices(root) {
598    const elements = Array.from(root.querySelectorAll(
599      '[itemprop="price"], [data-product-price], [data-sale-price], ' +
600      '[data-regular-price], [class*="price"], del, s'
601    ));
602    const values = [];
603
604    elements.forEach((element) => {
605      const source = element.getAttribute('content') ||
606        element.getAttribute('data-product-price') ||
607        element.getAttribute('data-sale-price') ||
608        element.getAttribute('data-regular-price') ||
609        cleanText(element);
610
611      pricesFromText(source).forEach((price) => values.push(price));
612    });
613
614    return Array.from(new Set(values));
615  }
616
617  function productPricing(root) {
618    /*
619     * Offer.price is always the amount a customer pays now. A higher visible
620     * regular price becomes StrikethroughPrice. Semantic attributes/classes
621     * win; min/max inference is limited to exactly two prices on the sale page
622     * so ratings, savings amounts, and variant ranges cannot become prices.
623     */
624    const salePage = /^\/collection\/wine-sale\/?$/i.test(
625      window.location.pathname
626    );
627    const activeElement = firstElement(root, [
628      '[data-sale-price]',
629      '[data-product-sale-price]',
630      '.sale-price',
631      '.c7-sale-price',
632      '[class*="sale-price"]',
633      '[class*="salePrice"]',
634      '[class*="discounted"]',
635    ]);
636    const regularElement = firstElement(root, [
637      '[data-regular-price]',
638      '[data-product-regular-price]',
639      '.regular-price',
640      '.original-price',
641      '.was-price',
642      '.compare-at-price',
643      '[class*="regular-price"]',
644      '[class*="original-price"]',
645      '[class*="compare-at"]',
646      'del',
647      's',
648    ]);
649    let activePrice = priceValue(
650      root.getAttribute('data-sale-price') ||
651      root.getAttribute('data-product-sale-price') ||
652      (activeElement
653        ? activeElement.getAttribute('content') ||
654          activeElement.getAttribute('data-sale-price') ||
655          activeElement.getAttribute('data-product-sale-price') ||
656          cleanText(activeElement)
657        : '')
658    );
659    let regularPrice = priceValue(
660      root.getAttribute('data-regular-price') ||
661      root.getAttribute('data-product-regular-price') ||
662      (regularElement
663        ? regularElement.getAttribute('content') ||
664          regularElement.getAttribute('data-regular-price') ||
665          regularElement.getAttribute('data-product-regular-price') ||
666          cleanText(regularElement)
667        : '')
668    );
669
670    if (salePage) {
671      const visiblePrices = visibleProductPrices(root)
672        .map(Number)
673        .filter((price) => price > 0);
674
675      if (visiblePrices.length === 2) {
676        activePrice = activePrice || Math.min(...visiblePrices).toFixed(2);
677        regularPrice = regularPrice || Math.max(...visiblePrices).toFixed(2);
678      }
679    }
680
681    activePrice = activePrice || productPrice(root);
682
683    if (
684      !activePrice ||
685      !regularPrice ||
686      Number(regularPrice) <= Number(activePrice)
687    ) {
688      regularPrice = '';
689    }
690
691    return {
692      active: activePrice,
693      regular: regularPrice,
694    };
695  }
696
697  function productSoldOut(root) {
698    const dataValue = cleanText(
699      root.getAttribute('data-product-sold-out')
700    ).toLowerCase();
701
702    if (
703      dataValue === 'true' ||
704      dataValue === 'yes' ||
705      dataValue === 'sold out'
706    ) {
707      return true;
708    }
709
710    return Boolean(
711      root.querySelector(
712        '.sold-out, .c7-product-sold-out, [class*="sold-out"], [class*="soldOut"]'
713      )
714    );
715  }
716
717  function productRoots() {
718    // Support both Algolia hits and Commerce7 cards. A Set removes the same
719    // card when it happens to match more than one selector.
720    const algolia = Array.from(
721      document.querySelectorAll(
722        '#algolia-hits article.product, [data-product-name][data-product-url]'
723      )
724    );
725    const commerce7 = Array.from(
726      document.querySelectorAll(
727        '#c7-content .products-list > .product, ' +
728        '#c7-content .products-list .product, ' +
729        '.c7-product-collection .product, ' +
730        '.c7-product-collection .c7-product, ' +
731        '.c7-product-collection__products .c7-product, ' +
732        '.c7-product-collection__products--featured .c7-product'
733      )
734    );
735
736    const seenUrls = new Set();
737
738    return Array.from(new Set(algolia.concat(commerce7))).filter((root) => {
739      const name = productName(root);
740      const url = productUrl(root);
741
742      if (!name || !url || seenUrls.has(url)) {
743        return false;
744      }
745
746      seenUrls.add(url);
747      return true;
748    });
749  }
750
751  function productFromRoot(root, graph) {
752    // Return one list-entry candidate for this card. URL is always retained
753    // for valid cards; Product markup is emitted only when price is present.
754    const url = productUrl(root);
755    const name = productName(root);
756    const image = productImage(root);
757    const pricing = productPricing(root);
758    const price = pricing.active;
759    const entry = {
760      url: url,
761      product: null,
762    };
763
764    if (!price) {
765      return entry;
766    }
767
768    const brandEntities =
769      brandEntitiesFromRoot(root, graph);
770
771    const resolvedShippingProfileKey =
772      shippingProfileKeyFromRoot(root);
773
774    const shippingDetailsId =
775      shippingProfileId(
776        resolvedShippingProfileKey
777      );
778
779    const sku = cleanText(
780      root.getAttribute(
781        'data-product-sku'
782      ) ||
783      (
784        root.querySelector(
785          '[data-sku]'
786        ) || {}
787      ).dataset?.sku
788    );
789
790    const explicitCategory = cleanText(
791      root.getAttribute('data-product-type') ||
792      root.getAttribute('data-product-category')
793    );
794    const merchandiseCollection = /^\/collection\/(?:wine-gifts|kitchenware|wine-accessories|logoed|provisions)\/?/i
795      .test(window.location.pathname);
796    const giftCard = /(?:e-?gift|gift\s*card)/i.test(name + ' ' + url);
797    const agaveNectar = /\b(?:agave\s+)?nectar\b/i.test(name + ' ' + url);
798    const mixer = /\b(?:mixer|bitters|syrup)\b/i.test(name + ' ' + url);
799    const category = agaveNectar
800      // Mixed sale collections can contain non-wine goods. Name-based exceptions
801      // run before the generic card category so nectar is never labeled Wine.
802      ? 'Agave Nectar'
803      : mixer
804        ? 'Mixer'
805        : giftCard
806          ? 'Gift Card'
807          : explicitCategory || (merchandiseCollection ? 'Merchandise' : 'Wine');
808    const description = cleanText(
809      root.getAttribute('data-product-description') ||
810      firstElement(root, [
811        '.product-description',
812        '.description',
813        '[class*="description"]',
814      ])
815    );
816
817    const product = {
818      '@type': 'Product',
819      '@id':
820        url.split('?')[0] +
821        '#product',
822      'url': url,
823      'name': name,
824      'category': category,
825    };
826
827    if (image) {
828      product.image = image;
829    }
830
831    if (description) {
832      product.description = description;
833    }
834
835    if (brandEntities.length === 1) {
836      product.brand = {
837        '@id': brandEntities[0].id,
838      };
839    } else if (brandEntities.length > 1) {
840      product.brand = brandEntities.map((entity) => ({ '@id': entity.id }));
841    }
842
843    if (sku) {
844      product.sku = sku;
845    }
846
847    const offer = {
848      '@type': 'Offer',
849      'url': url,
850      'price': price,
851      'priceCurrency': 'USD',
852      'availability':
853        productSoldOut(root)
854          ? 'https://schema.org/OutOfStock'
855          : 'https://schema.org/InStock',
856      'itemCondition':
857        'https://schema.org/NewCondition',
858      'seller': {
859        '@id':
860          canonicalOrigin +
861          '/#organization',
862      },
863      'hasMerchantReturnPolicy': {
864        '@id':
865          canonicalOrigin +
866          '/#merchant-return-policy',
867      },
868    };
869
870    if (shippingDetailsId) {
871      offer.shippingDetails = {
872        '@id': shippingDetailsId,
873      };
874    }
875
876    if (pricing.regular) {
877      offer.priceSpecification = {
878        '@type': 'UnitPriceSpecification',
879        'price': pricing.regular,
880        'priceCurrency': 'USD',
881        'priceType': 'https://schema.org/StrikethroughPrice',
882      };
883    }
884
885    product.offers = offer;
886    entry.product = product;
887
888    return entry;
889  }
890
891  function pageTitle() {
892    return cleanText(
893      firstElement(document, [
894        '.wine-results-title',
895        '#c7-content .products-list > h1',
896        '.collection h1',
897        'main h1',
898        'h1',
899      ])
900    ) || document.title;
901  }
902
903  function findPageNode(graph, url) {
904    return graph.find((node) => {
905      if (!node) {
906        return false;
907      }
908
909      const types = Array.isArray(node['@type'])
910        ? node['@type']
911        : [node['@type']];
912
913      return (
914        types.some((type) =>
915          ['WebPage', 'CollectionPage', 'ItemPage'].includes(type)
916        ) &&
917        (
918          String(node.url || '').replace(/\/$/, '') === url.replace(/\/$/, '') ||
919          String(node['@id'] || '').split('#')[0].replace(/\/$/, '') ===
920            url.replace(/\/$/, '')
921        )
922      );
923    });
924  }
925
926  function isCollectionContext() {
927    const path = window.location.pathname;
928
929    return Boolean(
930      document.getElementById('algolia-hits') ||
931      document.querySelector('#c7-content .products-list') ||
932      document.body.classList.contains('search') ||
933      document.body.className.includes('algolia-collection') ||
934      document.body.className.includes('commerce7-collection') ||
935      path === '/shop' ||
936      path.startsWith('/shop/') ||
937      path.startsWith('/collection/')
938    );
939  }
940
941  function isBrandDetailContext() {
942    // Single Brand pages live at /brands/{slug}/. Trailing slashes are
943    // normalized first so /brands/seaglasswines/ matches; the /brands/
944    // directory has no second path segment and never matches.
945    const path = window.location.pathname.replace(/\/+$/, '');
946
947    return /^\/brands\/[^/]+$/i.test(path);
948  }
949
950  function writeItemListSchema(
951    itemList,
952    brandNodes = []
953  ) {
954    // Keep the potentially large ItemList outside #schema-master-graph. The
955    // stable script id lets updates replace the previous list atomically.
956    let element = document.getElementById(itemListElementId);
957
958    if (!element) {
959      element = document.createElement('script');
960      element.type = 'application/ld+json';
961      element.id = itemListElementId;
962      document.head.appendChild(element);
963    }
964
965    const payload = {
966      '@context': 'https://schema.org',
967      '@graph': [
968        itemList,
969        ...brandNodes,
970      ],
971    };
972    const nextSchema = JSON.stringify(payload);
973
974    if (element.textContent !== nextSchema) {
975      element.textContent = nextSchema;
976    }
977  }
978
979  function updateCollectionGraph() {
980    // Re-scan the current DOM every time. This ensures the schema represents
981    // the products users actually see after filtering or loading more items,
982    // not merely the first server-rendered batch.
983    const graphElement = document.getElementById(graphElementId);
984    const roots = productRoots();
985
986    if (!graphElement || !roots.length) {
987      return false;
988    }
989
990    let payload;
991
992    try {
993      payload = JSON.parse(graphElement.textContent);
994    } catch (error) {
995      return false;
996    }
997
998    if (!payload || !Array.isArray(payload['@graph'])) {
999      return false;
1000    }
1001
1002    const url = canonicalUrl();
1003    const graphNodes = payload['@graph'];
1004    const entries = roots.map((root) => productFromRoot(root, graphNodes));
1005    const products = entries
1006      .map((entry) => entry.product)
1007      .filter((product) => Boolean(product));
1008    const currentIds = products.map((product) => product['@id']);
1009    const collectionContext = isCollectionContext();
1010    const brandDetail = isBrandDetailContext();
1011
1012    // Remove only Product nodes created by an earlier dynamic render.
1013    // Organization, WebSite, breadcrumbs, and all other engine nodes must
1014    // remain untouched.
1015    payload['@graph'] = payload['@graph'].filter(
1016      (node) => {
1017        if (!node || !generatedProductIds.includes(node['@id'])) {
1018          return true;
1019        }
1020
1021        const types = Array.isArray(node['@type'])
1022          ? node['@type']
1023          : [node['@type']];
1024
1025        return !types.includes('Product');
1026      }
1027    );
1028
1029    generatedProductIds = currentIds;
1030
1031    // Drop shipping nodes emitted by an earlier render; the currently visible
1032    // Offers below determine which shipping nodes are re-emitted.
1033    if (generatedShippingIds.length) {
1034      payload['@graph'] = payload['@graph'].filter(
1035        (node) => !node || !generatedShippingIds.includes(node['@id'])
1036      );
1037    }
1038
1039    const listId = url.replace(/\/$/, '') +
1040      (collectionContext ? '/#collection-list' : '/#product-list');
1041    const itemList = {
1042      '@type': 'ItemList',
1043      '@id': listId,
1044      'name': pageTitle(),
1045      'numberOfItems': entries.length,
1046      'itemListOrder': 'https://schema.org/ItemListOrderAscending',
1047      'itemListElement': entries.map((entry, index) => {
1048        const listItem = {
1049          '@type': 'ListItem',
1050          'position': index + 1,
1051          'url': entry.url,
1052        };
1053
1054        if (entry.product) {
1055          listItem.item = entry.product;
1056        }
1057
1058        return listItem;
1059      }),
1060    };
1061
1062    // The dynamic list is emitted in its own JSON-LD block. Remove a matching
1063    // server-side placeholder list from the master graph to avoid duplicates.
1064    payload['@graph'] = payload['@graph'].filter((node) => {
1065      if (!node || node['@type'] !== 'ItemList') {
1066        return true;
1067      }
1068
1069      const id = String(node['@id'] || '');
1070
1071      return id !== listId && !(
1072        collectionContext &&
1073        id.includes('#collection-list')
1074      );
1075    });
1076
1077    let pageNode = findPageNode(payload['@graph'], url);
1078
1079    if (!pageNode) {
1080      pageNode = {
1081        '@type': (collectionContext || brandDetail) ? 'CollectionPage' : 'WebPage',
1082        '@id': url.replace(/\/$/, '') + '/#webpage',
1083        'url': url,
1084        'name': pageTitle(),
1085        'isPartOf': {
1086          '@id': canonicalOrigin + '/#website',
1087        },
1088      };
1089      payload['@graph'].push(pageNode);
1090    }
1091
1092    if (brandDetail) {
1093      // The Brand is the primary entity, defined server-side (mainEntity +
1094      // about). Never overwrite those; attach the product list only as a
1095      // secondary mentions reference (ItemList is not a valid hasPart target).
1096      pageNode['@type'] = 'CollectionPage';
1097
1098      const mentions = Array.isArray(pageNode.mentions)
1099        ? pageNode.mentions
1100        : pageNode.mentions
1101          ? [pageNode.mentions]
1102          : [];
1103
1104      if (!mentions.some((mention) => mention && mention['@id'] === listId)) {
1105        mentions.push({ '@id': listId });
1106      }
1107
1108      pageNode.mentions = mentions;
1109    } else if (collectionContext) {
1110      pageNode['@type'] = 'CollectionPage';
1111      pageNode.mainEntity = { '@id': listId };
1112    } else {
1113      // ItemList is a Thing, not a CreativeWork, so it is not a valid target
1114      // for WebPage.hasPart or ItemList.isPartOf. WebPage.mentions accepts a
1115      // Thing and accurately links an embedded product list to its page.
1116      const hasPart = Array.isArray(pageNode.hasPart)
1117        ? pageNode.hasPart
1118        : pageNode.hasPart
1119          ? [pageNode.hasPart]
1120          : [];
1121      const preservedHasPart = hasPart.filter(
1122        (part) => !part || part['@id'] !== listId
1123      );
1124
1125      if (preservedHasPart.length) {
1126        pageNode.hasPart = preservedHasPart;
1127      } else {
1128        delete pageNode.hasPart;
1129      }
1130
1131      const mentions = Array.isArray(pageNode.mentions)
1132        ? pageNode.mentions
1133        : pageNode.mentions
1134          ? [pageNode.mentions]
1135          : [];
1136
1137      if (!mentions.some((mention) => mention && mention['@id'] === listId)) {
1138        mentions.push({ '@id': listId });
1139      }
1140
1141      pageNode.mentions = mentions;
1142    }
1143
1144    const brandNodes = calledBrandNodes(roots, graphNodes);
1145    let itemListBrandNodes = brandNodes;
1146
1147    if (brandDetail) {
1148      // The canonical Brand node already lives in the master graph (registered
1149      // by PHP). Emit only @id references in the ItemList block so the same
1150      // Brand is not duplicated as a second full node on the page.
1151      const masterBrandIds = new Set(
1152        payload['@graph']
1153          .map((node) => node && node['@id'])
1154          .filter(Boolean)
1155      );
1156
1157      itemListBrandNodes = brandNodes.filter(
1158        (node) => node && !masterBrandIds.has(node['@id'])
1159      );
1160    }
1161
1162    writeItemListSchema(
1163      itemList,
1164      itemListBrandNodes
1165    );
1166
1167    // Emit only the OfferShippingDetails nodes referenced by visible Offers,
1168    // each exactly once, from the server-localized definitions. Pages without
1169    // Product Offers (such as the /brands/ directory) reference none, so no
1170    // shipping nodes are added.
1171    const shippingRegistry = shippingNodeRegistry();
1172    const existingGraphIds = new Set(
1173      payload['@graph'].map((node) => node && node['@id']).filter(Boolean)
1174    );
1175    const emittedShippingIds = [];
1176
1177    shippingIdsFromProducts(products).forEach((id) => {
1178      if (!existingGraphIds.has(id) && shippingRegistry[id]) {
1179        payload['@graph'].push(shippingRegistry[id]);
1180        existingGraphIds.add(id);
1181        emittedShippingIds.push(id);
1182      }
1183    });
1184
1185    generatedShippingIds = emittedShippingIds;
1186
1187    const nextGraph = JSON.stringify(payload);
1188
1189    if (graphElement.textContent !== nextGraph) {
1190      graphElement.textContent = nextGraph;
1191    }
1192
1193    return true;
1194  }
1195
1196  function scheduleUpdate() {
1197    // Many DOM mutations happen during one render. Debouncing combines them
1198    // into one graph rebuild and avoids unnecessary repeated JSON serialization.
1199    window.clearTimeout(updateTimer);
1200    updateTimer = window.setTimeout(updateCollectionGraph, 250);
1201  }
1202
1203  function isProductMutation(mutation) {
1204    const selector =
1205      '#algolia-hits, #c7-content, .c7-product-collection, ' +
1206      '.c7-product-collection__products, [data-product-name][data-product-url]';
1207    const target = mutation.target;
1208
1209    if (
1210      target &&
1211      target.nodeType === 1 &&
1212      (target.matches(selector) || target.closest(selector))
1213    ) {
1214      return true;
1215    }
1216
1217    return Array.from(mutation.addedNodes || []).some((node) => {
1218      return (
1219        node &&
1220        node.nodeType === 1 &&
1221        (node.matches(selector) || node.querySelector(selector))
1222      );
1223    });
1224  }
1225
1226  function initialize() {
1227    updateCollectionGraph();
1228
1229    // Observe the full document because Commerce7/Algolia containers may
1230    // themselves be inserted asynchronously after DOMContentLoaded. Ignore
1231    // unrelated analytics, review-widget, navigation, and layout mutations.
1232    const observer = new MutationObserver((mutations) => {
1233      if (mutations.some(isProductMutation)) {
1234        scheduleUpdate();
1235      }
1236    });
1237
1238    observer.observe(document.body, {
1239      childList: true,
1240      subtree: true,
1241      attributes: true,
1242      attributeFilter: ['class', 'data-sku', 'data-product-url', 'data-schema-brand-key', 'data-schema-shipping-profile-key'],
1243    });
1244
1245    window.setTimeout(updateCollectionGraph, 1000);
1246    window.setTimeout(updateCollectionGraph, 2500);
1247  }
1248
1249  // Second line of defense behind the PHP enqueue gate: a product detail page
1250  // is owned by commerce7-product-schema.js, so this script stays inert there.
1251  function isProductDetailPage() {
1252    if (/^\/product\//i.test(window.location.pathname || '')) {
1253      return true;
1254    }
1255
1256    const body = document.body;
1257
1258    return Boolean(
1259      body &&
1260      (body.classList.contains('page-template-commerce7-product') ||
1261        body.classList.contains(
1262          'page-template-commerce7-collection-merchandise-detail'
1263        ))
1264    );
1265  }
1266
1267  if (isProductDetailPage()) {
1268    return;
1269  }
1270
1271  if (document.readyState === 'loading') {
1272    document.addEventListener('DOMContentLoaded', initialize);
1273  } else {
1274    initialize();
1275  }
1276}());

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.