@blamejs/blamejs-shop 0.3.49 → 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 +2 -0
- package/lib/asset-manifest.json +1 -1
- package/lib/locale-router.js +39 -0
- package/lib/storefront.js +306 -24
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,8 @@ 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
|
+
|
|
11
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.
|
|
12
14
|
|
|
13
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.
|
package/lib/asset-manifest.json
CHANGED
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" +
|
|
@@ -854,9 +856,20 @@ function _wrap(opts) {
|
|
|
854
856
|
? " <meta name=\"robots\" content=\"noindex,nofollow\">\n"
|
|
855
857
|
: "";
|
|
856
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 : "");
|
|
857
869
|
var assembled = _render(LAYOUT, vars)
|
|
858
870
|
.replace("RAW_CSS_INTEGRITY", themeCssIntegrity)
|
|
859
871
|
.replace("RAW_ROBOTS_META", robotsMeta)
|
|
872
|
+
.replace("RAW_HREFLANG", hreflangHtml)
|
|
860
873
|
.replace("RAW_CONSENT_SCRIPT", _islandScript("consent.js", { id: "consent-island", policy: _activeConsentPolicy }))
|
|
861
874
|
.replace("RAW_CART_COUNT_SCRIPT", _islandScript("cart-count.js", { id: "cart-count-island" }))
|
|
862
875
|
.replace("RAW_ANNOUNCEMENT_SCRIPT", announcementScript)
|
|
@@ -5713,6 +5726,143 @@ function _jsonLdScript(data) {
|
|
|
5713
5726
|
return "<script type=\"application/ld+json\">" + serialised + "</script>";
|
|
5714
5727
|
}
|
|
5715
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
|
+
|
|
5716
5866
|
function renderProduct(opts) {
|
|
5717
5867
|
if (!opts || !opts.product) throw new TypeError("storefront.renderProduct: opts.product required");
|
|
5718
5868
|
var variants = opts.variants || [];
|
|
@@ -5902,7 +6052,10 @@ function renderProduct(opts) {
|
|
|
5902
6052
|
{ "@type": "ListItem", "position": 2, "name": opts.product.title, "item": absoluteBase + "/products/" + opts.product.slug },
|
|
5903
6053
|
],
|
|
5904
6054
|
});
|
|
5905
|
-
|
|
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;
|
|
5906
6059
|
|
|
5907
6060
|
return _wrap(Object.assign({
|
|
5908
6061
|
title: opts.product.title,
|
|
@@ -9028,20 +9181,64 @@ function mount(router, deps) {
|
|
|
9028
9181
|
" </form>\n";
|
|
9029
9182
|
}
|
|
9030
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
|
+
|
|
9031
9222
|
// The per-request locale context the layout reads via the async-local
|
|
9032
9223
|
// store. Resolved SYNCHRONOUSLY so the middleware can seed the store
|
|
9033
|
-
// within one tick. Shape: { locale, lang, dir, chrome, switcher_html
|
|
9034
|
-
// 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.
|
|
9035
9227
|
function _localeCtx(req) {
|
|
9036
9228
|
var locale = defaultLocale;
|
|
9037
9229
|
try { locale = _resolveRequestLocale(req); } catch (_e) { locale = defaultLocale; }
|
|
9038
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 = ""; }
|
|
9039
9235
|
return {
|
|
9040
|
-
locale:
|
|
9041
|
-
lang:
|
|
9042
|
-
dir:
|
|
9043
|
-
chrome:
|
|
9044
|
-
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,
|
|
9045
9242
|
};
|
|
9046
9243
|
}
|
|
9047
9244
|
|
|
@@ -15816,17 +16013,23 @@ function mount(router, deps) {
|
|
|
15816
16013
|
// robots.txt — minimal crawl policy. Allow everything except
|
|
15817
16014
|
// the admin API + cart + account + checkout / pay / orders (these
|
|
15818
16015
|
// are session-scoped or operator-only, no crawl value), and
|
|
15819
|
-
// point at the sitemap.
|
|
15820
|
-
//
|
|
15821
|
-
//
|
|
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
|
|
15822
16023
|
// `robots.txt` key exists in the bucket).
|
|
15823
|
-
router.get("/robots.txt", function (req, res) {
|
|
16024
|
+
router.get("/robots.txt", async function (req, res) {
|
|
15824
16025
|
res.status(200);
|
|
15825
16026
|
res.setHeader && res.setHeader("content-type", "text/plain; charset=utf-8");
|
|
15826
16027
|
res.setHeader && res.setHeader("cache-control", "public, max-age=3600");
|
|
15827
16028
|
var hostHeader = req.headers && (req.headers.host || req.headers.Host) || "";
|
|
15828
16029
|
var origin = hostHeader ? ("https://" + hostHeader) : "";
|
|
15829
|
-
|
|
16030
|
+
// The shipped fallback — the same Disallow set the edge robots.txt
|
|
16031
|
+
// emits, kept as the fresh-deploy default.
|
|
16032
|
+
var fallback =
|
|
15830
16033
|
"User-agent: *\n" +
|
|
15831
16034
|
"Allow: /\n" +
|
|
15832
16035
|
"Disallow: /admin\n" +
|
|
@@ -15836,6 +16039,57 @@ function mount(router, deps) {
|
|
|
15836
16039
|
"Disallow: /orders/\n" +
|
|
15837
16040
|
"Disallow: /account\n" +
|
|
15838
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");
|
|
15839
16093
|
res.end ? res.end(body) : res.send(body);
|
|
15840
16094
|
});
|
|
15841
16095
|
|
|
@@ -15859,17 +16113,29 @@ function mount(router, deps) {
|
|
|
15859
16113
|
urls.push({ loc: origin + "/privacy", changefreq: "yearly", priority: "0.3" });
|
|
15860
16114
|
urls.push({ loc: origin + "/terms", changefreq: "yearly", priority: "0.3" });
|
|
15861
16115
|
try {
|
|
15862
|
-
|
|
15863
|
-
|
|
15864
|
-
|
|
15865
|
-
|
|
15866
|
-
|
|
15867
|
-
|
|
15868
|
-
|
|
15869
|
-
|
|
15870
|
-
|
|
15871
|
-
});
|
|
15872
|
-
|
|
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);
|
|
15873
16139
|
} catch (_e) { /* drop-silent — catalog unreachable means sitemap drops product rows */ }
|
|
15874
16140
|
// Active category browse pages, when the categoryNavigation primitive
|
|
15875
16141
|
// is wired. `tree({})` returns the full nested active tree (archived
|
|
@@ -15903,6 +16169,22 @@ function mount(router, deps) {
|
|
|
15903
16169
|
}
|
|
15904
16170
|
} catch (_e) { /* drop-silent — collections unreachable drops those rows */ }
|
|
15905
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
|
+
}
|
|
15906
16188
|
var body = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" +
|
|
15907
16189
|
"<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n";
|
|
15908
16190
|
for (var u = 0; u < urls.length; u += 1) {
|
package/package.json
CHANGED