@blamejs/blamejs-shop 0.3.48 → 0.3.50
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +4 -0
- package/lib/asset-manifest.json +5 -5
- package/lib/locale-router.js +39 -0
- package/lib/storefront.js +514 -101
- package/lib/translations.js +4 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.3.x
|
|
10
10
|
|
|
11
|
+
- v0.3.50 (2026-06-02) — **Installable PWA manifest, hreflang for multilingual stores, complete sitemap and robots coverage, localized edge pages, and product Q&A rich results.** Several search-engine and internationalization signals were missing or inconsistent between the cached (edge) and dynamically rendered pages. This release serves an installable web-app manifest and a service worker, emits hreflang alternates when a store runs path- or subdomain-based locales, localizes the blog, CMS, and policy pages on the cached path (they were always English), brings the robots.txt and sitemap into agreement across both render paths with collections, categories, and CMS pages now included, and emits Q&A structured data on product pages. It also fixes a bug that dropped every product from the dynamically rendered sitemap. **Added:** *Installable web-app manifest and service worker* — The store now serves a web-app manifest at /manifest.webmanifest and a service worker at /sw.js, and links the manifest from every page, so the storefront can be installed as a progressive web app. A working default is served out of the box; an operator who configures a custom manifest overrides it. · *hreflang alternates for multilingual stores* — When a store runs locales via URL prefixes (e.g. /de/) or subdomains, each page now emits rel=alternate hreflang links (plus x-default) so search engines map the equivalent localized URLs instead of treating them as duplicate content. Stores on a single locale, or locale-by-cookie, emit none. · *Product Q&A rich results* — A product page with answered customer questions now emits QAPage structured data so the questions and answers are eligible for rich results in search. **Fixed:** *Localized blog, CMS, and policy pages on the cached path* — The cached (edge-rendered) blog articles, CMS pages, and privacy/terms pages now carry the correct document language and direction for the active locale, matching the dynamically rendered pages. Previously they were always marked English and left-to-right, which harmed screen readers and search indexing and broke right-to-left layout. · *Consistent robots.txt and complete sitemap* — robots.txt now disallows the admin, cart, checkout, order, and account paths consistently on both render paths, and the sitemap now includes collection, category, and CMS-page URLs alongside products and blog posts. A bug that made the dynamically rendered sitemap omit every product (a list limit overflow) is fixed so the full catalog is listed.
|
|
12
|
+
|
|
13
|
+
- v0.3.49 (2026-06-02) — **Real collection tiles on the home page, a working mobile nav, Collections/Categories in the header, and From-price on multi-variant products.** The home page's 'Featured collections' band linked to fixed keyword searches that landed on empty results unless a product happened to contain those words, the header navigation disappeared entirely on phones with no menu to replace it, and the two real catalog-browse surfaces (Collections and Categories) were reachable only from the footer. This release resolves those: the home tiles now render the store's actual collections with real links, the header gains a no-JavaScript mobile menu, and Collections and Categories appear in the primary nav. Multi-variant products now show a 'From <lowest price>' headline instead of always showing the first variant's price, the non-functional 'New arrivals'/'On sale' sort links are removed, and two small accessibility fixes land (the survey required-field marker and a dark color-scheme hint for native form controls). **Changed:** *Home 'Featured collections' tiles render real collections* — The featured-collections band on the home page now lists the store's actual collections with links to each collection page, instead of fixed keyword searches that could land on an empty results page. If no collections are defined, the band is omitted rather than shown empty. Both the cached and dynamically rendered home pages produce identical markup. · *Mobile navigation menu (no JavaScript required)* — On narrow screens the header now offers a disclosure menu that reveals the navigation links, which previously vanished entirely below a tablet width. It is a pure-CSS menu, so it works with JavaScript disabled. · *Collections and Categories in the header navigation* — The two catalog-browse surfaces — Collections and Categories — are now in the primary header navigation (and inside the mobile menu), not only in the footer. · *Multi-variant products show a 'From' price* — A product whose variants differ in price now shows a 'From <lowest price>' headline so the prominent price is no longer misleading when a shopper selects a different variant; each variant chip still shows its own exact price. · *Removed non-functional sort links* — The 'New arrivals' and 'On sale' links did nothing — the home page does not yet support sorting — so they have been removed rather than left as dead controls. They will return if and when catalog sorting is added. · *Accessibility: survey required marker and dark color-scheme* — The survey form's required-field marker is now announced correctly to screen readers, and the theme declares a dark color-scheme so native form controls, scrollbars, and autofill render correctly on the dark surface.
|
|
14
|
+
|
|
11
15
|
- v0.3.48 (2026-06-02) — **Customers can export or request deletion of their personal data, and the admin console gets a privacy-request queue to fulfil it.** The framework had a complete data-subject-request engine but nothing reached it, so a store handling EU/California personal data had no way for a shopper to exercise their right of access or erasure and no operator path to fulfil one. A signed-in customer can now request a full export of their data — account, addresses, orders, subscriptions, loyalty, and support history, streamed as a downloadable JSON bundle — and can file a deletion request. The admin console gets a Privacy requests screen where an operator reviews each request, downloads the export, previews a deletion (a dry run that changes nothing), and then executes it. Erasure anonymizes the customer's account and archives their addresses and subscriptions while retaining orders, loyalty ledger, and support tickets that must be kept for tax and legal records. A customer can only ever see or act on their own requests; deletion is always filed by the customer and carried out by an operator. **Added:** *Customer data export and deletion requests* — A signed-in customer gets a Privacy & data area under their account where they can request a full export of their personal data and file a deletion request. The export is streamed as a downloadable JSON bundle covering their account, addresses, orders, subscriptions, loyalty balance and ledger, and support history. A customer can only access their own requests and downloads; a request belonging to anyone else returns not-found. · *Privacy-request queue in the admin console* — A new Privacy requests screen lists every data-subject request with its status. An operator can download a request's export, preview a deletion as a dry run that reports what would change without modifying anything, and then execute the deletion behind a confirmation step. Deletion anonymizes the customer's account and archives their addresses and subscriptions, while orders, the loyalty ledger, and support tickets are retained for tax and legal record-keeping. Every customer- and operator-entered field shown in either console is HTML-escaped.
|
|
12
16
|
|
|
13
17
|
- v0.3.47 (2026-06-01) — **Admin product detail, collections list, and order shipment panels load with fewer database round trips.** Three admin-console screens re-derived data with one database query per item: the product-detail screen ran a query per variant per currency for current and historical prices, the collections list pulled up to 200 member rows for each collection just to count them, and the order-detail shipment panel hydrated each shipment and its labels sequentially. These now read in batched or parallel queries. The displayed data is unchanged, with one correctness improvement: a manual collection with more than 200 members now shows its true product count instead of a number capped at 200. **Changed:** *Batched admin product-detail price hydration* — The admin product-detail screen now loads every variant's current and historical prices across all currencies in a single batched query instead of one query per variant per currency. The screen renders identically. · *Collections list counts without pulling member rows* — The admin collections list now counts a manual collection's members with a COUNT query instead of fetching up to 200 member rows per collection. A manual collection with more than 200 members now shows its true count (previously the displayed count was capped at 200); rule-based collections keep their existing bounded preview count. · *Parallel shipment hydration on order detail* — The order-detail shipment panel now hydrates each shipment and its labels in parallel rather than one after another, so an order with several shipments opens faster.
|
package/lib/asset-manifest.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.3.
|
|
2
|
+
"version": "0.3.50",
|
|
3
3
|
"assets": {
|
|
4
4
|
"css/admin.css": {
|
|
5
|
-
"integrity": "sha384
|
|
6
|
-
"fingerprinted": "css/admin.
|
|
5
|
+
"integrity": "sha384-/NizdENQkTc96romD0kmnl8220NAvW32QI0aSFuGiPbWE5lYbRxLXU5shsjlx5Ek",
|
|
6
|
+
"fingerprinted": "css/admin.f028b2e410387546.css"
|
|
7
7
|
},
|
|
8
8
|
"css/main.css": {
|
|
9
|
-
"integrity": "sha384-
|
|
10
|
-
"fingerprinted": "css/main.
|
|
9
|
+
"integrity": "sha384-9rtEz2wKJbpfALccIipRdo/BWVQZi5O4IufqaLO/7jFyLK1xxCaDc/65Apjy8dbs",
|
|
10
|
+
"fingerprinted": "css/main.1a1a379d6f58e2c0.css"
|
|
11
11
|
},
|
|
12
12
|
"js/announcement.js": {
|
|
13
13
|
"integrity": "sha384-z4zcEMn+tScoVnYRE4nEf8N/oyvpxdpaxTNrT4QO/jURChid4+qjAvWkzatCaAPq",
|
package/lib/locale-router.js
CHANGED
|
@@ -1042,11 +1042,50 @@ function create(opts) {
|
|
|
1042
1042
|
return out;
|
|
1043
1043
|
}
|
|
1044
1044
|
|
|
1045
|
+
// Build the hreflang alternate set for `path` on `host` under the
|
|
1046
|
+
// active policy. Returns
|
|
1047
|
+
// { default_locale, x_default_url, alternates: [{ tag, href }, …] }
|
|
1048
|
+
// when an active `url_prefix` / `subdomain` policy exists — one entry per
|
|
1049
|
+
// supported locale, each `href` the `_canonicalForUrl` for that tag, plus
|
|
1050
|
+
// the `x_default_url` for the default locale. Returns null for a non-URL
|
|
1051
|
+
// strategy (cookie / accept-language), a single-locale policy, no active
|
|
1052
|
+
// policy, or a malformed host/path. Request-shape reader: never throws on
|
|
1053
|
+
// bad input (a hostile Host header must not 500 a page render) — it
|
|
1054
|
+
// returns null so callers emit no hreflang.
|
|
1055
|
+
async function alternateUrls(host, path) {
|
|
1056
|
+
var h, p;
|
|
1057
|
+
try { h = _host(host, "host"); } catch (_e) { return null; }
|
|
1058
|
+
try { p = _path(path, "path"); } catch (_e) { return null; }
|
|
1059
|
+
var policy;
|
|
1060
|
+
try { policy = await activePolicy(); } catch (_e) { return null; }
|
|
1061
|
+
if (!policy) return null;
|
|
1062
|
+
var strategy = policy.strategy;
|
|
1063
|
+
if (strategy !== "url_prefix" && strategy !== "subdomain") return null;
|
|
1064
|
+
var supported = policy.supported_locales;
|
|
1065
|
+
if (!Array.isArray(supported) || supported.length < 2) return null;
|
|
1066
|
+
var defaultLocale = policy.default_locale;
|
|
1067
|
+
var alternates = [];
|
|
1068
|
+
for (var i = 0; i < supported.length; i += 1) {
|
|
1069
|
+
var tag = supported[i];
|
|
1070
|
+
var href = _canonicalForUrl(h, p, tag, strategy);
|
|
1071
|
+
if (href === undefined) continue;
|
|
1072
|
+
alternates.push({ tag: tag, href: href });
|
|
1073
|
+
}
|
|
1074
|
+
if (!alternates.length) return null;
|
|
1075
|
+
var xDefault = _canonicalForUrl(h, p, defaultLocale, strategy);
|
|
1076
|
+
return {
|
|
1077
|
+
default_locale: defaultLocale,
|
|
1078
|
+
x_default_url: xDefault === undefined ? null : xDefault,
|
|
1079
|
+
alternates: alternates,
|
|
1080
|
+
};
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1045
1083
|
return {
|
|
1046
1084
|
KINDS: KINDS.slice(),
|
|
1047
1085
|
STRATEGIES: STRATEGIES.slice(),
|
|
1048
1086
|
LOCALE_RE: LOCALE_RE,
|
|
1049
1087
|
CURRENCY_RE: CURRENCY_RE,
|
|
1088
|
+
alternateUrls: alternateUrls,
|
|
1050
1089
|
defineLocale: defineLocale,
|
|
1051
1090
|
getLocale: getLocale,
|
|
1052
1091
|
listLocales: listLocales,
|
package/lib/storefront.js
CHANGED
|
@@ -267,9 +267,11 @@ var LAYOUT =
|
|
|
267
267
|
" <meta name=\"description\" content=\"{{og_description}}\">\n" +
|
|
268
268
|
" <link rel=\"canonical\" href=\"{{canonical_url}}\">\n" +
|
|
269
269
|
"RAW_ROBOTS_META" +
|
|
270
|
+
"RAW_HREFLANG" +
|
|
270
271
|
" <link rel=\"icon\" type=\"image/svg+xml\" href=\"/assets/brand/favicon.svg\">\n" +
|
|
271
272
|
" <link rel=\"icon\" type=\"image/png\" href=\"/assets/brand/favicon.png\">\n" +
|
|
272
273
|
" <link rel=\"apple-touch-icon\" href=\"/assets/brand/favicon.png\">\n" +
|
|
274
|
+
" <link rel=\"manifest\" href=\"/manifest.webmanifest\">\n" +
|
|
273
275
|
" <meta name=\"theme-color\" content=\"#08080a\">\n" +
|
|
274
276
|
" <link rel=\"stylesheet\" href=\"{{theme_css}}\"RAW_CSS_INTEGRITY>\n" +
|
|
275
277
|
" <meta property=\"og:type\" content=\"{{og_type}}\">\n" +
|
|
@@ -306,12 +308,7 @@ var LAYOUT =
|
|
|
306
308
|
" <button type=\"submit\">{{search_submit}}</button>\n" +
|
|
307
309
|
" </div>\n" +
|
|
308
310
|
" </form>\n" +
|
|
309
|
-
"
|
|
310
|
-
" <a class=\"site-nav__link\" href=\"/\">{{nav_shop}}</a>\n" +
|
|
311
|
-
" <a class=\"site-nav__link\" href=\"/#framework\">{{nav_framework}}</a>\n" +
|
|
312
|
-
" <a class=\"site-nav__icon\" href=\"/account\" aria-label=\"{{nav_account}}\"><svg viewBox=\"0 0 24 24\" width=\"20\" height=\"20\" aria-hidden=\"true\"><path d=\"M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm-7 9a7 7 0 0 1 14 0\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.75\" stroke-linecap=\"round\"/></svg></a>\n" +
|
|
313
|
-
" <a class=\"cart-pill\" href=\"/cart\" aria-label=\"{{nav_cart_aria}}\"><svg viewBox=\"0 0 24 24\" width=\"18\" height=\"18\" aria-hidden=\"true\"><path d=\"M3 4h2l2.4 12.1a2 2 0 0 0 2 1.6h7.6a2 2 0 0 0 1.95-1.55L21 8H6\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.75\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/><circle cx=\"10\" cy=\"21\" r=\"1.4\" fill=\"currentColor\"/><circle cx=\"17\" cy=\"21\" r=\"1.4\" fill=\"currentColor\"/></svg><span class=\"cart-pill__count\">{{cart_count}}</span></a>\n" +
|
|
314
|
-
" </nav>\n" +
|
|
311
|
+
" RAW_PRIMARY_NAV\n" +
|
|
315
312
|
" </div>\n" +
|
|
316
313
|
" </header>\n" +
|
|
317
314
|
"\n" +
|
|
@@ -349,8 +346,6 @@ var LAYOUT =
|
|
|
349
346
|
" <li><a href=\"/\">{{footer_shop_all}}</a></li>\n" +
|
|
350
347
|
" <li><a href=\"/collections\">{{footer_shop_collections}}</a></li>\n" +
|
|
351
348
|
" <li><a href=\"/categories\">{{footer_shop_categories}}</a></li>\n" +
|
|
352
|
-
" <li><a href=\"/?sort=new\">{{footer_shop_new}}</a></li>\n" +
|
|
353
|
-
" <li><a href=\"/?sort=sale\">{{footer_shop_sale}}</a></li>\n" +
|
|
354
349
|
" <li><a href=\"/compare\">{{footer_shop_compare}}</a></li>\n" +
|
|
355
350
|
" <li><a href=\"/cart\">{{footer_shop_cart}}</a></li>\n" +
|
|
356
351
|
" <li><a href=\"/terms\">{{footer_shop_shipping}}</a></li>\n" +
|
|
@@ -683,6 +678,57 @@ function _requestUrls(req) {
|
|
|
683
678
|
return { canonical_url: origin + path, og_url: origin + path + search };
|
|
684
679
|
}
|
|
685
680
|
|
|
681
|
+
// Whether the Collections / Categories routes are mounted (deps wired).
|
|
682
|
+
// The header links + the mobile-disclosure entries render only when their
|
|
683
|
+
// route exists, so a store that hasn't wired the primitive never shows a
|
|
684
|
+
// link that 404s. Set once at mount (alongside `_ccyEnabled`), read in the
|
|
685
|
+
// nav builder. Default true so a pure render of a page (a unit test calling
|
|
686
|
+
// `renderHome` without mounting) shows the full nav; the route-level
|
|
687
|
+
// conditional only suppresses a link when the mount explicitly lacks the dep.
|
|
688
|
+
var _hasCollections = true;
|
|
689
|
+
var _hasCategoryNav = true;
|
|
690
|
+
|
|
691
|
+
// The account-icon + cart-pill SVGs, lifted verbatim from the prior LAYOUT
|
|
692
|
+
// nav so the builder emits the same chrome. Kept byte-identical to the edge
|
|
693
|
+
// headers (worker/render/*.js).
|
|
694
|
+
var _NAV_ACCOUNT_SVG = "<svg viewBox=\"0 0 24 24\" width=\"20\" height=\"20\" aria-hidden=\"true\"><path d=\"M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm-7 9a7 7 0 0 1 14 0\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.75\" stroke-linecap=\"round\"/></svg>";
|
|
695
|
+
var _NAV_CART_SVG = "<svg viewBox=\"0 0 24 24\" width=\"18\" height=\"18\" aria-hidden=\"true\"><path d=\"M3 4h2l2.4 12.1a2 2 0 0 0 2 1.6h7.6a2 2 0 0 0 1.95-1.55L21 8H6\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.75\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/><circle cx=\"10\" cy=\"21\" r=\"1.4\" fill=\"currentColor\"/><circle cx=\"17\" cy=\"21\" r=\"1.4\" fill=\"currentColor\"/></svg>";
|
|
696
|
+
var _NAV_MENU_SVG = "<svg viewBox=\"0 0 24 24\" width=\"22\" height=\"22\" aria-hidden=\"true\"><path d=\"M4 7h16M4 12h16M4 17h16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.75\" stroke-linecap=\"round\"/></svg>";
|
|
697
|
+
|
|
698
|
+
// Primary nav builder — a desktop link row (`.site-nav__links`) PLUS a
|
|
699
|
+
// CSP-safe <details>/<summary> disclosure (`.site-nav__menu`) carrying the
|
|
700
|
+
// same links for narrow viewports. The CSS shows one or the other by
|
|
701
|
+
// breakpoint (the inline row hides at <=48rem; the disclosure hides above
|
|
702
|
+
// it). No JS — the browser drives open/close. Collections / Categories
|
|
703
|
+
// render only when `hasCollections` / `hasCategories` (their routes mounted).
|
|
704
|
+
// All labels are chrome strings, escaped at the sink. Kept byte-identical to
|
|
705
|
+
// the static nav in every worker/render/*.js header (the edge renders the
|
|
706
|
+
// two extra links unconditionally — accepted divergence; the byte-compare
|
|
707
|
+
// test wires both container deps so the markup matches).
|
|
708
|
+
function _buildNavLinks(chrome, hasCollections, hasCategories, indent) {
|
|
709
|
+
var esc = b.template.escapeHtml;
|
|
710
|
+
return indent + "<a class=\"site-nav__link\" href=\"/\">" + esc(chrome.nav_shop) + "</a>\n" +
|
|
711
|
+
(hasCollections ? indent + "<a class=\"site-nav__link\" href=\"/collections\">" + esc(chrome.nav_collections) + "</a>\n" : "") +
|
|
712
|
+
(hasCategories ? indent + "<a class=\"site-nav__link\" href=\"/categories\">" + esc(chrome.nav_categories) + "</a>\n" : "") +
|
|
713
|
+
indent + "<a class=\"site-nav__link\" href=\"/#framework\">" + esc(chrome.nav_framework) + "</a>\n";
|
|
714
|
+
}
|
|
715
|
+
function _buildPrimaryNav(chrome, cartCount, cartAria, hasCollections, hasCategories) {
|
|
716
|
+
var esc = b.template.escapeHtml;
|
|
717
|
+
return "<nav class=\"site-nav\" aria-label=\"Primary\">\n" +
|
|
718
|
+
" <div class=\"site-nav__links\">\n" +
|
|
719
|
+
_buildNavLinks(chrome, hasCollections, hasCategories, " ") +
|
|
720
|
+
" </div>\n" +
|
|
721
|
+
" <details class=\"site-nav__menu\">\n" +
|
|
722
|
+
" <summary class=\"site-nav__menu-toggle\" aria-label=\"" + esc(chrome.nav_menu) + "\">" + _NAV_MENU_SVG + "<span class=\"site-nav__menu-label\">" + esc(chrome.nav_menu) + "</span></summary>\n" +
|
|
723
|
+
" <div class=\"site-nav__drawer\">\n" +
|
|
724
|
+
_buildNavLinks(chrome, hasCollections, hasCategories, " ") +
|
|
725
|
+
" </div>\n" +
|
|
726
|
+
" </details>\n" +
|
|
727
|
+
" <a class=\"site-nav__icon\" href=\"/account\" aria-label=\"" + esc(chrome.nav_account) + "\">" + _NAV_ACCOUNT_SVG + "</a>\n" +
|
|
728
|
+
" <a class=\"cart-pill\" href=\"/cart\" aria-label=\"" + esc(cartAria) + "\">" + _NAV_CART_SVG + "<span class=\"cart-pill__count\">" + esc(String(cartCount)) + "</span></a>\n" +
|
|
729
|
+
" </nav>";
|
|
730
|
+
}
|
|
731
|
+
|
|
686
732
|
function _wrap(opts) {
|
|
687
733
|
var themeCss = (opts && typeof opts.theme_css === "string" && opts.theme_css.length)
|
|
688
734
|
? opts.theme_css
|
|
@@ -758,7 +804,6 @@ function _wrap(opts) {
|
|
|
758
804
|
var vars = {
|
|
759
805
|
title: opts.title,
|
|
760
806
|
shop_name: shopName,
|
|
761
|
-
cart_count: cartCount,
|
|
762
807
|
year: _COPYRIGHT_YEAR,
|
|
763
808
|
search_q: opts.search_q == null ? "" : opts.search_q,
|
|
764
809
|
theme_css: themeCss,
|
|
@@ -770,19 +815,24 @@ function _wrap(opts) {
|
|
|
770
815
|
canonical_url: canonicalUrl,
|
|
771
816
|
lang: localeCtx.lang || "en",
|
|
772
817
|
dir: localeCtx.dir || "ltr",
|
|
773
|
-
nav_cart_aria: cartAria,
|
|
774
818
|
body: "RAW_BODY_PLACEHOLDER",
|
|
775
819
|
};
|
|
776
|
-
// Layer in every chrome string the LAYOUT references.
|
|
777
|
-
//
|
|
778
|
-
// `
|
|
779
|
-
//
|
|
780
|
-
//
|
|
781
|
-
//
|
|
820
|
+
// Layer in every chrome string the LAYOUT references. The nav chrome
|
|
821
|
+
// strings (`nav_shop` / `nav_collections` / `nav_categories` /
|
|
822
|
+
// `nav_framework` / `nav_account` / `nav_menu` / `nav_cart_aria`) are
|
|
823
|
+
// NOT referenced by the LAYOUT const — the nav is raw-spliced via
|
|
824
|
+
// `RAW_PRIMARY_NAV` from `_buildPrimaryNav` so the Collections/Categories
|
|
825
|
+
// links can render conditionally. The strict `_render` refuses an unused
|
|
826
|
+
// placeholder, so those keys are skipped here. Likewise the two
|
|
827
|
+
// `locale_switcher_*` strings live only inside the switcher form (spliced
|
|
828
|
+
// via RAW_LOCALE_SWITCHER).
|
|
782
829
|
var chromeKeys = Object.keys(chrome);
|
|
783
830
|
for (var ci = 0; ci < chromeKeys.length; ci += 1) {
|
|
784
831
|
var ck = chromeKeys[ci];
|
|
785
|
-
if (ck === "
|
|
832
|
+
if (ck === "nav_shop" || ck === "nav_collections" || ck === "nav_categories" ||
|
|
833
|
+
ck === "nav_framework" || ck === "nav_account" || ck === "nav_menu" ||
|
|
834
|
+
ck === "nav_cart_aria" ||
|
|
835
|
+
ck === "locale_switcher_label" || ck === "locale_switcher_submit") continue;
|
|
786
836
|
vars[ck] = chrome[ck];
|
|
787
837
|
}
|
|
788
838
|
|
|
@@ -806,9 +856,20 @@ function _wrap(opts) {
|
|
|
806
856
|
? " <meta name=\"robots\" content=\"noindex,nofollow\">\n"
|
|
807
857
|
: "";
|
|
808
858
|
|
|
859
|
+
// hreflang alternates — pre-rendered HTML seeded onto the locale ALS
|
|
860
|
+
// store by `localeMiddleware` (kept synchronous so it reaches the
|
|
861
|
+
// handler), or threaded explicitly by a renderer/unit test. Empty for a
|
|
862
|
+
// single-locale store or a cookie/accept-language strategy (no URL-shaped
|
|
863
|
+
// alternates to emit). Byte-identical to the edge's `alternateLinks`
|
|
864
|
+
// output for the same (host, path, supported, default, strategy) — a
|
|
865
|
+
// parity test pins the two.
|
|
866
|
+
var hreflangHtml = (typeof opts.alternate_links === "string")
|
|
867
|
+
? opts.alternate_links
|
|
868
|
+
: ((storeCtx && typeof storeCtx.alternate_links === "string") ? storeCtx.alternate_links : "");
|
|
809
869
|
var assembled = _render(LAYOUT, vars)
|
|
810
870
|
.replace("RAW_CSS_INTEGRITY", themeCssIntegrity)
|
|
811
871
|
.replace("RAW_ROBOTS_META", robotsMeta)
|
|
872
|
+
.replace("RAW_HREFLANG", hreflangHtml)
|
|
812
873
|
.replace("RAW_CONSENT_SCRIPT", _islandScript("consent.js", { id: "consent-island", policy: _activeConsentPolicy }))
|
|
813
874
|
.replace("RAW_CART_COUNT_SCRIPT", _islandScript("cart-count.js", { id: "cart-count-island" }))
|
|
814
875
|
.replace("RAW_ANNOUNCEMENT_SCRIPT", announcementScript)
|
|
@@ -821,6 +882,14 @@ function _wrap(opts) {
|
|
|
821
882
|
// Matches the edge renderers' `spliceRaw` so the dual-render stays
|
|
822
883
|
// byte-consistent under a `$`-bearing announcement. See `_spliceRaw`.
|
|
823
884
|
assembled = _spliceRaw(assembled, "RAW_ANNOUNCEMENT_BAR", announcementBarHtml);
|
|
885
|
+
// Primary nav — a raw splice (post strict-render) so the chrome strings
|
|
886
|
+
// it consumes (nav_shop / nav_collections / nav_categories /
|
|
887
|
+
// nav_framework / nav_account / nav_menu / nav_cart_aria) need no LAYOUT
|
|
888
|
+
// `{{}}` placeholder, and the Collections/Categories links render only
|
|
889
|
+
// when their routes are mounted. Escaped values are spliced via the
|
|
890
|
+
// replacer-function helper so a `$`-sequence lands literally.
|
|
891
|
+
assembled = _spliceRaw(assembled, "RAW_PRIMARY_NAV",
|
|
892
|
+
_buildPrimaryNav(chrome, cartCount, cartAria, _hasCollections, _hasCategoryNav));
|
|
824
893
|
// The body is RAW HTML (already rendered + escaped at the
|
|
825
894
|
// per-fragment level). The placeholder swap is post-render so the
|
|
826
895
|
// outer renderer's HTML-escape doesn't double-escape the inner
|
|
@@ -905,6 +974,60 @@ function _buildRelatedProducts(related) {
|
|
|
905
974
|
"</section>";
|
|
906
975
|
}
|
|
907
976
|
|
|
977
|
+
// Home "Featured collections" band — the six decorative card-art SVGs
|
|
978
|
+
// lifted verbatim from the prior static band, indexed 1..6. The dynamic
|
|
979
|
+
// builder rotates through them by position so an operator's collections
|
|
980
|
+
// keep the band's designed look without per-collection iconography. Kept
|
|
981
|
+
// byte-identical to worker/render/home.js#COLLECTION_BAND_ART so the dual
|
|
982
|
+
// render agrees.
|
|
983
|
+
var COLLECTION_BAND_ART = [
|
|
984
|
+
"<div class=\"collection-card__art collection-card__art--1\" aria-hidden=\"true\"><svg class=\"collection-card__icon\" viewBox=\"0 0 160 120\" fill=\"none\" stroke=\"#AD38DB\" stroke-width=\"2.4\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M70 38 C74 44 86 44 90 38 L104 44 L112 58 L100 64 L96 58 L96 92 L64 92 L64 58 L60 64 L48 58 L56 44 Z\"/><path d=\"M71 40 C75 47 85 47 89 40\" stroke=\"#732A8D\" stroke-width=\"2\"/><path d=\"M73 76 H87\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-dasharray=\"2 3\"/></svg></div>",
|
|
985
|
+
"<div class=\"collection-card__art collection-card__art--2\" aria-hidden=\"true\"><svg class=\"collection-card__icon\" viewBox=\"0 0 160 120\" fill=\"none\" stroke=\"#AD38DB\" stroke-width=\"2.4\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><rect x=\"58\" y=\"38\" width=\"44\" height=\"44\" rx=\"4\"/><rect x=\"70\" y=\"50\" width=\"20\" height=\"20\" rx=\"2\" stroke=\"#732A8D\"/><circle cx=\"80\" cy=\"60\" r=\"3\" fill=\"#AD38DB\" stroke=\"none\"/><path d=\"M66 38 V30 M76 38 V30 M86 38 V30 M96 38 V30\" stroke=\"currentColor\" stroke-width=\"2\"/><path d=\"M66 82 V90 M76 82 V90 M86 82 V90 M96 82 V90\" stroke=\"currentColor\" stroke-width=\"2\"/><path d=\"M58 48 H50 M58 60 H50 M58 72 H50\" stroke=\"currentColor\" stroke-width=\"2\"/><path d=\"M102 48 H110 M102 60 H110 M102 72 H110\" stroke=\"currentColor\" stroke-width=\"2\"/></svg></div>",
|
|
986
|
+
"<div class=\"collection-card__art collection-card__art--3\" aria-hidden=\"true\"><svg class=\"collection-card__icon\" viewBox=\"0 0 160 120\" fill=\"none\" stroke=\"#AD38DB\" stroke-width=\"2.4\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M60 74 C50 74 48 62 57 59 C56 47 73 43 79 53 C88 47 100 54 97 64 C107 65 107 74 99 74 Z\" stroke=\"#732A8D\"/><path d=\"M78 60 V86\"/><path d=\"M70 78 L78 88 L86 78\"/><path d=\"M64 98 H92\" stroke=\"currentColor\" stroke-width=\"2\"/></svg></div>",
|
|
987
|
+
"<div class=\"collection-card__art collection-card__art--4\" aria-hidden=\"true\"><svg class=\"collection-card__icon\" viewBox=\"0 0 160 120\" fill=\"none\" stroke=\"#AD38DB\" stroke-width=\"2.4\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M56 50 A26 26 0 0 1 104 56\"/><path d=\"M104 70 A26 26 0 0 1 56 64\"/><path d=\"M104 42 L106 57 L91 55\" stroke=\"#AD38DB\"/><path d=\"M56 78 L54 63 L69 65\" stroke=\"#AD38DB\"/><circle cx=\"80\" cy=\"60\" r=\"6\" stroke=\"#732A8D\"/><circle cx=\"80\" cy=\"60\" r=\"1.6\" fill=\"currentColor\" stroke=\"none\"/></svg></div>",
|
|
988
|
+
"<div class=\"collection-card__art collection-card__art--5\" aria-hidden=\"true\"><svg class=\"collection-card__icon\" viewBox=\"0 0 160 120\" fill=\"none\" stroke=\"#AD38DB\" stroke-width=\"2.4\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M86 36 L106 44 L106 64 L86 72 L66 64 L66 44 Z\" stroke=\"#732A8D\"/><path d=\"M66 44 L86 52 L106 44 M86 52 V72\" stroke=\"#732A8D\"/><path d=\"M70 56 L90 64 L90 88 L70 96 L50 88 L50 64 Z\"/><path d=\"M50 64 L70 72 L90 64 M70 72 V96\"/><path d=\"M70 72 V96\" stroke=\"currentColor\" stroke-width=\"2\"/></svg></div>",
|
|
989
|
+
"<div class=\"collection-card__art collection-card__art--6\" aria-hidden=\"true\"><svg class=\"collection-card__icon\" viewBox=\"0 0 160 120\" fill=\"none\" stroke=\"#AD38DB\" stroke-width=\"2.4\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><rect x=\"48\" y=\"48\" width=\"64\" height=\"42\" rx=\"6\"/><path d=\"M80 48 V90\" stroke=\"#732A8D\"/><path d=\"M80 48 C71 36 56 39 62 49 C57 52 61 57 71 53 C76 51 80 50 80 48 Z\"/><path d=\"M80 48 C89 36 104 39 98 49 C103 52 99 57 89 53 C84 51 80 50 80 48 Z\"/><circle cx=\"80\" cy=\"48\" r=\"2.4\" fill=\"#AD38DB\" stroke=\"none\"/><rect x=\"56\" y=\"70\" width=\"11\" height=\"8\" rx=\"1.6\" stroke=\"currentColor\" stroke-width=\"1.8\"/></svg></div>",
|
|
990
|
+
];
|
|
991
|
+
|
|
992
|
+
// Home "Featured collections" band builder — operator collections (active,
|
|
993
|
+
// newest-curated first via collections.list, capped at 6) rendered as the
|
|
994
|
+
// existing collection-card grid. Each card links to /collections/<slug>;
|
|
995
|
+
// the decorative per-card art rotates through COLLECTION_BAND_ART by index.
|
|
996
|
+
// Returns "" when there are no collections to show — the caller drops the
|
|
997
|
+
// whole section, so a store with no collections shows hero → catalog with
|
|
998
|
+
// no empty band. Operator slug/title/description are escaped at the sink
|
|
999
|
+
// (escape-by-default; cross-customer free-text rendered to every visitor).
|
|
1000
|
+
// A theme that owns its own home template never reaches this band; the
|
|
1001
|
+
// data is still threaded so a theme can add a collections slot later.
|
|
1002
|
+
// Kept byte-identical to worker/render/home.js#_buildCollectionsBand.
|
|
1003
|
+
function _buildCollectionsBand(collections) {
|
|
1004
|
+
var esc = b.template.escapeHtml;
|
|
1005
|
+
var cols = Array.isArray(collections) ? collections.slice(0, 6) : [];
|
|
1006
|
+
if (cols.length === 0) return "";
|
|
1007
|
+
var cards = "";
|
|
1008
|
+
for (var i = 0; i < cols.length; i += 1) {
|
|
1009
|
+
var c = cols[i];
|
|
1010
|
+
var art = COLLECTION_BAND_ART[i % COLLECTION_BAND_ART.length];
|
|
1011
|
+
cards +=
|
|
1012
|
+
" <a class=\"collection-card\" href=\"/collections/" + esc(c.slug) + "\">\n" +
|
|
1013
|
+
" " + art + "\n" +
|
|
1014
|
+
" <div class=\"collection-card__meta\">\n" +
|
|
1015
|
+
" <h3>" + esc(c.title) + "</h3>\n" +
|
|
1016
|
+
(c.description ? " <p>" + esc(c.description) + "</p>\n" : "") +
|
|
1017
|
+
" </div>\n" +
|
|
1018
|
+
" </a>\n";
|
|
1019
|
+
}
|
|
1020
|
+
return "<section class=\"collections\" aria-labelledby=\"collections-title\">\n" +
|
|
1021
|
+
" <header class=\"section-head\">\n" +
|
|
1022
|
+
" <p class=\"eyebrow\">Featured collections</p>\n" +
|
|
1023
|
+
" <h2 id=\"collections-title\" class=\"section-head__title\">Browse the catalog by collection.</h2>\n" +
|
|
1024
|
+
" </header>\n" +
|
|
1025
|
+
" <div class=\"collections__grid\">\n" +
|
|
1026
|
+
cards +
|
|
1027
|
+
" </div>\n" +
|
|
1028
|
+
"</section>\n";
|
|
1029
|
+
}
|
|
1030
|
+
|
|
908
1031
|
var HOME_HERO =
|
|
909
1032
|
"<section class=\"hero hero--dark\">\n" +
|
|
910
1033
|
" <div class=\"hero__bg\" aria-hidden=\"true\">\n" +
|
|
@@ -983,57 +1106,7 @@ var HOME_HERO =
|
|
|
983
1106
|
"\n" +
|
|
984
1107
|
"RAW_FEATURED_CALLOUT\n" +
|
|
985
1108
|
"\n" +
|
|
986
|
-
"
|
|
987
|
-
" <header class=\"section-head\">\n" +
|
|
988
|
-
" <p class=\"eyebrow\">Featured collections</p>\n" +
|
|
989
|
-
" <h2 id=\"collections-title\" class=\"section-head__title\">Shaped for a storefront that ships from day one.</h2>\n" +
|
|
990
|
-
" <p class=\"section-head__lede\">Drop products into any of these starting categories — or define your own taxonomy through the catalog admin and the framework will server-render the grids, filters, and PDP routes for free.</p>\n" +
|
|
991
|
-
" </header>\n" +
|
|
992
|
-
" <div class=\"collections__grid\">\n" +
|
|
993
|
-
" <a class=\"collection-card\" href=\"/search?q=tee\">\n" +
|
|
994
|
-
" <div class=\"collection-card__art collection-card__art--1\" aria-hidden=\"true\"><svg class=\"collection-card__icon\" viewBox=\"0 0 160 120\" fill=\"none\" stroke=\"#AD38DB\" stroke-width=\"2.4\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M70 38 C74 44 86 44 90 38 L104 44 L112 58 L100 64 L96 58 L96 92 L64 92 L64 58 L60 64 L48 58 L56 44 Z\"/><path d=\"M71 40 C75 47 85 47 89 40\" stroke=\"#732A8D\" stroke-width=\"2\"/><path d=\"M73 76 H87\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-dasharray=\"2 3\"/></svg></div>\n" +
|
|
995
|
-
" <div class=\"collection-card__meta\">\n" +
|
|
996
|
-
" <h3>Apparel</h3>\n" +
|
|
997
|
-
" <p>Sized, colored, inventoried.</p>\n" +
|
|
998
|
-
" </div>\n" +
|
|
999
|
-
" </a>\n" +
|
|
1000
|
-
" <a class=\"collection-card\" href=\"/search?q=edge\">\n" +
|
|
1001
|
-
" <div class=\"collection-card__art collection-card__art--2\" aria-hidden=\"true\"><svg class=\"collection-card__icon\" viewBox=\"0 0 160 120\" fill=\"none\" stroke=\"#AD38DB\" stroke-width=\"2.4\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><rect x=\"58\" y=\"38\" width=\"44\" height=\"44\" rx=\"4\"/><rect x=\"70\" y=\"50\" width=\"20\" height=\"20\" rx=\"2\" stroke=\"#732A8D\"/><circle cx=\"80\" cy=\"60\" r=\"3\" fill=\"#AD38DB\" stroke=\"none\"/><path d=\"M66 38 V30 M76 38 V30 M86 38 V30 M96 38 V30\" stroke=\"currentColor\" stroke-width=\"2\"/><path d=\"M66 82 V90 M76 82 V90 M86 82 V90 M96 82 V90\" stroke=\"currentColor\" stroke-width=\"2\"/><path d=\"M58 48 H50 M58 60 H50 M58 72 H50\" stroke=\"currentColor\" stroke-width=\"2\"/><path d=\"M102 48 H110 M102 60 H110 M102 72 H110\" stroke=\"currentColor\" stroke-width=\"2\"/></svg></div>\n" +
|
|
1002
|
-
" <div class=\"collection-card__meta\">\n" +
|
|
1003
|
-
" <h3>Hardware</h3>\n" +
|
|
1004
|
-
" <p>Serialized, warranty-tracked.</p>\n" +
|
|
1005
|
-
" </div>\n" +
|
|
1006
|
-
" </a>\n" +
|
|
1007
|
-
" <a class=\"collection-card\" href=\"/search?q=license\">\n" +
|
|
1008
|
-
" <div class=\"collection-card__art collection-card__art--3\" aria-hidden=\"true\"><svg class=\"collection-card__icon\" viewBox=\"0 0 160 120\" fill=\"none\" stroke=\"#AD38DB\" stroke-width=\"2.4\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M60 74 C50 74 48 62 57 59 C56 47 73 43 79 53 C88 47 100 54 97 64 C107 65 107 74 99 74 Z\" stroke=\"#732A8D\"/><path d=\"M78 60 V86\"/><path d=\"M70 78 L78 88 L86 78\"/><path d=\"M64 98 H92\" stroke=\"currentColor\" stroke-width=\"2\"/></svg></div>\n" +
|
|
1009
|
-
" <div class=\"collection-card__meta\">\n" +
|
|
1010
|
-
" <h3>Digital</h3>\n" +
|
|
1011
|
-
" <p>License-key fulfillment.</p>\n" +
|
|
1012
|
-
" </div>\n" +
|
|
1013
|
-
" </a>\n" +
|
|
1014
|
-
" <a class=\"collection-card\" href=\"/search?q=subscription\">\n" +
|
|
1015
|
-
" <div class=\"collection-card__art collection-card__art--4\" aria-hidden=\"true\"><svg class=\"collection-card__icon\" viewBox=\"0 0 160 120\" fill=\"none\" stroke=\"#AD38DB\" stroke-width=\"2.4\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M56 50 A26 26 0 0 1 104 56\"/><path d=\"M104 70 A26 26 0 0 1 56 64\"/><path d=\"M104 42 L106 57 L91 55\" stroke=\"#AD38DB\"/><path d=\"M56 78 L54 63 L69 65\" stroke=\"#AD38DB\"/><circle cx=\"80\" cy=\"60\" r=\"6\" stroke=\"#732A8D\"/><circle cx=\"80\" cy=\"60\" r=\"1.6\" fill=\"currentColor\" stroke=\"none\"/></svg></div>\n" +
|
|
1016
|
-
" <div class=\"collection-card__meta\">\n" +
|
|
1017
|
-
" <h3>Subscriptions</h3>\n" +
|
|
1018
|
-
" <p>Stripe-backed recurring.</p>\n" +
|
|
1019
|
-
" </div>\n" +
|
|
1020
|
-
" </a>\n" +
|
|
1021
|
-
" <a class=\"collection-card\" href=\"/search?q=bundle\">\n" +
|
|
1022
|
-
" <div class=\"collection-card__art collection-card__art--5\" aria-hidden=\"true\"><svg class=\"collection-card__icon\" viewBox=\"0 0 160 120\" fill=\"none\" stroke=\"#AD38DB\" stroke-width=\"2.4\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M86 36 L106 44 L106 64 L86 72 L66 64 L66 44 Z\" stroke=\"#732A8D\"/><path d=\"M66 44 L86 52 L106 44 M86 52 V72\" stroke=\"#732A8D\"/><path d=\"M70 56 L90 64 L90 88 L70 96 L50 88 L50 64 Z\"/><path d=\"M50 64 L70 72 L90 64 M70 72 V96\"/><path d=\"M70 72 V96\" stroke=\"currentColor\" stroke-width=\"2\"/></svg></div>\n" +
|
|
1023
|
-
" <div class=\"collection-card__meta\">\n" +
|
|
1024
|
-
" <h3>Bundles</h3>\n" +
|
|
1025
|
-
" <p>Composite SKUs, atomic stock.</p>\n" +
|
|
1026
|
-
" </div>\n" +
|
|
1027
|
-
" </a>\n" +
|
|
1028
|
-
" <a class=\"collection-card\" href=\"/search?q=gift\">\n" +
|
|
1029
|
-
" <div class=\"collection-card__art collection-card__art--6\" aria-hidden=\"true\"><svg class=\"collection-card__icon\" viewBox=\"0 0 160 120\" fill=\"none\" stroke=\"#AD38DB\" stroke-width=\"2.4\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><rect x=\"48\" y=\"48\" width=\"64\" height=\"42\" rx=\"6\"/><path d=\"M80 48 V90\" stroke=\"#732A8D\"/><path d=\"M80 48 C71 36 56 39 62 49 C57 52 61 57 71 53 C76 51 80 50 80 48 Z\"/><path d=\"M80 48 C89 36 104 39 98 49 C103 52 99 57 89 53 C84 51 80 50 80 48 Z\"/><circle cx=\"80\" cy=\"48\" r=\"2.4\" fill=\"#AD38DB\" stroke=\"none\"/><rect x=\"56\" y=\"70\" width=\"11\" height=\"8\" rx=\"1.6\" stroke=\"currentColor\" stroke-width=\"1.8\"/></svg></div>\n" +
|
|
1030
|
-
" <div class=\"collection-card__meta\">\n" +
|
|
1031
|
-
" <h3>Gift cards</h3>\n" +
|
|
1032
|
-
" <p>PQC-signed redemption codes.</p>\n" +
|
|
1033
|
-
" </div>\n" +
|
|
1034
|
-
" </a>\n" +
|
|
1035
|
-
" </div>\n" +
|
|
1036
|
-
"</section>\n" +
|
|
1109
|
+
"RAW_COLLECTIONS_BAND\n" +
|
|
1037
1110
|
"\n" +
|
|
1038
1111
|
"<section id=\"framework\" class=\"framework-band\" aria-labelledby=\"framework-title\">\n" +
|
|
1039
1112
|
" <div class=\"framework-band__inner\">\n" +
|
|
@@ -1092,13 +1165,12 @@ var CATALOG_EMPTY =
|
|
|
1092
1165
|
|
|
1093
1166
|
var CATALOG_HEAD =
|
|
1094
1167
|
"<section id=\"catalog\" class=\"catalog-section\">\n" +
|
|
1095
|
-
" <header class=\"section-head
|
|
1168
|
+
" <header class=\"section-head\">\n" +
|
|
1096
1169
|
" <div>\n" +
|
|
1097
1170
|
" <p class=\"eyebrow\">Catalog</p>\n" +
|
|
1098
1171
|
" <h2 class=\"section-head__title\">Products in store</h2>\n" +
|
|
1099
1172
|
" <p class=\"section-head__lede\">Server-rendered listings — every card, price, and link arrived on the wire as complete HTML.</p>\n" +
|
|
1100
1173
|
" </div>\n" +
|
|
1101
|
-
" <a class=\"link-arrow\" href=\"/?sort=new\">New arrivals <span aria-hidden=\"true\">→</span></a>\n" +
|
|
1102
1174
|
" </header>\n" +
|
|
1103
1175
|
" <div class=\"grid\">{{cards}}</div>\n" +
|
|
1104
1176
|
"</section>\n";
|
|
@@ -1181,8 +1253,15 @@ function renderHome(opts) {
|
|
|
1181
1253
|
"</section>";
|
|
1182
1254
|
}
|
|
1183
1255
|
|
|
1184
|
-
|
|
1185
|
-
|
|
1256
|
+
// Featured-collections band — operator collections (active, newest
|
|
1257
|
+
// first, capped at 6); the band is dropped entirely when there are
|
|
1258
|
+
// none. Spliced raw (replacer-function form, $-safe) since the
|
|
1259
|
+
// operator slug/title/description are escaped at the band's own sink.
|
|
1260
|
+
var collectionsBand = _buildCollectionsBand(opts.collections || []);
|
|
1261
|
+
var hero = _spliceRaw(
|
|
1262
|
+
_render(HOME_HERO, { product_count: heroProductCount })
|
|
1263
|
+
.replace("RAW_FEATURED_CALLOUT", featuredHtml),
|
|
1264
|
+
"RAW_COLLECTIONS_BAND", collectionsBand);
|
|
1186
1265
|
// The hero + value band + catalog section give the home page a
|
|
1187
1266
|
// designed surface even when no products are loaded yet —
|
|
1188
1267
|
// visitors land on the storefront shell, not a tech demo.
|
|
@@ -1754,7 +1833,40 @@ var VARIANT_ROW =
|
|
|
1754
1833
|
// worker/render/product.js#_buildBuyBox.
|
|
1755
1834
|
var BUYBOX_CHIP_LIMIT = 12;
|
|
1756
1835
|
|
|
1757
|
-
|
|
1836
|
+
// Compute the PDP buy-box headline price string. `rendered` is the
|
|
1837
|
+
// formatted-variant list ([{ id, sku, title, price }]); `variants` + the
|
|
1838
|
+
// `prices` map ({ variant_id: { currency, amount_minor } }) carry the
|
|
1839
|
+
// integer minor-units. Multi-variant products with >1 distinct price read
|
|
1840
|
+
// "From <lowest>" (min over amount_minor, formatted once via `fmt`); a
|
|
1841
|
+
// single-variant, no-price, or all-equal-price product keeps the lead
|
|
1842
|
+
// variant's exact formatted price. Money discipline: the minimum is taken
|
|
1843
|
+
// over integers, never over formatted strings; the result is formatted once.
|
|
1844
|
+
// Kept byte-identical to worker/render/product.js#_buildHeadlinePrice.
|
|
1845
|
+
function _buildHeadlinePrice(rendered, variants, prices, fmt) {
|
|
1846
|
+
var leadPrice = (rendered[0] && rendered[0].price) || "—";
|
|
1847
|
+
if (!Array.isArray(rendered) || rendered.length <= 1) return leadPrice;
|
|
1848
|
+
var lowMinor = null;
|
|
1849
|
+
var lowCurrency = null;
|
|
1850
|
+
for (var i = 0; i < variants.length; i += 1) {
|
|
1851
|
+
var p = prices && prices[variants[i].id];
|
|
1852
|
+
if (!p || !Number.isInteger(p.amount_minor)) continue;
|
|
1853
|
+
if (lowMinor === null || p.amount_minor < lowMinor) {
|
|
1854
|
+
lowMinor = p.amount_minor;
|
|
1855
|
+
lowCurrency = p.currency || "USD";
|
|
1856
|
+
}
|
|
1857
|
+
}
|
|
1858
|
+
if (lowMinor === null) return leadPrice;
|
|
1859
|
+
// All variants share one price → keep the exact figure (no "From").
|
|
1860
|
+
var allEqual = true;
|
|
1861
|
+
for (var j = 0; j < variants.length; j += 1) {
|
|
1862
|
+
var pj = prices && prices[variants[j].id];
|
|
1863
|
+
if (pj && Number.isInteger(pj.amount_minor) && pj.amount_minor !== lowMinor) { allEqual = false; break; }
|
|
1864
|
+
}
|
|
1865
|
+
if (allEqual) return leadPrice;
|
|
1866
|
+
return "From " + fmt(lowMinor, lowCurrency);
|
|
1867
|
+
}
|
|
1868
|
+
|
|
1869
|
+
function _buildBuyBox(variants, escAttr, availability, headlinePrice) {
|
|
1758
1870
|
var inStock = !availability || availability.in_stock !== false;
|
|
1759
1871
|
if (!variants || variants.length === 0) {
|
|
1760
1872
|
return "<div class=\"pdp__variants\">\n" +
|
|
@@ -1835,8 +1947,9 @@ function _buildBuyBox(variants, escAttr, availability) {
|
|
|
1835
1947
|
? "<button type=\"submit\" class=\"btn-primary cart-page__checkout\">$ add to cart</button>"
|
|
1836
1948
|
: soldOutBtn;
|
|
1837
1949
|
|
|
1950
|
+
var headline = (headlinePrice != null && headlinePrice !== "") ? headlinePrice : lead.price;
|
|
1838
1951
|
return "<div class=\"pdp__buybox\">\n" +
|
|
1839
|
-
" <p class=\"featured-product__price\">" + escAttr(
|
|
1952
|
+
" <p class=\"featured-product__price\">" + escAttr(headline) + "</p>\n" +
|
|
1840
1953
|
" <form method=\"post\" action=\"/cart/lines\">\n" +
|
|
1841
1954
|
" " + variantBlock + "\n" +
|
|
1842
1955
|
" <label class=\"pdp__variants-title\" for=\"buybox-qty\">Quantity</label>\n" +
|
|
@@ -5613,6 +5726,143 @@ function _jsonLdScript(data) {
|
|
|
5613
5726
|
return "<script type=\"application/ld+json\">" + serialised + "</script>";
|
|
5614
5727
|
}
|
|
5615
5728
|
|
|
5729
|
+
// PWA default manifest + service worker — the shipped fallback served at
|
|
5730
|
+
// /manifest.webmanifest + /sw.js when the operator has not defined an
|
|
5731
|
+
// active pwaManifest row. BOTH substrates serve these exact bytes (the
|
|
5732
|
+
// edge has no DB-backed primitive; the container's DB-backed pwaManifest is
|
|
5733
|
+
// the override), so the `<link rel="manifest">` in every layout never 404s
|
|
5734
|
+
// on a fresh deploy. The byte-identical twin of
|
|
5735
|
+
// worker/render/_lib.js's PWA_DEFAULT_MANIFEST / PWA_DEFAULT_SW — a parity
|
|
5736
|
+
// test pins them. An operator customizes via pwaManifest.defineManifest +
|
|
5737
|
+
// setActive on the container path.
|
|
5738
|
+
var _PWA_DEFAULT_MANIFEST_OBJ = {
|
|
5739
|
+
name: "blamejs.shop",
|
|
5740
|
+
short_name: "blamejs.shop",
|
|
5741
|
+
description: "Open-source ecommerce framework built on blamejs — server-rendered HTML, post-quantum crypto, zero npm runtime dependencies.",
|
|
5742
|
+
start_url: "/",
|
|
5743
|
+
scope: "/",
|
|
5744
|
+
display: "standalone",
|
|
5745
|
+
orientation: "portrait",
|
|
5746
|
+
theme_color: "#08080a",
|
|
5747
|
+
background_color: "#08080a",
|
|
5748
|
+
lang: "en",
|
|
5749
|
+
dir: "ltr",
|
|
5750
|
+
icons: [
|
|
5751
|
+
{ src: "/assets/brand/favicon.png", sizes: "192x192", type: "image/png", purpose: "any" },
|
|
5752
|
+
{ src: "/assets/brand/favicon.png", sizes: "512x512", type: "image/png", purpose: "any maskable" },
|
|
5753
|
+
{ src: "/assets/brand/favicon.svg", sizes: "any", type: "image/svg+xml", purpose: "any" }
|
|
5754
|
+
]
|
|
5755
|
+
};
|
|
5756
|
+
var PWA_DEFAULT_MANIFEST = JSON.stringify(_PWA_DEFAULT_MANIFEST_OBJ, null, 2);
|
|
5757
|
+
|
|
5758
|
+
var PWA_DEFAULT_SW =
|
|
5759
|
+
"// service worker — shipped default (pass-through navigation fallback)\n" +
|
|
5760
|
+
"\"use strict\";\n" +
|
|
5761
|
+
"var CACHE_NAME = \"blamejs-shop-default-v1\";\n" +
|
|
5762
|
+
"self.addEventListener(\"install\", function (e) { self.skipWaiting(); });\n" +
|
|
5763
|
+
"self.addEventListener(\"activate\", function (e) {\n" +
|
|
5764
|
+
" e.waitUntil(caches.keys().then(function (keys) {\n" +
|
|
5765
|
+
" return Promise.all(keys.map(function (k) { if (k !== CACHE_NAME) return caches.delete(k); }));\n" +
|
|
5766
|
+
" }).then(function () { return self.clients.claim(); }));\n" +
|
|
5767
|
+
"});\n" +
|
|
5768
|
+
"self.addEventListener(\"fetch\", function (e) {\n" +
|
|
5769
|
+
" if (e.request.method !== \"GET\") return;\n" +
|
|
5770
|
+
" if (e.request.mode === \"navigate\") {\n" +
|
|
5771
|
+
" e.respondWith(fetch(e.request).catch(function () { return caches.match(\"/\"); }));\n" +
|
|
5772
|
+
" }\n" +
|
|
5773
|
+
"});\n";
|
|
5774
|
+
|
|
5775
|
+
// QAPage JSON-LD from the PDP's published Q&A threads. Surfaces the
|
|
5776
|
+
// question + its answers in Google's Q&A rich result. Emitted only when at
|
|
5777
|
+
// least one question has at least one answer — Google rejects a QAPage
|
|
5778
|
+
// whose Question carries neither an `acceptedAnswer` nor a
|
|
5779
|
+
// `suggestedAnswer`. The first answer is the `acceptedAnswer`; the rest are
|
|
5780
|
+
// `suggestedAnswer`. `q.body` / `a.body` are operator/customer free text —
|
|
5781
|
+
// `_jsonLdScript` JSON.stringify's them (neutralizing quotes + the
|
|
5782
|
+
// `</script` breakout), so they are NOT additionally HTML-escaped (JSON-LD
|
|
5783
|
+
// is not HTML, by the documented `_jsonLdScript` contract — the same trust
|
|
5784
|
+
// model as the Product JSON-LD `description`). The byte-identical twin of
|
|
5785
|
+
// the edge's `worker/render/product.js#_buildQaPageJsonLd`; a parity test
|
|
5786
|
+
// pins the two. Returns "" when there is nothing rich-result-eligible.
|
|
5787
|
+
function _buildQaPageJsonLd(questions) {
|
|
5788
|
+
questions = questions || [];
|
|
5789
|
+
var mainEntity = [];
|
|
5790
|
+
for (var i = 0; i < questions.length; i += 1) {
|
|
5791
|
+
var q = questions[i];
|
|
5792
|
+
var answers = (q && q.answers) || [];
|
|
5793
|
+
if (answers.length === 0) continue;
|
|
5794
|
+
var question = {
|
|
5795
|
+
"@type": "Question",
|
|
5796
|
+
"name": String(q.body),
|
|
5797
|
+
"answerCount": answers.length,
|
|
5798
|
+
"acceptedAnswer": { "@type": "Answer", "text": String(answers[0].body) },
|
|
5799
|
+
};
|
|
5800
|
+
if (answers.length > 1) {
|
|
5801
|
+
var suggested = [];
|
|
5802
|
+
for (var j = 1; j < answers.length; j += 1) {
|
|
5803
|
+
suggested.push({ "@type": "Answer", "text": String(answers[j].body) });
|
|
5804
|
+
}
|
|
5805
|
+
question.suggestedAnswer = suggested;
|
|
5806
|
+
}
|
|
5807
|
+
mainEntity.push(question);
|
|
5808
|
+
}
|
|
5809
|
+
if (mainEntity.length === 0) return "";
|
|
5810
|
+
return _jsonLdScript({
|
|
5811
|
+
"@context": "https://schema.org",
|
|
5812
|
+
"@type": "QAPage",
|
|
5813
|
+
"mainEntity": mainEntity,
|
|
5814
|
+
});
|
|
5815
|
+
}
|
|
5816
|
+
|
|
5817
|
+
// ---- hreflang alternates (URL-shaped locale policies) ------------------
|
|
5818
|
+
//
|
|
5819
|
+
// The href math + validation envelopes below are the byte-identical twin
|
|
5820
|
+
// of worker/render/chrome-i18n.js's `alternateLinks` so the edge + the
|
|
5821
|
+
// container emit the same `<link rel="alternate" hreflang>` block for the
|
|
5822
|
+
// same (host, path, supported, default, strategy). A parity test pins the
|
|
5823
|
+
// two. Both mirror lib/locale-router.js#_canonicalForUrl's prefix /
|
|
5824
|
+
// subdomain substitution.
|
|
5825
|
+
var _HREFLANG_TAG_RE = /^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/;
|
|
5826
|
+
var _HREFLANG_HOST_RE = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$/;
|
|
5827
|
+
|
|
5828
|
+
function _hreflangStripLeadingSegment(path) {
|
|
5829
|
+
var rest = path.charAt(0) === "/" ? path.slice(1) : path;
|
|
5830
|
+
if (!rest.length) return null;
|
|
5831
|
+
var slash = rest.indexOf("/");
|
|
5832
|
+
var seg = slash === -1 ? rest : rest.slice(0, slash);
|
|
5833
|
+
if (!seg.length || seg.length > 35 || !_HREFLANG_TAG_RE.test(seg)) return null;
|
|
5834
|
+
return seg;
|
|
5835
|
+
}
|
|
5836
|
+
|
|
5837
|
+
function _hreflangStripLeadingSubdomain(host) {
|
|
5838
|
+
if (host.indexOf(".") === -1) return null;
|
|
5839
|
+
var first = host.slice(0, host.indexOf("."));
|
|
5840
|
+
if (!first.length || first.length > 35 || !_HREFLANG_TAG_RE.test(first)) return null;
|
|
5841
|
+
return first;
|
|
5842
|
+
}
|
|
5843
|
+
|
|
5844
|
+
function _canonicalForLocaleUrl(host, path, locale, strategy) {
|
|
5845
|
+
var lc = String(locale).toLowerCase();
|
|
5846
|
+
if (strategy === "url_prefix") {
|
|
5847
|
+
var prefix = _hreflangStripLeadingSegment(path);
|
|
5848
|
+
var rest;
|
|
5849
|
+
if (prefix) {
|
|
5850
|
+
rest = path.slice(1 + prefix.length);
|
|
5851
|
+
if (rest.length === 0) rest = "/";
|
|
5852
|
+
} else {
|
|
5853
|
+
rest = path;
|
|
5854
|
+
}
|
|
5855
|
+
if (rest.charAt(0) !== "/") rest = "/" + rest;
|
|
5856
|
+
return "https://" + host + "/" + lc + (rest === "/" ? "" : rest);
|
|
5857
|
+
}
|
|
5858
|
+
if (strategy === "subdomain") {
|
|
5859
|
+
var sub = _hreflangStripLeadingSubdomain(host);
|
|
5860
|
+
var apex = sub ? host.slice(sub.length + 1) : host;
|
|
5861
|
+
return "https://" + lc + "." + apex + path;
|
|
5862
|
+
}
|
|
5863
|
+
return null;
|
|
5864
|
+
}
|
|
5865
|
+
|
|
5616
5866
|
function renderProduct(opts) {
|
|
5617
5867
|
if (!opts || !opts.product) throw new TypeError("storefront.renderProduct: opts.product required");
|
|
5618
5868
|
var variants = opts.variants || [];
|
|
@@ -5689,9 +5939,18 @@ function renderProduct(opts) {
|
|
|
5689
5939
|
var preorderShape = opts.preorder_campaign
|
|
5690
5940
|
? preorderCtaShape(opts.preorder_campaign.campaign, { remaining_units: opts.preorder_campaign.remaining_units }, fmt, opts.product.slug)
|
|
5691
5941
|
: null;
|
|
5942
|
+
// Multi-variant headline price — "From <lowest>" so the buy box never
|
|
5943
|
+
// advertises a price that isn't the cheapest buyable variant (variants[0]
|
|
5944
|
+
// may not be the cheapest). The minimum is computed over the integer
|
|
5945
|
+
// amount_minor map and formatted ONCE via the page's own `fmt` (never by
|
|
5946
|
+
// comparing formatted strings — "$9.99" < "$10.00" sorts wrong as text).
|
|
5947
|
+
// Single-variant, no-price, and all-equal-price products keep the lead
|
|
5948
|
+
// variant's exact price string ("From X" when there's only one X is
|
|
5949
|
+
// noise). Kept byte-identical to worker/render/product.js. See UX-5.
|
|
5950
|
+
var headlinePrice = _buildHeadlinePrice(rendered, variants, prices, fmt);
|
|
5692
5951
|
var buyboxHtml = preorderShape
|
|
5693
5952
|
? _buildPreorderCta(preorderShape, b.template.escapeHtml)
|
|
5694
|
-
: _buildBuyBox(rendered, b.template.escapeHtml, availability);
|
|
5953
|
+
: _buildBuyBox(rendered, b.template.escapeHtml, availability, headlinePrice);
|
|
5695
5954
|
// The reserve PRG lands the shopper back on the PDP with a fixed
|
|
5696
5955
|
// ?preorder=<reserved|unavailable|closed> marker; the banner prepends the
|
|
5697
5956
|
// buy box. The marker set is closed (built from a lookup, never the raw
|
|
@@ -5793,7 +6052,10 @@ function renderProduct(opts) {
|
|
|
5793
6052
|
{ "@type": "ListItem", "position": 2, "name": opts.product.title, "item": absoluteBase + "/products/" + opts.product.slug },
|
|
5794
6053
|
],
|
|
5795
6054
|
});
|
|
5796
|
-
|
|
6055
|
+
// QAPage JSON-LD from the published Q&A (empty string when no question is
|
|
6056
|
+
// answered — Google rejects an answerless QAPage). Mirrors the edge.
|
|
6057
|
+
var qaPageJsonLd = _buildQaPageJsonLd(opts.qa_questions);
|
|
6058
|
+
jsonLd = (jsonLd || "") + breadcrumbJsonLd + qaPageJsonLd;
|
|
5797
6059
|
|
|
5798
6060
|
return _wrap(Object.assign({
|
|
5799
6061
|
title: opts.product.title,
|
|
@@ -8300,7 +8562,9 @@ function renderOrderList(opts) {
|
|
|
8300
8562
|
function _surveyQuestion(q) {
|
|
8301
8563
|
var esc = function (s) { return b.template.escapeHtml(String(s == null ? "" : s)); };
|
|
8302
8564
|
var name = "q_" + esc(q.id);
|
|
8303
|
-
var req = q.required
|
|
8565
|
+
var req = q.required
|
|
8566
|
+
? " <span class=\"survey-req\" aria-hidden=\"true\">*</span><span class=\"sr-only\">(required)</span>"
|
|
8567
|
+
: "";
|
|
8304
8568
|
var control = "";
|
|
8305
8569
|
if (q.kind === "rating") {
|
|
8306
8570
|
var lo = (q.max >= 9) ? 0 : 1; // 0..10 for NPS-scale, else 1..max
|
|
@@ -8630,6 +8894,14 @@ function mount(router, deps) {
|
|
|
8630
8894
|
}
|
|
8631
8895
|
_liveConsentPolicy();
|
|
8632
8896
|
|
|
8897
|
+
// Header nav gating — the Collections / Categories links + their mobile-
|
|
8898
|
+
// disclosure entries render only when the matching routes are mounted
|
|
8899
|
+
// (deps wired). Module-scoped so `_buildPrimaryNav` (called from the
|
|
8900
|
+
// module-level `_wrap`, which doesn't close over `deps`) can read them,
|
|
8901
|
+
// matching the `_ccyEnabled` / `_activeConsentPolicy` pattern.
|
|
8902
|
+
_hasCollections = !!deps.collections;
|
|
8903
|
+
_hasCategoryNav = !!deps.categoryNavigation;
|
|
8904
|
+
|
|
8633
8905
|
function _send(res, status, html) {
|
|
8634
8906
|
res.status(status);
|
|
8635
8907
|
res.setHeader && res.setHeader("content-type", "text/html; charset=utf-8");
|
|
@@ -8909,20 +9181,64 @@ function mount(router, deps) {
|
|
|
8909
9181
|
" </form>\n";
|
|
8910
9182
|
}
|
|
8911
9183
|
|
|
9184
|
+
// hreflang `<link rel="alternate">` block for a URL-shaped locale policy.
|
|
9185
|
+
// The byte-identical twin of the edge's `chrome-i18n.js#alternateLinks` —
|
|
9186
|
+
// an edge↔container parity test pins the two so they never drift. Returns
|
|
9187
|
+
// "" for a non-URL strategy (cookie / accept-language / single-locale) or
|
|
9188
|
+
// a malformed host/path, so most stores emit nothing. Synchronous so the
|
|
9189
|
+
// locale middleware can seed it onto the ALS store within one tick (an
|
|
9190
|
+
// `await` would scope the store to the awaited frame, not the request).
|
|
9191
|
+
function _alternateLinksHtml(host, path) {
|
|
9192
|
+
var strategy = localeOptions.strategy;
|
|
9193
|
+
if (strategy !== "url_prefix" && strategy !== "subdomain") return "";
|
|
9194
|
+
if (supportedTags.length < 2) return "";
|
|
9195
|
+
if (typeof defaultLocale !== "string" || !_HREFLANG_TAG_RE.test(defaultLocale)) return "";
|
|
9196
|
+
if (typeof host !== "string" || typeof path !== "string") return "";
|
|
9197
|
+
var h = host.toLowerCase();
|
|
9198
|
+
var colon = h.indexOf(":");
|
|
9199
|
+
if (colon !== -1) h = h.slice(0, colon);
|
|
9200
|
+
if (!h.length || h.length > 253 || !_HREFLANG_HOST_RE.test(h)) return "";
|
|
9201
|
+
if (!path.length || path.length > 2048 || path.charAt(0) !== "/") return "";
|
|
9202
|
+
if (/[\x00-\x1f\x7f]/.test(path)) return "";
|
|
9203
|
+
var esc = b.template.escapeHtml;
|
|
9204
|
+
var out = "";
|
|
9205
|
+
for (var i = 0; i < supportedTags.length; i += 1) {
|
|
9206
|
+
var tag = supportedTags[i];
|
|
9207
|
+
if (typeof tag !== "string" || !_HREFLANG_TAG_RE.test(tag)) continue;
|
|
9208
|
+
var href = _canonicalForLocaleUrl(h, path, tag, strategy);
|
|
9209
|
+
if (href == null) continue;
|
|
9210
|
+
out += " <link rel=\"alternate\" hreflang=\"" + esc(tag) +
|
|
9211
|
+
"\" href=\"" + esc(href) + "\">\n";
|
|
9212
|
+
}
|
|
9213
|
+
if (!out) return "";
|
|
9214
|
+
var xDefault = _canonicalForLocaleUrl(h, path, defaultLocale, strategy);
|
|
9215
|
+
if (xDefault != null) {
|
|
9216
|
+
out += " <link rel=\"alternate\" hreflang=\"x-default\" href=\"" +
|
|
9217
|
+
esc(xDefault) + "\">\n";
|
|
9218
|
+
}
|
|
9219
|
+
return out;
|
|
9220
|
+
}
|
|
9221
|
+
|
|
8912
9222
|
// The per-request locale context the layout reads via the async-local
|
|
8913
9223
|
// store. Resolved SYNCHRONOUSLY so the middleware can seed the store
|
|
8914
|
-
// within one tick. Shape: { locale, lang, dir, chrome, switcher_html
|
|
8915
|
-
// Never throws — any failure falls back to the
|
|
9224
|
+
// within one tick. Shape: { locale, lang, dir, chrome, switcher_html,
|
|
9225
|
+
// alternate_links }. Never throws — any failure falls back to the
|
|
9226
|
+
// default locale.
|
|
8916
9227
|
function _localeCtx(req) {
|
|
8917
9228
|
var locale = defaultLocale;
|
|
8918
9229
|
try { locale = _resolveRequestLocale(req); } catch (_e) { locale = defaultLocale; }
|
|
8919
9230
|
var chrome = _chromeFor(locale);
|
|
9231
|
+
var hostHeader = (req.headers && (req.headers.host || req.headers.Host)) || "";
|
|
9232
|
+
var reqPath = req.pathname || (String(req.url || "/").split("?")[0]) || "/";
|
|
9233
|
+
var alternateLinks = "";
|
|
9234
|
+
try { alternateLinks = _alternateLinksHtml(String(hostHeader), reqPath); } catch (_e) { alternateLinks = ""; }
|
|
8920
9235
|
return {
|
|
8921
|
-
locale:
|
|
8922
|
-
lang:
|
|
8923
|
-
dir:
|
|
8924
|
-
chrome:
|
|
8925
|
-
switcher_html:
|
|
9236
|
+
locale: locale,
|
|
9237
|
+
lang: locale,
|
|
9238
|
+
dir: _dirFor(locale),
|
|
9239
|
+
chrome: chrome,
|
|
9240
|
+
switcher_html: _switcherHtml(req, locale, chrome),
|
|
9241
|
+
alternate_links: alternateLinks,
|
|
8926
9242
|
};
|
|
8927
9243
|
}
|
|
8928
9244
|
|
|
@@ -9902,8 +10218,19 @@ function mount(router, deps) {
|
|
|
9902
10218
|
// id DESC) matches the prior `products.list` order.
|
|
9903
10219
|
var decorated = await deps.catalog.batch.decoratedActive({ currency: "USD", limit: 24 });
|
|
9904
10220
|
var products = decorated.rows;
|
|
10221
|
+
// Featured-collections band data — active collections, newest-curated
|
|
10222
|
+
// first (collections.list orders updated_at DESC, slug DESC; the band
|
|
10223
|
+
// caps to 6). Best-effort: a band read failure degrades to no band, it
|
|
10224
|
+
// never blocks the home page. The band itself drops when the list is
|
|
10225
|
+
// empty, so an unwired collections dep also yields hero → catalog.
|
|
10226
|
+
var collections = [];
|
|
10227
|
+
if (deps.collections && typeof deps.collections.list === "function") {
|
|
10228
|
+
try {
|
|
10229
|
+
collections = await deps.collections.list({ active_only: true });
|
|
10230
|
+
} catch (_e) { collections = []; }
|
|
10231
|
+
}
|
|
9905
10232
|
var ccy = await _currencyForReq(req);
|
|
9906
|
-
var html = renderHome(Object.assign({ products: products, shop_name: shopName, theme: theme }, _requestUrls(req), ccy));
|
|
10233
|
+
var html = renderHome(Object.assign({ products: products, collections: collections, shop_name: shopName, theme: theme }, _requestUrls(req), ccy));
|
|
9907
10234
|
_send(res, 200, html);
|
|
9908
10235
|
});
|
|
9909
10236
|
|
|
@@ -15686,17 +16013,23 @@ function mount(router, deps) {
|
|
|
15686
16013
|
// robots.txt — minimal crawl policy. Allow everything except
|
|
15687
16014
|
// the admin API + cart + account + checkout / pay / orders (these
|
|
15688
16015
|
// are session-scoped or operator-only, no crawl value), and
|
|
15689
|
-
// point at the sitemap.
|
|
15690
|
-
//
|
|
15691
|
-
//
|
|
16016
|
+
// point at the sitemap. When the operator has defined per-bot rules
|
|
16017
|
+
// via robotsConfig, those drive the body instead; absent any rules
|
|
16018
|
+
// the hardcoded Disallow set is kept (robotsConfig's empty-table
|
|
16019
|
+
// branch emits `Allow: /` only, which would silently drop the
|
|
16020
|
+
// /admin disallow on a fresh deploy). Operators with stricter
|
|
16021
|
+
// requirements also replace the file at the same path via R2 (the
|
|
16022
|
+
// Worker's static-asset bridge serves it ahead of this route if a
|
|
15692
16023
|
// `robots.txt` key exists in the bucket).
|
|
15693
|
-
router.get("/robots.txt", function (req, res) {
|
|
16024
|
+
router.get("/robots.txt", async function (req, res) {
|
|
15694
16025
|
res.status(200);
|
|
15695
16026
|
res.setHeader && res.setHeader("content-type", "text/plain; charset=utf-8");
|
|
15696
16027
|
res.setHeader && res.setHeader("cache-control", "public, max-age=3600");
|
|
15697
16028
|
var hostHeader = req.headers && (req.headers.host || req.headers.Host) || "";
|
|
15698
16029
|
var origin = hostHeader ? ("https://" + hostHeader) : "";
|
|
15699
|
-
|
|
16030
|
+
// The shipped fallback — the same Disallow set the edge robots.txt
|
|
16031
|
+
// emits, kept as the fresh-deploy default.
|
|
16032
|
+
var fallback =
|
|
15700
16033
|
"User-agent: *\n" +
|
|
15701
16034
|
"Allow: /\n" +
|
|
15702
16035
|
"Disallow: /admin\n" +
|
|
@@ -15706,6 +16039,57 @@ function mount(router, deps) {
|
|
|
15706
16039
|
"Disallow: /orders/\n" +
|
|
15707
16040
|
"Disallow: /account\n" +
|
|
15708
16041
|
(origin ? ("Sitemap: " + origin + "/sitemap.xml\n") : "");
|
|
16042
|
+
var body = fallback;
|
|
16043
|
+
// robotsConfig.render THROWS on a bad origin (no Host header) / bad
|
|
16044
|
+
// state; the route must never let that reach the response. Only honor
|
|
16045
|
+
// the operator's config when it actually carries rules — an empty
|
|
16046
|
+
// robots_rules table renders `Allow: /` only, weaker than the
|
|
16047
|
+
// hardcoded Disallow set. A read/render failure degrades to the
|
|
16048
|
+
// shipped fallback (drop-to-default), never a 500.
|
|
16049
|
+
if (deps.robotsConfig && origin) {
|
|
16050
|
+
try {
|
|
16051
|
+
var rules = await deps.robotsConfig.listRules({});
|
|
16052
|
+
if (Array.isArray(rules) && rules.length > 0) {
|
|
16053
|
+
body = await deps.robotsConfig.render({ origin_url: origin });
|
|
16054
|
+
}
|
|
16055
|
+
} catch (_e) { body = fallback; }
|
|
16056
|
+
}
|
|
16057
|
+
res.end ? res.end(body) : res.send(body);
|
|
16058
|
+
});
|
|
16059
|
+
|
|
16060
|
+
// PWA web app manifest — served at /manifest.webmanifest (the
|
|
16061
|
+
// `<link rel="manifest">` every layout carries). When the operator has an
|
|
16062
|
+
// active pwaManifest row, its bytes serve; absent one (or on any read /
|
|
16063
|
+
// render failure — renderManifestJson THROWS PWA_NO_ACTIVE_MANIFEST when
|
|
16064
|
+
// no row is active) the shipped default serves (drop-to-default), so the
|
|
16065
|
+
// manifest link never 404s. The default bytes are byte-identical to the
|
|
16066
|
+
// edge's, so a cached edge read and a container read agree.
|
|
16067
|
+
router.get("/manifest.webmanifest", async function (req, res) {
|
|
16068
|
+
var body = PWA_DEFAULT_MANIFEST;
|
|
16069
|
+
if (deps.pwaManifest && typeof deps.pwaManifest.renderManifestJson === "function") {
|
|
16070
|
+
try { body = await deps.pwaManifest.renderManifestJson(); }
|
|
16071
|
+
catch (_e) { body = PWA_DEFAULT_MANIFEST; }
|
|
16072
|
+
}
|
|
16073
|
+
res.status(200);
|
|
16074
|
+
res.setHeader && res.setHeader("content-type", "application/manifest+json; charset=utf-8");
|
|
16075
|
+
res.setHeader && res.setHeader("cache-control", "public, max-age=3600");
|
|
16076
|
+
res.end ? res.end(body) : res.send(body);
|
|
16077
|
+
});
|
|
16078
|
+
|
|
16079
|
+
// PWA service worker — served at /sw.js. Same drop-to-default discipline
|
|
16080
|
+
// as the manifest: the operator's active SW config serves when present,
|
|
16081
|
+
// else the shipped pass-through default (renderServiceWorkerJs THROWS
|
|
16082
|
+
// PWA_NO_ACTIVE_SW when none is active). Served same-origin under the
|
|
16083
|
+
// strict `script-src 'self'` CSP, so no CSP change is needed.
|
|
16084
|
+
router.get("/sw.js", async function (req, res) {
|
|
16085
|
+
var body = PWA_DEFAULT_SW;
|
|
16086
|
+
if (deps.pwaManifest && typeof deps.pwaManifest.renderServiceWorkerJs === "function") {
|
|
16087
|
+
try { body = await deps.pwaManifest.renderServiceWorkerJs(); }
|
|
16088
|
+
catch (_e) { body = PWA_DEFAULT_SW; }
|
|
16089
|
+
}
|
|
16090
|
+
res.status(200);
|
|
16091
|
+
res.setHeader && res.setHeader("content-type", "text/javascript; charset=utf-8");
|
|
16092
|
+
res.setHeader && res.setHeader("cache-control", "public, max-age=3600");
|
|
15709
16093
|
res.end ? res.end(body) : res.send(body);
|
|
15710
16094
|
});
|
|
15711
16095
|
|
|
@@ -15729,17 +16113,29 @@ function mount(router, deps) {
|
|
|
15729
16113
|
urls.push({ loc: origin + "/privacy", changefreq: "yearly", priority: "0.3" });
|
|
15730
16114
|
urls.push({ loc: origin + "/terms", changefreq: "yearly", priority: "0.3" });
|
|
15731
16115
|
try {
|
|
15732
|
-
|
|
15733
|
-
|
|
15734
|
-
|
|
15735
|
-
|
|
15736
|
-
|
|
15737
|
-
|
|
15738
|
-
|
|
15739
|
-
|
|
15740
|
-
|
|
15741
|
-
});
|
|
15742
|
-
|
|
16116
|
+
// Page through the catalog via the cursor — `products.list` caps a
|
|
16117
|
+
// single page at the catalog's MAX_LIMIT (200), so a single
|
|
16118
|
+
// `limit: 1000` call would throw and silently drop EVERY product from
|
|
16119
|
+
// the sitemap. Walk pages until exhausted or the sitemap's per-file
|
|
16120
|
+
// 50k-URL ceiling, matching the edge sitemap's 50k cap.
|
|
16121
|
+
var SITEMAP_PRODUCT_CAP = 50000;
|
|
16122
|
+
var productCursor = null;
|
|
16123
|
+
var productCount = 0;
|
|
16124
|
+
do {
|
|
16125
|
+
var page = await deps.catalog.products.list({ status: "active", limit: 200, cursor: productCursor });
|
|
16126
|
+
for (var i = 0; i < page.rows.length && productCount < SITEMAP_PRODUCT_CAP; i += 1) {
|
|
16127
|
+
var p = page.rows[i];
|
|
16128
|
+
var lastmod = new Date(p.updated_at || p.created_at || Date.now()).toISOString().slice(0, 10);
|
|
16129
|
+
urls.push({
|
|
16130
|
+
loc: origin + "/products/" + p.slug,
|
|
16131
|
+
lastmod: lastmod,
|
|
16132
|
+
changefreq: "weekly",
|
|
16133
|
+
priority: "0.8",
|
|
16134
|
+
});
|
|
16135
|
+
productCount += 1;
|
|
16136
|
+
}
|
|
16137
|
+
productCursor = page.next_cursor;
|
|
16138
|
+
} while (productCursor && productCount < SITEMAP_PRODUCT_CAP);
|
|
15743
16139
|
} catch (_e) { /* drop-silent — catalog unreachable means sitemap drops product rows */ }
|
|
15744
16140
|
// Active category browse pages, when the categoryNavigation primitive
|
|
15745
16141
|
// is wired. `tree({})` returns the full nested active tree (archived
|
|
@@ -15773,6 +16169,22 @@ function mount(router, deps) {
|
|
|
15773
16169
|
}
|
|
15774
16170
|
} catch (_e) { /* drop-silent — collections unreachable drops those rows */ }
|
|
15775
16171
|
}
|
|
16172
|
+
// Published CMS pages (/pages/<slug>), when the storefrontPages
|
|
16173
|
+
// primitive is wired. `monthly` / 0.5 — operator content changes less
|
|
16174
|
+
// often than the catalog; the edge sitemap emits the same values.
|
|
16175
|
+
// Best-effort: a read failure drops the page rows rather than 500-ing.
|
|
16176
|
+
if (deps.storefrontPages && typeof deps.storefrontPages.listPublished === "function") {
|
|
16177
|
+
try {
|
|
16178
|
+
var cmsPages = await deps.storefrontPages.listPublished();
|
|
16179
|
+
for (var pgi = 0; pgi < cmsPages.length; pgi += 1) {
|
|
16180
|
+
var pg = cmsPages[pgi];
|
|
16181
|
+
if (pg && pg.slug) {
|
|
16182
|
+
var pgLast = new Date(pg.updated_at || pg.created_at || Date.now()).toISOString().slice(0, 10);
|
|
16183
|
+
urls.push({ loc: origin + "/pages/" + pg.slug, lastmod: pgLast, changefreq: "monthly", priority: "0.5" });
|
|
16184
|
+
}
|
|
16185
|
+
}
|
|
16186
|
+
} catch (_e) { /* drop-silent — CMS pages unreachable drops those rows */ }
|
|
16187
|
+
}
|
|
15776
16188
|
var body = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" +
|
|
15777
16189
|
"<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n";
|
|
15778
16190
|
for (var u = 0; u < urls.length; u += 1) {
|
|
@@ -15847,6 +16259,7 @@ module.exports = {
|
|
|
15847
16259
|
renderProfile: renderProfile,
|
|
15848
16260
|
renderAccountSubscriptions: renderAccountSubscriptions,
|
|
15849
16261
|
renderCookiePreferences: renderCookiePreferences,
|
|
16262
|
+
renderSurveyPage: renderSurveyPage,
|
|
15850
16263
|
renderNewsletterError: renderNewsletterError,
|
|
15851
16264
|
renderNotFound: renderNotFound,
|
|
15852
16265
|
// Layout exposed so operators forking the framework can override.
|
package/lib/translations.js
CHANGED
|
@@ -571,8 +571,12 @@ var CHROME_DEFAULTS = Object.freeze({
|
|
|
571
571
|
search_submit: "Search",
|
|
572
572
|
|
|
573
573
|
nav_shop: "Shop",
|
|
574
|
+
nav_collections: "Collections",
|
|
575
|
+
nav_categories: "Categories",
|
|
574
576
|
nav_framework: "Framework",
|
|
575
577
|
nav_account: "Account",
|
|
578
|
+
// The narrow-viewport disclosure's <summary> label.
|
|
579
|
+
nav_menu: "Menu",
|
|
576
580
|
// The cart aria-label carries the live item count; `{count}` is
|
|
577
581
|
// interpolated by b.i18n at render time.
|
|
578
582
|
nav_cart_aria: "Cart, {count} items",
|
|
@@ -589,8 +593,6 @@ var CHROME_DEFAULTS = Object.freeze({
|
|
|
589
593
|
footer_shop_all: "All products",
|
|
590
594
|
footer_shop_collections: "Collections",
|
|
591
595
|
footer_shop_categories: "Categories",
|
|
592
|
-
footer_shop_new: "New arrivals",
|
|
593
|
-
footer_shop_sale: "On sale",
|
|
594
596
|
footer_shop_compare: "Compare",
|
|
595
597
|
footer_shop_cart: "Cart",
|
|
596
598
|
footer_shop_shipping: "Shipping & returns",
|
package/package.json
CHANGED