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.