@blamejs/blamejs-shop 0.3.15 → 0.3.17
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/admin.js +136 -3
- package/lib/asset-manifest.json +1 -1
- package/lib/catalog.js +61 -2
- package/lib/search-facets.js +15 -6
- package/lib/storefront.js +158 -9
- package/lib/vendor/MANIFEST.json +2 -2
- package/lib/vendor/blamejs/CHANGELOG.md +2 -0
- package/lib/vendor/blamejs/api-snapshot.json +2 -2
- package/lib/vendor/blamejs/lib/a2a-tasks.js +6 -6
- package/lib/vendor/blamejs/lib/ai-input.js +1 -1
- package/lib/vendor/blamejs/lib/auth/acr-vocabulary.js +1 -1
- package/lib/vendor/blamejs/lib/auth/ciba.js +3 -3
- package/lib/vendor/blamejs/lib/auth/oauth.js +1 -1
- package/lib/vendor/blamejs/lib/auth/oid4vci.js +1 -1
- package/lib/vendor/blamejs/lib/auth/openid-federation.js +1 -1
- package/lib/vendor/blamejs/lib/breach-deadline.js +3 -3
- package/lib/vendor/blamejs/lib/calendar.js +2 -2
- package/lib/vendor/blamejs/lib/content-credentials.js +3 -3
- package/lib/vendor/blamejs/lib/ddl-change-control.js +2 -2
- package/lib/vendor/blamejs/lib/did.js +2 -2
- package/lib/vendor/blamejs/lib/dsr.js +4 -4
- package/lib/vendor/blamejs/lib/external-db.js +1 -1
- package/lib/vendor/blamejs/lib/guard-cidr.js +1 -1
- package/lib/vendor/blamejs/lib/guard-image.js +1 -1
- package/lib/vendor/blamejs/lib/guard-list-unsubscribe.js +1 -1
- package/lib/vendor/blamejs/lib/guard-time.js +1 -1
- package/lib/vendor/blamejs/lib/guard-xml.js +1 -1
- package/lib/vendor/blamejs/lib/http-client-cache.js +1 -1
- package/lib/vendor/blamejs/lib/iab-tcf.js +4 -4
- package/lib/vendor/blamejs/lib/json-schema.js +1 -1
- package/lib/vendor/blamejs/lib/jtd.js +1 -1
- package/lib/vendor/blamejs/lib/mail-auth.js +1 -1
- package/lib/vendor/blamejs/lib/mail-bimi.js +1 -1
- package/lib/vendor/blamejs/lib/mail-crypto-smime.js +1 -1
- package/lib/vendor/blamejs/lib/mail-server-mx.js +1 -1
- package/lib/vendor/blamejs/lib/mail-server-rate-limit.js +1 -1
- package/lib/vendor/blamejs/lib/mail-server-submission.js +1 -1
- package/lib/vendor/blamejs/lib/mcp.js +7 -7
- package/lib/vendor/blamejs/lib/mdoc.js +1 -1
- package/lib/vendor/blamejs/lib/metrics.js +2 -2
- package/lib/vendor/blamejs/lib/middleware/compose-pipeline.js +1 -1
- package/lib/vendor/blamejs/lib/network-dnssec.js +2 -2
- package/lib/vendor/blamejs/lib/network-smtp-policy.js +1 -1
- package/lib/vendor/blamejs/lib/network-tls.js +1 -1
- package/lib/vendor/blamejs/lib/network-tsig.js +3 -3
- package/lib/vendor/blamejs/lib/rfc3339.js +2 -2
- package/lib/vendor/blamejs/lib/safe-decompress.js +1 -1
- package/lib/vendor/blamejs/lib/standard-webhooks.js +3 -3
- package/lib/vendor/blamejs/lib/stream-throttle.js +2 -2
- package/lib/vendor/blamejs/lib/structured-fields.js +1 -1
- package/lib/vendor/blamejs/lib/vault/seal-pem-file.js +1 -1
- package/lib/vendor/blamejs/lib/web-push-vapid.js +1 -1
- package/lib/vendor/blamejs/lib/webhook.js +1 -1
- package/lib/vendor/blamejs/lib/websocket.js +1 -1
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.14.8.json +27 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +221 -35
- 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.17 (2026-05-30) — **Search results paginate with an accurate count, and collection pages get canonical URLs.** Two customer-facing storefront fixes. Search no longer stops at the first 24 products: the result count now reports the true number of matches instead of the page size, and numbered page links (with prev/next) make every matching product reachable while preserving the active query and filters. Separately, collection pages now emit a correct canonical and og:url so search engines and shared links resolve to one address, while the cart and account pages — which should not be indexed — are explicitly marked noindex. **Fixed:** *Search shows the real match count and paginates* — The search results page previously capped at 24 products, discarded the real total, and reported the page size as the match count — so anything past the first 24 was unreachable and the count was wrong. It now computes the true total, shows it in the "Showing N matches" summary, and adds no-JavaScript numbered page links (with prev/next) that carry the active query and facet filters, so every matching product can be reached. · *Collection pages have canonical URLs; cart and account are noindex* — Collection pages now emit an absolute canonical and og:url derived from the request host, so indexers and shared links resolve to a single address instead of an empty tag. The cart and account pages, which are not meant to be indexed, now carry a noindex robots tag in their markup, consistent on both the edge and container render paths.
|
|
12
|
+
|
|
13
|
+
- v0.3.16 (2026-05-30) — **Uploaded SVGs are sanitized, and product images can be reordered with a chosen primary.** Two things for product media in the admin console. First, when an SVG image is uploaded it now passes through a strict sanitizer: an SVG that carries script or other active content is repaired, or refused outright when it can't be made safe, so a stored image can't become a script-execution vector. Second, a product's images can now be reordered and any image promoted to the primary (hero) slot directly from the console — previously the order was fixed at attach time and the first-attached image was always the hero. The primary image is the one shown first on the product page. **Added:** *Reorder product images and set the primary from the console* — Each image on a product now has a "Make primary" control that moves it to the hero slot shown first on the product page, and a reorder control sets the full order. Both are no-JavaScript form actions. The product page renders images in this order, so changing it here changes what shoppers see first. **Changed:** *Vendored runtime updated to v0.14.8* — The vendored blamejs runtime is updated to v0.14.8, an internal source-comment and lint-hygiene release with no API, wire-format, or behavior changes. No operator action is required. **Security:** *Uploaded SVG images are sanitized or refused* — An uploaded SVG is now checked by a strict sanitizer before it is stored: active content (scripts, event handlers, external references) is stripped, and an SVG that cannot be made safe is rejected with a clear error and never written. This closes the path where a crafted SVG, once stored and served, could execute script. Raster image uploads (PNG, JPEG, WebP, GIF) are unaffected.
|
|
14
|
+
|
|
11
15
|
- v0.3.15 (2026-05-30) — **Product image galleries are interactive — click a thumbnail to view that image full size.** A product page's image gallery now lets a shopper switch between the product's images: clicking a thumbnail swaps the main image, with no JavaScript required. Previously the thumbnail strip was decorative — the additional images were unreachable, a single-image product showed empty placeholder slots, and only the first four images were shown. Now the main image stacks the product's images and the thumbnails are real, keyboard-accessible controls that select which one is shown; a product with one image shows just that image (no empty slots), and the four-image cap is raised to twelve so far more images are reachable. The picker is pure HTML and CSS, so it works without scripting and degrades cleanly. **Fixed:** *The product image gallery actually switches images now* — Thumbnails on the product page are now interactive: selecting one shows that image as the main image, using a no-JavaScript radio/label control. The empty placeholder slots a single-image product used to show are gone, the four-image cap is raised to twelve so far more images are reachable, and the strip is keyboard-navigable with accessible labels. The edge and container renderers emit identical markup.
|
|
12
16
|
|
|
13
17
|
- v0.3.14 (2026-05-30) — **Drop the inert Document-Policy header that only produced browser warnings.** Container-rendered pages were sending a Document-Policy response header whose feature tokens (document-write, unsized-media, oversized-images) are no longer recognized by current browsers — so it enforced nothing while making the browser log a warning for each token on every page. The header is now omitted; every other security header (Content-Security-Policy, Permissions-Policy, HSTS, X-Frame-Options, the COOP/CORP set, and the rest) is unchanged. The edge and container paths now agree — neither sends Document-Policy — closing a header-parity gap. **Fixed:** *No more Document-Policy console warnings* — The Document-Policy header is no longer emitted on container-rendered pages. Its tokens were rejected by current browsers (the policy applied nothing and logged a warning per token on every page), so the header is dropped rather than shipped inert; the full set of other security headers is retained unchanged. Edge and container responses are now consistent.
|
package/lib/admin.js
CHANGED
|
@@ -1200,6 +1200,43 @@ function mount(router, deps) {
|
|
|
1200
1200
|
// caller renders the problem.
|
|
1201
1201
|
async function _storeAndAttach(buf, contentType, body) {
|
|
1202
1202
|
var declared = String(contentType).split(";")[0].trim().toLowerCase();
|
|
1203
|
+
// SVG is text — magic-byte sniffing can't classify it, so the type
|
|
1204
|
+
// gate trusts the declared `image/svg+xml`. An SVG served same-origin
|
|
1205
|
+
// from the media bucket is a stored-XSS vector on direct navigation
|
|
1206
|
+
// (embedded <script>, on* handlers, javascript: URLs, foreignObject
|
|
1207
|
+
// namespace escapes). Run the bytes through the framework's SVG guard
|
|
1208
|
+
// before they reach the bucket: `refuse` (an unrepairable threat —
|
|
1209
|
+
// <script>, DOCTYPE/XXE, SVGZ) is an operational failure the caller
|
|
1210
|
+
// renders as a clean 4xx with no row written; `sanitize` stores the
|
|
1211
|
+
// repaired bytes; `serve` stores the (clean) original. Composes the
|
|
1212
|
+
// vendored b.guardSvg — never a hand-rolled SVG allowlist.
|
|
1213
|
+
if (declared === "image/svg+xml") {
|
|
1214
|
+
var verdict;
|
|
1215
|
+
try {
|
|
1216
|
+
verdict = await b.guardSvg.gate({ profile: "strict" }).check({ bytes: buf });
|
|
1217
|
+
} catch (_e) {
|
|
1218
|
+
return { status: 422, code: "svg-unsafe",
|
|
1219
|
+
detail: "the uploaded SVG could not be validated as safe to serve" };
|
|
1220
|
+
}
|
|
1221
|
+
if (!verdict || verdict.action === "refuse") {
|
|
1222
|
+
return { status: 422, code: "svg-unsafe",
|
|
1223
|
+
detail: "the uploaded SVG carries active content that can't be served safely (scripts, external entities, or a compressed payload)" };
|
|
1224
|
+
}
|
|
1225
|
+
if (verdict.action === "sanitize") {
|
|
1226
|
+
// The guard hands back the repaired bytes as a Buffer (the gate
|
|
1227
|
+
// contract) — or, defensively, a string. Anything else is a guard
|
|
1228
|
+
// malfunction; fail closed rather than store unvalidated bytes.
|
|
1229
|
+
var clean = verdict.sanitized;
|
|
1230
|
+
if (Buffer.isBuffer(clean)) {
|
|
1231
|
+
buf = clean;
|
|
1232
|
+
} else if (typeof clean === "string") {
|
|
1233
|
+
buf = Buffer.from(clean, "utf8");
|
|
1234
|
+
} else {
|
|
1235
|
+
return { status: 422, code: "svg-unsafe",
|
|
1236
|
+
detail: "the uploaded SVG could not be sanitized into safe-to-serve bytes" };
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1203
1240
|
var ext = _extFromContentType(declared);
|
|
1204
1241
|
var id = b.uuid.v7();
|
|
1205
1242
|
var key = "media/" + id + (ext ? "." + ext : "");
|
|
@@ -1432,6 +1469,76 @@ function mount(router, deps) {
|
|
|
1432
1469
|
},
|
|
1433
1470
|
));
|
|
1434
1471
|
|
|
1472
|
+
// Reorder a product's media. The PDP gallery renders the media in list
|
|
1473
|
+
// order with the first row as the hero, so this is "rearrange the gallery
|
|
1474
|
+
// + choose which image leads." Browser form posts the full ordered id set
|
|
1475
|
+
// as a comma-joined `ordered_media_ids` string (same convention as the
|
|
1476
|
+
// collections-members reorder); the JSON API accepts an array. The
|
|
1477
|
+
// primitive rewrites `position` to 0..N-1 and refuses a partial / foreign
|
|
1478
|
+
// set (TypeError → clean 400 / ?err=1). DB-only — no R2 bridge needed.
|
|
1479
|
+
router.post("/admin/products/:id/media/reorder", _pageOrApi(false,
|
|
1480
|
+
W("media.reorder", async function (req, res) {
|
|
1481
|
+
var body = req.body || {};
|
|
1482
|
+
var ids = Array.isArray(body.ordered_media_ids) ? body.ordered_media_ids : null;
|
|
1483
|
+
if (!ids || ids.length === 0) return _problem(res, 400, "bad-request", "ordered_media_ids array required");
|
|
1484
|
+
try { await catalog.media.reorder(req.params.id, ids); }
|
|
1485
|
+
catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
|
|
1486
|
+
_json(res, 200, { ok: true });
|
|
1487
|
+
return { id: req.params.id };
|
|
1488
|
+
}),
|
|
1489
|
+
async function (req, res) {
|
|
1490
|
+
var id = req.params.id;
|
|
1491
|
+
var enc = encodeURIComponent(id);
|
|
1492
|
+
var body = req.body || {};
|
|
1493
|
+
// The reorder form posts a single comma-joined id field; also accept
|
|
1494
|
+
// repeated ordered_media_id rows for parity with the collections form.
|
|
1495
|
+
var ids;
|
|
1496
|
+
if (body.ordered_media_ids != null && typeof body.ordered_media_ids === "string") {
|
|
1497
|
+
ids = body.ordered_media_ids.split(",").map(function (s) { return s.trim(); }).filter(Boolean);
|
|
1498
|
+
} else {
|
|
1499
|
+
var raw = body.ordered_media_id;
|
|
1500
|
+
var rows = raw == null ? [] : (Array.isArray(raw) ? raw : [raw]);
|
|
1501
|
+
ids = rows.map(function (s) { return String(s).trim(); }).filter(Boolean);
|
|
1502
|
+
}
|
|
1503
|
+
if (!ids.length) return _redirect(res, "/admin/products/" + enc + "?err=1");
|
|
1504
|
+
try { await catalog.media.reorder(id, ids); }
|
|
1505
|
+
catch (e) {
|
|
1506
|
+
_safeNotice(e, "media.reorder");
|
|
1507
|
+
return _redirect(res, "/admin/products/" + enc + "?err=1");
|
|
1508
|
+
}
|
|
1509
|
+
b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".media.reorder", outcome: "success", metadata: { id: id } });
|
|
1510
|
+
_redirect(res, "/admin/products/" + enc + "?saved=1");
|
|
1511
|
+
},
|
|
1512
|
+
));
|
|
1513
|
+
|
|
1514
|
+
// Promote one media row to the hero slot (position 0). No body — the media
|
|
1515
|
+
// id is in the path, the product id only used to PRG back to the detail.
|
|
1516
|
+
// A malformed id throws TypeError (→ 400 / ?err=1); an unknown or
|
|
1517
|
+
// variant-only row returns false (→ 404 / ?err=1). DB-only.
|
|
1518
|
+
router.post("/admin/products/:id/media/:mid/primary", _pageOrApi(false,
|
|
1519
|
+
W("media.set_primary", async function (req, res) {
|
|
1520
|
+
var ok;
|
|
1521
|
+
try { ok = await catalog.media.setPrimary(req.params.mid); }
|
|
1522
|
+
catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
|
|
1523
|
+
if (!ok) return _problem(res, 404, "media-not-found");
|
|
1524
|
+
_json(res, 200, { ok: true });
|
|
1525
|
+
return { id: req.params.mid };
|
|
1526
|
+
}),
|
|
1527
|
+
async function (req, res) {
|
|
1528
|
+
var id = req.params.id;
|
|
1529
|
+
var enc = encodeURIComponent(id);
|
|
1530
|
+
var ok = false;
|
|
1531
|
+
try { ok = await catalog.media.setPrimary(req.params.mid); }
|
|
1532
|
+
catch (e) {
|
|
1533
|
+
_safeNotice(e, "media.set_primary");
|
|
1534
|
+
return _redirect(res, "/admin/products/" + enc + "?err=1");
|
|
1535
|
+
}
|
|
1536
|
+
if (!ok) return _redirect(res, "/admin/products/" + enc + "?err=1");
|
|
1537
|
+
b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".media.set_primary", outcome: "success", metadata: { id: req.params.mid } });
|
|
1538
|
+
_redirect(res, "/admin/products/" + enc + "?saved=1");
|
|
1539
|
+
},
|
|
1540
|
+
));
|
|
1541
|
+
|
|
1435
1542
|
// ---- bulk catalog import --------------------------------------------
|
|
1436
1543
|
|
|
1437
1544
|
// POST /admin/catalog/import — Content-Type: text/csv body. The CSV
|
|
@@ -9658,24 +9765,50 @@ function renderAdminProduct(opts) {
|
|
|
9658
9765
|
var variantsSection = "<section class=\"mt\"><h3 class=\"fs-105\">Variants</h3>" + variantsBody + addVariant + "</section>";
|
|
9659
9766
|
|
|
9660
9767
|
// ---- media -----------------------------------------------------------
|
|
9661
|
-
var mediaCards = mediaRows.map(function (m) {
|
|
9768
|
+
var mediaCards = mediaRows.map(function (m, idx) {
|
|
9662
9769
|
var mid = _htmlEscape(m.id);
|
|
9663
9770
|
var url = assetPrefix + m.r2_key;
|
|
9664
9771
|
var isImage = /^image\//.test(m.content_type || "");
|
|
9665
9772
|
var thumb = isImage
|
|
9666
9773
|
? "<img class=\"media-thumb\" src=\"" + _htmlEscape(url) + "\" alt=\"" + _htmlEscape(m.alt_text || "") + "\" loading=\"lazy\">"
|
|
9667
9774
|
: "<span class=\"media-thumb media-thumb--file\">" + _htmlEscape((m.content_type || "file").split("/")[0]) + "</span>";
|
|
9775
|
+
// The first row is the hero shown on the product page — mark it with a
|
|
9776
|
+
// pill; every other row offers a "Make primary" form that promotes it to
|
|
9777
|
+
// position 0 (the CSRF field is injected into the form by the shell).
|
|
9778
|
+
var makePrimary = (idx === 0)
|
|
9779
|
+
? "<span class=\"status-pill paid\">Primary</span>"
|
|
9780
|
+
: "<form method=\"post\" action=\"/admin/products/" + pid + "/media/" + mid + "/primary\" class=\"form-inline\">" +
|
|
9781
|
+
"<button class=\"btn btn--ghost\" type=\"submit\">Make primary</button></form>";
|
|
9668
9782
|
return "<div class=\"media-card\">" +
|
|
9669
9783
|
thumb +
|
|
9670
9784
|
"<code class=\"order-id\">" + _htmlEscape(m.r2_key) + "</code>" +
|
|
9671
9785
|
"<span class=\"u-mute\">" + _htmlEscape(m.content_type || "") + (m.alt_text ? " · " + _htmlEscape(m.alt_text) : "") + "</span>" +
|
|
9672
|
-
"<
|
|
9786
|
+
"<div class=\"actions-row mt-0\">" + makePrimary +
|
|
9787
|
+
"<a class=\"btn btn--danger\" href=\"/admin/media/" + mid + "/delete/confirm-page?product_id=" + pid + "\">Delete</a>" +
|
|
9788
|
+
"</div>" +
|
|
9673
9789
|
"</div>";
|
|
9674
9790
|
}).join("");
|
|
9675
9791
|
var mediaGrid = mediaRows.length
|
|
9676
9792
|
? "<div class=\"media-grid\">" + mediaCards + "</div>"
|
|
9677
9793
|
: "<p class=\"empty\">No media attached yet.</p>";
|
|
9678
9794
|
|
|
9795
|
+
// Reorder: a single field of the current media ids, comma-joined, that the
|
|
9796
|
+
// operator rewrites into the new order. No-JS-required and v1-defensible
|
|
9797
|
+
// — the primitive normalises positions to 0..N-1 and the first row is the
|
|
9798
|
+
// hero. Only shown when there's more than one image to order.
|
|
9799
|
+
var currentMediaIds = mediaRows.map(function (m) { return m.id; }).join(",");
|
|
9800
|
+
var mediaReorderForm = mediaRows.length > 1
|
|
9801
|
+
? "<div class=\"panel mt-1 mw-40\">" +
|
|
9802
|
+
"<h3 class=\"subhead\">Reorder images</h3>" +
|
|
9803
|
+
"<p class=\"meta\">The first image is the hero shown on the product page. Rewrite the comma-separated id list into the order you want — list every current image.</p>" +
|
|
9804
|
+
"<form method=\"post\" action=\"/admin/products/" + pid + "/media/reorder\">" +
|
|
9805
|
+
"<label class=\"form-field\"><span>Ordered media ids</span>" +
|
|
9806
|
+
"<input type=\"text\" name=\"ordered_media_ids\" value=\"" + _htmlEscape(currentMediaIds) + "\"></label>" +
|
|
9807
|
+
"<div class=\"actions-row\"><button class=\"btn btn--ghost\" type=\"submit\">Apply order</button></div>" +
|
|
9808
|
+
"</form>" +
|
|
9809
|
+
"</div>"
|
|
9810
|
+
: "";
|
|
9811
|
+
|
|
9679
9812
|
var attachForm =
|
|
9680
9813
|
"<div class=\"panel mt-1 mw-40\">" +
|
|
9681
9814
|
"<h3 class=\"subhead\">Attach media by R2 key</h3>" +
|
|
@@ -9716,7 +9849,7 @@ function renderAdminProduct(opts) {
|
|
|
9716
9849
|
"</div>"
|
|
9717
9850
|
: "";
|
|
9718
9851
|
|
|
9719
|
-
var mediaSection = "<section class=\"mt\"><h3 class=\"fs-105\">Media</h3>" + mediaGrid + fileUploadForm + attachForm + uploadForm + "</section>";
|
|
9852
|
+
var mediaSection = "<section class=\"mt\"><h3 class=\"fs-105\">Media</h3>" + mediaGrid + mediaReorderForm + fileUploadForm + attachForm + uploadForm + "</section>";
|
|
9720
9853
|
|
|
9721
9854
|
// ---- head + assembly -------------------------------------------------
|
|
9722
9855
|
var statusCls = p.status === "active" ? "paid" : (p.status === "archived" ? "refunded" : "pending");
|
package/lib/asset-manifest.json
CHANGED
package/lib/catalog.js
CHANGED
|
@@ -8,10 +8,11 @@
|
|
|
8
8
|
* Five sub-modules:
|
|
9
9
|
*
|
|
10
10
|
* - `products` — create, get, bySlug, list, update, archive, restore
|
|
11
|
-
* - `variants` — create, get, listForProduct, update, delete
|
|
11
|
+
* - `variants` — create, get, listForProduct, update, delete
|
|
12
12
|
* - `prices` — set (versioned), current, history
|
|
13
13
|
* - `inventory` — create, get, decrement, release, restock
|
|
14
|
-
* - `media` — attach, listForProduct, listForVariant, delete,
|
|
14
|
+
* - `media` — attach, get, listForProduct, listForVariant, delete,
|
|
15
|
+
* reorder, setPrimary
|
|
15
16
|
*
|
|
16
17
|
* Every write validates input at entry; bad input throws TypeError
|
|
17
18
|
* with a descriptive message so the calling route handler can
|
|
@@ -789,6 +790,64 @@ function _mediaModule(query) {
|
|
|
789
790
|
var r = await query("DELETE FROM media WHERE id = ?1", [id]);
|
|
790
791
|
return r.rowCount > 0;
|
|
791
792
|
},
|
|
793
|
+
|
|
794
|
+
// Reorder a product's media. `orderedIds` is the FULL set of this
|
|
795
|
+
// product's media ids in the desired order; `position` is rewritten to
|
|
796
|
+
// 0..N-1 to match. Throws TypeError on a non-array / id-shape error or
|
|
797
|
+
// when the set doesn't exactly match the product's current media (a
|
|
798
|
+
// missing / extra / foreign id) — a partial reorder would silently drop
|
|
799
|
+
// a row's ordering. D1 has no cross-statement transaction over the HTTP
|
|
800
|
+
// bridge, so positions are written one UPDATE per row, each scoped by
|
|
801
|
+
// product_id so a crafted id list can never reposition another product's
|
|
802
|
+
// row out of band. Returns the count written.
|
|
803
|
+
reorder: async function (productId, orderedIds) {
|
|
804
|
+
_id(productId, "product_id");
|
|
805
|
+
if (!Array.isArray(orderedIds) || orderedIds.length === 0) {
|
|
806
|
+
throw new TypeError("catalog.media.reorder: orderedIds must be a non-empty array");
|
|
807
|
+
}
|
|
808
|
+
orderedIds.forEach(function (id) { _id(id, "media id"); });
|
|
809
|
+
var existing = await query("SELECT id FROM media WHERE product_id = ?1", [productId]);
|
|
810
|
+
var have = existing.rows.map(function (r) { return r.id; }).sort();
|
|
811
|
+
var want = orderedIds.slice().sort();
|
|
812
|
+
if (have.length !== want.length ||
|
|
813
|
+
have.some(function (id, i) { return id !== want[i]; })) {
|
|
814
|
+
throw new TypeError("catalog.media.reorder: orderedIds must list exactly this product's current media");
|
|
815
|
+
}
|
|
816
|
+
for (var i = 0; i < orderedIds.length; i += 1) {
|
|
817
|
+
await query(
|
|
818
|
+
"UPDATE media SET position = ?1 WHERE id = ?2 AND product_id = ?3",
|
|
819
|
+
[i, orderedIds[i], productId],
|
|
820
|
+
);
|
|
821
|
+
}
|
|
822
|
+
return orderedIds.length;
|
|
823
|
+
},
|
|
824
|
+
|
|
825
|
+
// Promote one media row to the hero slot (position 0) and push every
|
|
826
|
+
// other row of the same product down by one, preserving their relative
|
|
827
|
+
// order. The PDP gallery renders media[0] as the hero, so this is the
|
|
828
|
+
// "set primary image" operation. Throws TypeError on a malformed id;
|
|
829
|
+
// returns false when the id doesn't resolve to a product-scoped row
|
|
830
|
+
// (an unknown id, or a variant-only row that has no product_id). Every
|
|
831
|
+
// UPDATE is scoped by product_id — same cross-product-IDOR guard as
|
|
832
|
+
// reorder.
|
|
833
|
+
setPrimary: async function (mediaId) {
|
|
834
|
+
_id(mediaId, "media id");
|
|
835
|
+
var row = (await query("SELECT id, product_id FROM media WHERE id = ?1", [mediaId])).rows[0];
|
|
836
|
+
if (!row || !row.product_id) return false;
|
|
837
|
+
var ordered = (await query(
|
|
838
|
+
"SELECT id FROM media WHERE product_id = ?1 ORDER BY position ASC, created_at ASC",
|
|
839
|
+
[row.product_id],
|
|
840
|
+
)).rows.map(function (r) { return r.id; });
|
|
841
|
+
var without = ordered.filter(function (id) { return id !== mediaId; });
|
|
842
|
+
var next = [mediaId].concat(without);
|
|
843
|
+
for (var i = 0; i < next.length; i += 1) {
|
|
844
|
+
await query(
|
|
845
|
+
"UPDATE media SET position = ?1 WHERE id = ?2 AND product_id = ?3",
|
|
846
|
+
[i, next[i], row.product_id],
|
|
847
|
+
);
|
|
848
|
+
}
|
|
849
|
+
return true;
|
|
850
|
+
},
|
|
792
851
|
};
|
|
793
852
|
}
|
|
794
853
|
|
package/lib/search-facets.js
CHANGED
|
@@ -36,10 +36,12 @@
|
|
|
36
36
|
* the facet registry portable across whichever search
|
|
37
37
|
* backend the operator chooses.
|
|
38
38
|
*
|
|
39
|
-
* `previewQuery({ query, filters })` returns the
|
|
40
|
-
* a
|
|
41
|
-
* combination — used by the storefront to render "Show 23
|
|
42
|
-
* before the shopper commits the click
|
|
39
|
+
* `previewQuery({ query, filters, sample?, offset? })` returns the
|
|
40
|
+
* product count + a window of matching products for a candidate
|
|
41
|
+
* filter combination — used by the storefront to render "Show 23
|
|
42
|
+
* results" before the shopper commits the click, and to page the
|
|
43
|
+
* results grid (`offset`/`sample` window the matches; `total` stays
|
|
44
|
+
* the full passing count so the result-count copy is page-independent).
|
|
43
45
|
*
|
|
44
46
|
* `recordFacetUse({ key, value, session_id })` appends an
|
|
45
47
|
* analytics row. The session id is namespaceHashed under
|
|
@@ -66,7 +68,7 @@
|
|
|
66
68
|
* - updateFacet(key, patch)
|
|
67
69
|
* - archiveFacet(key)
|
|
68
70
|
* - getFacets({ query?, applied_filters?, scope? })
|
|
69
|
-
* - previewQuery({ query?, filters })
|
|
71
|
+
* - previewQuery({ query?, filters, sample?, offset? })
|
|
70
72
|
* - recordFacetUse({ key, value, session_id })
|
|
71
73
|
*
|
|
72
74
|
* Catalog contract — the operator passes a `catalog` to the
|
|
@@ -768,6 +770,13 @@ function create(opts) {
|
|
|
768
770
|
var qStr = _optQuery(input.query, "searchFacets.previewQuery");
|
|
769
771
|
var filters = _appliedFilters(input.filters, "searchFacets.previewQuery");
|
|
770
772
|
var sample = _sampleSize(input.sample, "searchFacets.previewQuery");
|
|
773
|
+
// `offset` pages the returned window over the full passing set so a
|
|
774
|
+
// caller can request page N of the matches (storefront search
|
|
775
|
+
// pagination) without re-running the in-memory filter walk. The
|
|
776
|
+
// `total` is always the REAL passing count — the page window never
|
|
777
|
+
// changes it — so the result-count copy stays honest regardless of
|
|
778
|
+
// which page is requested. Defaults to 0 (the first window).
|
|
779
|
+
var offset = _offset(input.offset, "searchFacets.previewQuery");
|
|
771
780
|
|
|
772
781
|
var facets = await _loadFacets();
|
|
773
782
|
var facetsByKey = {};
|
|
@@ -790,7 +799,7 @@ function create(opts) {
|
|
|
790
799
|
}
|
|
791
800
|
return {
|
|
792
801
|
total: passing.length,
|
|
793
|
-
sample: passing.slice(
|
|
802
|
+
sample: passing.slice(offset, offset + sample),
|
|
794
803
|
};
|
|
795
804
|
},
|
|
796
805
|
|
package/lib/storefront.js
CHANGED
|
@@ -243,6 +243,7 @@ var LAYOUT =
|
|
|
243
243
|
" <title>{{title}} — {{shop_name}}</title>\n" +
|
|
244
244
|
" <meta name=\"description\" content=\"{{og_description}}\">\n" +
|
|
245
245
|
" <link rel=\"canonical\" href=\"{{canonical_url}}\">\n" +
|
|
246
|
+
"RAW_ROBOTS_META" +
|
|
246
247
|
" <link rel=\"icon\" type=\"image/svg+xml\" href=\"/assets/brand/favicon.svg\">\n" +
|
|
247
248
|
" <link rel=\"icon\" type=\"image/png\" href=\"/assets/brand/favicon.png\">\n" +
|
|
248
249
|
" <link rel=\"apple-touch-icon\" href=\"/assets/brand/favicon.png\">\n" +
|
|
@@ -755,8 +756,21 @@ function _wrap(opts) {
|
|
|
755
756
|
vars[ck] = chrome[ck];
|
|
756
757
|
}
|
|
757
758
|
|
|
759
|
+
// Per-page robots directive. Indexable pages (the default) emit no
|
|
760
|
+
// robots meta — the absence means "index, follow". Pages the storefront
|
|
761
|
+
// keeps out of search (cart / account / any session-scoped surface)
|
|
762
|
+
// pass `robots: "noindex"`, which stamps a noindex,nofollow meta in the
|
|
763
|
+
// <head> — a belt-and-suspenders pairing with the robots.txt Disallow +
|
|
764
|
+
// the edge `x-robots-tag` header, so a directly-fetched page is also
|
|
765
|
+
// self-describing. A noindex page needs no canonical (a crawler won't
|
|
766
|
+
// index it to dedupe), so these pages don't thread a canonical_url.
|
|
767
|
+
var robotsMeta = (opts.robots === "noindex")
|
|
768
|
+
? " <meta name=\"robots\" content=\"noindex,nofollow\">\n"
|
|
769
|
+
: "";
|
|
770
|
+
|
|
758
771
|
var assembled = _render(LAYOUT, vars)
|
|
759
772
|
.replace("RAW_CSS_INTEGRITY", themeCssIntegrity)
|
|
773
|
+
.replace("RAW_ROBOTS_META", robotsMeta)
|
|
760
774
|
.replace("RAW_ANNOUNCEMENT_BAR", announcementBarHtml)
|
|
761
775
|
.replace("RAW_CONSENT_SCRIPT", _islandScript("consent.js", { id: "consent-island", policy: _activeConsentPolicy }))
|
|
762
776
|
.replace("RAW_CART_COUNT_SCRIPT", _islandScript("cart-count.js", { id: "cart-count-island" }))
|
|
@@ -1331,6 +1345,87 @@ function _renderSearchChips(facets, filters, q) {
|
|
|
1331
1345
|
return "<div class=\"search-active-filters\" aria-label=\"Active filters\">\n" + chips + clearAll + "</div>\n";
|
|
1332
1346
|
}
|
|
1333
1347
|
|
|
1348
|
+
// The fixed search page size — the number of result cards one page of
|
|
1349
|
+
// `/search` shows. Pages beyond the first are reached via `?page=N`
|
|
1350
|
+
// links the pagination nav emits (the real total drives how many pages
|
|
1351
|
+
// exist). Mirrors the edge renderer's SEARCH_PAGE_SIZE.
|
|
1352
|
+
var SEARCH_PAGE_SIZE = 24;
|
|
1353
|
+
|
|
1354
|
+
// `/search?...` URL for a specific results page — the query + active
|
|
1355
|
+
// filters carried forward (so paging preserves the facet state) with a
|
|
1356
|
+
// `page` param appended for any page past the first. Page 1 omits the
|
|
1357
|
+
// param so the canonical first page has one URL. Mirrors the edge
|
|
1358
|
+
// renderer's `_searchPageUrl` byte-for-byte.
|
|
1359
|
+
function _searchPageUrl(q, filters, page) {
|
|
1360
|
+
var base = _searchUrl(q, filters);
|
|
1361
|
+
if (page <= 1) return base;
|
|
1362
|
+
return base + (base.indexOf("?") === -1 ? "?" : "&") + "page=" + page;
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
var SEARCH_PAGE_LINK =
|
|
1366
|
+
"<li class=\"search-pagination__page\"><a class=\"search-pagination__link\" href=\"{{href}}\"{{aria_current}}>{{n}}</a></li>\n";
|
|
1367
|
+
|
|
1368
|
+
// Numbered prev/next pagination for the result grid. No-JS + SEO-
|
|
1369
|
+
// friendly: every page is a real `?page=N` link carrying the active
|
|
1370
|
+
// query + facets, `rel="prev"/"next"` mark the sequence, and the current
|
|
1371
|
+
// page is `aria-current="page"`. Renders nothing for a single page (the
|
|
1372
|
+
// default when a caller threads no total — keeps the unpaginated render
|
|
1373
|
+
// byte-identical). `total` is the REAL match count (not the page slice
|
|
1374
|
+
// length); `page` is the 1-based current page. Mirrors the edge
|
|
1375
|
+
// renderer's `_renderSearchPagination` byte-for-byte.
|
|
1376
|
+
function _renderSearchPagination(q, filters, total, page, pageSize) {
|
|
1377
|
+
var size = pageSize > 0 ? pageSize : SEARCH_PAGE_SIZE;
|
|
1378
|
+
var totalPages = Math.max(1, Math.ceil(total / size));
|
|
1379
|
+
if (totalPages <= 1) return "";
|
|
1380
|
+
var cur = page < 1 ? 1 : (page > totalPages ? totalPages : page);
|
|
1381
|
+
var prev = cur > 1
|
|
1382
|
+
? _render("<a class=\"search-pagination__link search-pagination__prev\" href=\"{{href}}\" rel=\"prev\">Previous</a>\n",
|
|
1383
|
+
{ href: _searchPageUrl(q, filters, cur - 1) })
|
|
1384
|
+
: "<span class=\"search-pagination__link search-pagination__prev is-disabled\" aria-disabled=\"true\">Previous</span>\n";
|
|
1385
|
+
var next = cur < totalPages
|
|
1386
|
+
? _render("<a class=\"search-pagination__link search-pagination__next\" href=\"{{href}}\" rel=\"next\">Next</a>\n",
|
|
1387
|
+
{ href: _searchPageUrl(q, filters, cur + 1) })
|
|
1388
|
+
: "<span class=\"search-pagination__link search-pagination__next is-disabled\" aria-disabled=\"true\">Next</span>\n";
|
|
1389
|
+
var pagesHtml = "";
|
|
1390
|
+
for (var n = 1; n <= totalPages; n += 1) {
|
|
1391
|
+
pagesHtml += _render(SEARCH_PAGE_LINK, {
|
|
1392
|
+
href: _searchPageUrl(q, filters, n),
|
|
1393
|
+
aria_current: n === cur ? "RAW_ARIA" : "",
|
|
1394
|
+
n: String(n),
|
|
1395
|
+
}).replace("RAW_ARIA", n === cur ? " aria-current=\"page\"" : "");
|
|
1396
|
+
}
|
|
1397
|
+
return "<nav class=\"search-pagination\" aria-label=\"Search results pages\">\n" +
|
|
1398
|
+
prev +
|
|
1399
|
+
"<ol class=\"search-pagination__pages\">\n" + pagesHtml + "</ol>\n" +
|
|
1400
|
+
next +
|
|
1401
|
+
"</nav>\n";
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
// Read the 1-based `?page=N` results page off a parsed URL. A missing,
|
|
1405
|
+
// non-integer, or sub-1 value reads as page 1 (the canonical first page);
|
|
1406
|
+
// the upper bound is clamped against the real page count by `_clampPage`
|
|
1407
|
+
// once the total is known. Defensive request-shape reader — returns the
|
|
1408
|
+
// safe default for any garbage rather than throwing.
|
|
1409
|
+
function _parsePageParam(url) {
|
|
1410
|
+
if (!url || !url.searchParams) return 1;
|
|
1411
|
+
var raw = url.searchParams.get("page");
|
|
1412
|
+
if (raw == null) return 1;
|
|
1413
|
+
if (!/^[0-9]{1,9}$/.test(raw)) return 1;
|
|
1414
|
+
var n = parseInt(raw, 10);
|
|
1415
|
+
return n >= 1 ? n : 1;
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
// Clamp a 1-based page to `[1, lastPage]` for a given total + page size.
|
|
1419
|
+
// A `?page` past the end serves the last page (a stable, link-followable
|
|
1420
|
+
// result) rather than an empty grid; a zero-result query stays on page 1.
|
|
1421
|
+
function _clampPage(page, total, pageSize) {
|
|
1422
|
+
var size = pageSize > 0 ? pageSize : SEARCH_PAGE_SIZE;
|
|
1423
|
+
var lastPage = Math.max(1, Math.ceil(total / size));
|
|
1424
|
+
if (page < 1) return 1;
|
|
1425
|
+
if (page > lastPage) return lastPage;
|
|
1426
|
+
return page;
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1334
1429
|
// Hard caps mirroring `lib/search-facets.js`'s applied-filter
|
|
1335
1430
|
// validators so a hostile / stale URL can't blow the in-memory facet
|
|
1336
1431
|
// walk. Garbage values and unknown facet keys are dropped (not
|
|
@@ -1377,6 +1472,14 @@ function renderSearch(opts) {
|
|
|
1377
1472
|
var facets = Array.isArray(opts.facets) ? opts.facets : [];
|
|
1378
1473
|
var filters = (opts.filters && typeof opts.filters === "object") ? opts.filters : {};
|
|
1379
1474
|
var hasFilters = Object.keys(filters).length > 0;
|
|
1475
|
+
// The result count copy + the page math run off the REAL match total —
|
|
1476
|
+
// the count of every product the query matched, not the length of the
|
|
1477
|
+
// page slice the renderer was handed. A caller that doesn't thread a
|
|
1478
|
+
// total (a unit test rendering one page directly) falls back to the page
|
|
1479
|
+
// length so the unpaginated render is unchanged.
|
|
1480
|
+
var pageSize = (typeof opts.page_size === "number" && opts.page_size > 0) ? opts.page_size : SEARCH_PAGE_SIZE;
|
|
1481
|
+
var totalCount = (typeof opts.total === "number" && opts.total >= 0) ? opts.total : products.length;
|
|
1482
|
+
var page = (typeof opts.page === "number" && opts.page >= 1) ? Math.floor(opts.page) : 1;
|
|
1380
1483
|
var qTrim = opts.q.trim();
|
|
1381
1484
|
var title, summary, emptyHeading, emptyCopy;
|
|
1382
1485
|
if (qTrim.length === 0) {
|
|
@@ -1384,14 +1487,14 @@ function renderSearch(opts) {
|
|
|
1384
1487
|
summary = "Use the search box in the header to look for a product by title, SKU, or description.";
|
|
1385
1488
|
emptyHeading = "What are you looking for?";
|
|
1386
1489
|
emptyCopy = "Type a query in the header search to find products by title, SKU, or description.";
|
|
1387
|
-
} else if (
|
|
1490
|
+
} else if (totalCount === 0) {
|
|
1388
1491
|
title = "No matches";
|
|
1389
1492
|
summary = "Nothing in the catalog matched “" + qTrim + "”.";
|
|
1390
1493
|
emptyHeading = "We don't carry that yet";
|
|
1391
1494
|
emptyCopy = "Try a broader term, or browse every product on the home page.";
|
|
1392
1495
|
} else {
|
|
1393
1496
|
title = "“" + qTrim + "”";
|
|
1394
|
-
summary = "Showing " +
|
|
1497
|
+
summary = "Showing " + totalCount + " match" + (totalCount === 1 ? "" : "es") + " for your query.";
|
|
1395
1498
|
}
|
|
1396
1499
|
|
|
1397
1500
|
var correctionHtml = "";
|
|
@@ -1424,6 +1527,14 @@ function renderSearch(opts) {
|
|
|
1424
1527
|
}).join("\n");
|
|
1425
1528
|
resultsInner = "<section class=\"search-grid\"><div class=\"grid\">" + cards + "</div></section>";
|
|
1426
1529
|
}
|
|
1530
|
+
// Pagination nav under the grid — `?page=N` links carrying the active
|
|
1531
|
+
// query + facets so paging never drops the filter state. Renders nothing
|
|
1532
|
+
// for a single page (the result count fits one page) or the empty / no-
|
|
1533
|
+
// query states. Driven by the real total, not the page slice length.
|
|
1534
|
+
var paginationHtml = (qTrim.length > 0 && totalCount > 0)
|
|
1535
|
+
? _renderSearchPagination(opts.q, filters, totalCount, page, pageSize)
|
|
1536
|
+
: "";
|
|
1537
|
+
resultsInner += paginationHtml;
|
|
1427
1538
|
var body;
|
|
1428
1539
|
if (facetsHtml.length > 0) {
|
|
1429
1540
|
body = header + correctionHtml + chipsHtml +
|
|
@@ -2689,6 +2800,7 @@ function renderCollectionList(opts) {
|
|
|
2689
2800
|
return _wrap({
|
|
2690
2801
|
title: "Collections", shop_name: opts.shop_name || "blamejs.shop",
|
|
2691
2802
|
cart_count: opts.cart_count == null ? 0 : opts.cart_count, theme_css: opts.theme_css, body: body,
|
|
2803
|
+
canonical_url: opts.canonical_url, og_url: opts.og_url,
|
|
2692
2804
|
});
|
|
2693
2805
|
}
|
|
2694
2806
|
|
|
@@ -2719,6 +2831,7 @@ function renderCollection(opts) {
|
|
|
2719
2831
|
return _wrap({
|
|
2720
2832
|
title: col.title, shop_name: opts.shop_name || "blamejs.shop",
|
|
2721
2833
|
cart_count: opts.cart_count == null ? 0 : opts.cart_count, theme_css: opts.theme_css, body: body,
|
|
2834
|
+
canonical_url: opts.canonical_url, og_url: opts.og_url,
|
|
2722
2835
|
});
|
|
2723
2836
|
}
|
|
2724
2837
|
|
|
@@ -4969,6 +5082,11 @@ function renderCart(opts) {
|
|
|
4969
5082
|
shop_name: shopName,
|
|
4970
5083
|
cart_count: lines.length,
|
|
4971
5084
|
theme_css: opts.theme_css,
|
|
5085
|
+
// The cart is session-scoped and robots.txt-disallowed — keep it out
|
|
5086
|
+
// of the index with a noindex meta (matches the edge x-robots-tag on
|
|
5087
|
+
// the guest cart render), so a directly-crawled cart URL is self-
|
|
5088
|
+
// describing rather than relying on robots.txt alone.
|
|
5089
|
+
robots: "noindex",
|
|
4972
5090
|
body: body,
|
|
4973
5091
|
}, _currencyWrapOpts(opts)));
|
|
4974
5092
|
}
|
|
@@ -5808,6 +5926,10 @@ function renderAccount(opts) {
|
|
|
5808
5926
|
shop_name: opts.shop_name || "blamejs.shop",
|
|
5809
5927
|
cart_count: opts.cart_count,
|
|
5810
5928
|
theme_css: opts.theme_css,
|
|
5929
|
+
// The account dashboard is per-customer + robots.txt-disallowed — keep
|
|
5930
|
+
// it out of the index with a noindex meta rather than relying on the
|
|
5931
|
+
// crawl policy alone.
|
|
5932
|
+
robots: "noindex",
|
|
5811
5933
|
body: body,
|
|
5812
5934
|
});
|
|
5813
5935
|
}
|
|
@@ -7396,10 +7518,17 @@ function mount(router, deps) {
|
|
|
7396
7518
|
// otherwise round-trip through the LIKE escape function.
|
|
7397
7519
|
if (q.length > 200) q = q.slice(0, 200);
|
|
7398
7520
|
|
|
7521
|
+
// 1-based results page from `?page=N`. A missing / non-numeric /
|
|
7522
|
+
// out-of-low-range value reads as page 1; the upper bound is clamped
|
|
7523
|
+
// to the real page count once the total is known (below) so a `?page`
|
|
7524
|
+
// past the end serves the last page rather than an empty grid.
|
|
7525
|
+
var page = _parsePageParam(url);
|
|
7526
|
+
|
|
7399
7527
|
var products = [];
|
|
7400
7528
|
var facetGroups = [];
|
|
7401
7529
|
var filters = {};
|
|
7402
7530
|
var correctedQ = "";
|
|
7531
|
+
var totalCount = 0;
|
|
7403
7532
|
|
|
7404
7533
|
if (q.trim().length > 0) {
|
|
7405
7534
|
// Synonym + typo rewrite expands the typed query into the
|
|
@@ -7434,11 +7563,28 @@ function mount(router, deps) {
|
|
|
7434
7563
|
var facetDefs = await sfInstance.listFacets({});
|
|
7435
7564
|
filters = _parseSearchFilters(url, facetDefs);
|
|
7436
7565
|
facetGroups = await sfInstance.getFacets({ query: q, applied_filters: filters });
|
|
7437
|
-
|
|
7566
|
+
// First pass reads the REAL total so the result-count copy is
|
|
7567
|
+
// honest and the page can be clamped to the last page. The second
|
|
7568
|
+
// pass windows that page (offset/sample) over the full passing set
|
|
7569
|
+
// — pages past the first are reachable, not discarded at 24.
|
|
7570
|
+
var totalPreview = await sfInstance.previewQuery({ query: q, filters: filters, sample: 0 });
|
|
7571
|
+
totalCount = totalPreview.total;
|
|
7572
|
+
page = _clampPage(page, totalCount, SEARCH_PAGE_SIZE);
|
|
7573
|
+
var preview = await sfInstance.previewQuery({
|
|
7574
|
+
query: q,
|
|
7575
|
+
filters: filters,
|
|
7576
|
+
sample: SEARCH_PAGE_SIZE,
|
|
7577
|
+
offset: (page - 1) * SEARCH_PAGE_SIZE,
|
|
7578
|
+
});
|
|
7438
7579
|
products = preview.sample;
|
|
7439
7580
|
} else {
|
|
7440
|
-
// No facet dep — flat search over the expanded terms.
|
|
7441
|
-
|
|
7581
|
+
// No facet dep — flat search over the expanded terms. The full
|
|
7582
|
+
// matched universe gives the honest total; the page is the
|
|
7583
|
+
// windowed slice (the same page size the faceted path uses).
|
|
7584
|
+
var flatUniverse = await _facetableUniverse(terms);
|
|
7585
|
+
totalCount = flatUniverse.length;
|
|
7586
|
+
page = _clampPage(page, totalCount, SEARCH_PAGE_SIZE);
|
|
7587
|
+
products = flatUniverse.slice((page - 1) * SEARCH_PAGE_SIZE, page * SEARCH_PAGE_SIZE);
|
|
7442
7588
|
}
|
|
7443
7589
|
}
|
|
7444
7590
|
|
|
@@ -7450,6 +7596,9 @@ function mount(router, deps) {
|
|
|
7450
7596
|
facets: facetGroups,
|
|
7451
7597
|
filters: filters,
|
|
7452
7598
|
corrected_query: correctedQ,
|
|
7599
|
+
total: totalCount,
|
|
7600
|
+
page: page,
|
|
7601
|
+
page_size: SEARCH_PAGE_SIZE,
|
|
7453
7602
|
shop_name: shopName,
|
|
7454
7603
|
cart_count: cartCount,
|
|
7455
7604
|
}, _requestUrls(req), ccy)));
|
|
@@ -7809,9 +7958,9 @@ function mount(router, deps) {
|
|
|
7809
7958
|
router.get("/collections", async function (req, res) {
|
|
7810
7959
|
var cols = await deps.collections.list({ active_only: true });
|
|
7811
7960
|
var cartCount = await _cartCountForReq(req);
|
|
7812
|
-
_send(res, 200, renderCollectionList({
|
|
7961
|
+
_send(res, 200, renderCollectionList(Object.assign({
|
|
7813
7962
|
collections: cols, shop_name: shopName, cart_count: cartCount, asset_prefix: _cardAssetPrefix,
|
|
7814
|
-
}));
|
|
7963
|
+
}, _requestUrls(req))));
|
|
7815
7964
|
});
|
|
7816
7965
|
|
|
7817
7966
|
router.get("/collections/:slug", async function (req, res) {
|
|
@@ -7835,9 +7984,9 @@ function mount(router, deps) {
|
|
|
7835
7984
|
if (card) products.push(card);
|
|
7836
7985
|
}
|
|
7837
7986
|
var cartCount = await _cartCountForReq(req);
|
|
7838
|
-
_send(res, 200, renderCollection({
|
|
7987
|
+
_send(res, 200, renderCollection(Object.assign({
|
|
7839
7988
|
collection: col, products: products, shop_name: shopName, cart_count: cartCount,
|
|
7840
|
-
}));
|
|
7989
|
+
}, _requestUrls(req))));
|
|
7841
7990
|
});
|
|
7842
7991
|
}
|
|
7843
7992
|
|
package/lib/vendor/MANIFEST.json
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
"_about": "blamejs.shop vendors a single framework — blamejs — which itself bundles every server-side crypto/identity dependency. The transitive packages blamejs ships are surfaced in its own MANIFEST.json at lib/vendor/blamejs/lib/vendor/MANIFEST.json — Trivy / Grype rely on that nested data for CVE attribution.",
|
|
4
4
|
"packages": {
|
|
5
5
|
"blamejs": {
|
|
6
|
-
"version": "0.14.
|
|
7
|
-
"tag": "v0.14.
|
|
6
|
+
"version": "0.14.8",
|
|
7
|
+
"tag": "v0.14.8",
|
|
8
8
|
"license": "Apache-2.0",
|
|
9
9
|
"author": "blamejs contributors",
|
|
10
10
|
"source": "https://github.com/blamejs/blamejs",
|