@blamejs/blamejs-shop 0.4.11 → 0.4.12

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 CHANGED
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.4.x
10
10
 
11
+ - v0.4.12 (2026-06-05) — **Blog posts render their Markdown, plural search queries match again, and the search-ranking metrics view gets the impression and click data it was built to read.** Four search-and-content fixes. Published blog posts were showing their raw Markdown source — headings, bold, and links rendered as literal text — even though the authoring screen has always promised Markdown; the edge now renders it through the same sanitized renderer the post primitive uses, so the live page matches the editor's promise. The query stemmer over-stripped plurals ending in a vowel plus "es" ("tees", "shoes", "does"), so those searches missed their synonym groups and matched unrelated products; it now strips those down to the right singular. The search-ranking event log finally has production writers — every search records an impression and every result click records a click against the active weight set, so the admin search-metrics view shows real click-through and conversion instead of an empty table. And the analytics search funnel now applies the same hygiene the autocomplete log does, so "Popular searches" and the analytics top-terms report agree. **Added:** *Search-ranking impressions and clicks are recorded, so the admin metrics view has data* — The search-ranking feature ships a weight-set metrics view that reads an event log to report click-through, conversion, and click-to-purchase per weight set — but nothing ever wrote those events, so the view was always empty. Two writers are now wired. Every search records one impression against the active weight set (a drop-silent, fire-and-forget write that never affects the search response). Every search result link carries a `?from=search` marker plus the query it was ranked for; when a shopper follows one to a product page, that page records a click against the active weight set. Both work with JavaScript off — the click signal is read server-side from the link, with no tracking beacon. Facet narrowing is recorded the same way, so the facet-usage rollup reflects real use. Purchase attribution is deliberately left out of this release: tying a completed order back to the search that led to it needs a session-to-order link the event schema doesn't carry, so the metrics view reports impressions, clicks, and the click-through ratio from real traffic while purchase counts and conversion stay at zero until that linkage ships. **Fixed:** *Blog posts render Markdown instead of showing the raw source* — A published post's body is authored in Markdown — the editor labels the field "Body (Markdown)" and the post primitive's renderer turns headings, lists, blockquotes, rules, inline code, bold, italic, and https-or-rooted links into HTML. But the edge-served public post page painted the body as plain escaped text, so a shopper saw literal `## Heading` and `**bold**` markers rather than formatted copy. The edge now renders the body through the same sanitized renderer the primitive uses, so the live page matches what the editor previews and the authoring screen promises. The security posture is unchanged: every operator-authored byte is HTML-escaped, raw HTML never passes through (any `<` lands as `&lt;`), and link URLs are restricted to https or `/`-rooted paths — a `<script>` or `javascript:` link in a post body renders inert. · *Plural search queries ending in a vowel plus "es" match again* — The query stemmer tried its `-es` plural rule before its `-s` rule, so a word ending in a vowel plus "es" was over-stripped: "tees" became "te", "shoes" became "sho", "does" became "do". Two things broke as a result — the over-stripped term no longer matched its operator-curated synonym group (so a search for "tees" missed a "tee"/"t-shirt" grouping), and the truncated term fed a broad substring match that surfaced unrelated products. The stemmer now strips the full `-es` only when the stem ends in a sibilant ("boxes" → "box", "dishes" → "dish", "churches" → "church") and takes the bare `-s` otherwise ("tees" → "tee", "shoes" → "shoe", "does" → "doe"). The edge search mirror carries the identical rule, so the result is the same whether the page is served from the edge cache or the container. · *The analytics search funnel and the autocomplete log apply the same query hygiene* — Two search sinks recorded the typed query with different cleaning. The autocomplete query log stripped control bytes and lowercased before writing; the analytics funnel that feeds the top-search-terms report trimmed only — so a control byte could reach the analytics table raw, and the two reports counted differently. The storefront now normalizes the query once (strip control bytes, trim, lowercase) and hands the same value to both sinks. The analytics top-terms report also groups case-insensitively, so a term typed as "Hat" and "hat" collapses into one row — folding any mixed-case history written before this — matching how "Popular searches" has always counted. The consent gate on analytics recording is unchanged.
12
+
11
13
  - v0.4.11 (2026-06-05) — **Search autocomplete: a live suggestions dropdown in the header, plus an admin screen to curate it.** Typing in the storefront search box now opens an autocomplete dropdown with matching products, what other shoppers are searching for, and operator-curated featured links. A new admin screen pins those featured suggestions and surfaces a popular-searches report so you can see demand and spot terms that return nothing. The search box keeps working with JavaScript off — the dropdown is a progressive enhancement on top of the plain form. **Added:** *Search autocomplete in the storefront header* — As a shopper types in the header search box, a dropdown opens beneath it with up to three groups: matching products (linking straight to the product page), popular recent searches (click one to run it), and operator-curated featured suggestions (linking wherever you point them). It's keyboard-navigable — arrow keys move through the list, Enter picks, Escape dismisses — and announces itself to screen readers as a combobox. The dropdown is served from a cacheable JSON endpoint that carries no per-visitor data, and it's a pure enhancement: with JavaScript off, or before the data loads, the box stays an ordinary search form that submits to the results page. · *Search-suggestions admin screen* — A new Search suggestions screen under the admin console lets you curate featured suggestions: pin a typed prefix (for example, prefix "free" surfaces "Free shipping over $50"), set its destination link, priority, and an optional active window, then edit the priority or status inline or remove it. Below the curation table, a read-only Popular searches view reports what shoppers have typed over the last 30 days — each term's search count, its zero-result share, and when it was last seen — so a high zero-result term flags a stock or naming gap worth closing. Every search a shopper runs is logged for this report (the visitor's session identifier is hashed before storage), and entries older than 90 days are pruned automatically.
12
14
 
13
15
  - v0.4.10 (2026-06-05) — **The Promo banners console is back in the admin nav.** The promo-banners management screen introduced in 0.4.4 was fully built and mounted, but the admin nav never showed its link: the nav filters items against an availability map, and the map was missing the one key the Promo banners item checks, so the link was filtered off every page and the screen was reachable only by typing /admin/promo-banners. The key is in the map now, and a contract test pins every nav item's gate key to the map so a future screen can't ship undiscoverable the same way. **Added:** *Nav gate keys are contract-tested* — A test now parses every nav item's gate key against the availability map and fails the suite on any mismatch, in either direction of drift — a new screen whose key is forgotten can't ship with a permanently hidden link again. **Fixed:** *Promo banners nav link renders* — The admin nav gates each item on an availability map keyed by the deps the console was mounted with. The Promo banners item checked a key the map never defined, which reads as permanently unavailable — so the link was hidden on every authenticated admin page even though the screen itself mounted and worked. Operators see the link again wherever the promo-banners primitive is wired.
package/lib/analytics.js CHANGED
@@ -492,18 +492,26 @@ function create(opts) {
492
492
  // GROUPs by the denormalised column so the query never decodes
493
493
  // the JSON payload. Default limit 10; max 100 (same envelope as
494
494
  // `topSKUs`).
495
+ //
496
+ // GROUPs + sorts on `lower(search_q)` so "Tee" and "tee" collapse
497
+ // into one row, matching how the autocomplete "Popular searches"
498
+ // aggregate (search-suggestions, which lowercases on write)
499
+ // counts them. New writes already arrive lowercased from the
500
+ // storefront record site; lowering at aggregate time folds any
501
+ // mixed-case history written before that. ASCII queries only, so
502
+ // SQLite's built-in `lower()` (ASCII-only) is sufficient.
495
503
  topSearchTerms: async function (windowOpts) {
496
504
  var w = _resolveEventWindow(windowOpts);
497
505
  var limit = (windowOpts && windowOpts.limit) == null ? 10 : windowOpts.limit;
498
506
  _limit(limit, "limit");
499
507
  var r = await query(
500
- "SELECT search_q AS search_q, COUNT(*) AS count " +
508
+ "SELECT lower(search_q) AS search_q, COUNT(*) AS count " +
501
509
  " FROM analytics_events " +
502
510
  " WHERE event_type = 'search_query' " +
503
511
  " AND search_q IS NOT NULL " +
504
512
  " AND occurred_at >= ?1 AND occurred_at < ?2 " +
505
- " GROUP BY search_q " +
506
- " ORDER BY count DESC, search_q ASC " +
513
+ " GROUP BY lower(search_q) " +
514
+ " ORDER BY count DESC, lower(search_q) ASC " +
507
515
  " LIMIT ?3",
508
516
  [w.from, w.to, limit],
509
517
  );
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.4.11",
2
+ "version": "0.4.12",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-6k53cvkRrxMgmeStLIoLjVXZQHqIJgTmv1Izd8TYhh1HOC4POgE6GCvx1bsalyEP",
@@ -276,7 +276,21 @@ function _stem(token) {
276
276
  if (token.length >= 5 && token.slice(-3) === "ies") return token.slice(0, -3) + "y";
277
277
  if (token.length >= 5 && token.slice(-3) === "ing") return token.slice(0, -3);
278
278
  if (token.length >= 4 && token.slice(-2) === "ed") return token.slice(0, -2);
279
- if (token.length >= 4 && token.slice(-2) === "es") return token.slice(0, -2);
279
+ // `-es` is only a true plural suffix when the stem ends in a sibilant
280
+ // (`s`/`x`/`z`/`ch`/`sh`): boxes -> box, dishes -> dish, churches ->
281
+ // church. For vowel-final stems the singular keeps a trailing `e` and
282
+ // the plural is a bare `-s` — stripping `-es` there over-strips
283
+ // (tees -> te, shoes -> sho, does -> do). Strip `-es` only for the
284
+ // sibilant case; everything else falls through to the bare `-s` rule
285
+ // (tees -> tee, shoes -> shoe, does -> doe).
286
+ if (token.length >= 4 && token.slice(-2) === "es") {
287
+ var esStem = token.slice(0, -2);
288
+ var esLast = esStem.slice(-1);
289
+ var esLast2 = esStem.slice(-2);
290
+ if (esLast === "s" || esLast === "x" || esLast === "z" || esLast2 === "ch" || esLast2 === "sh") {
291
+ return esStem;
292
+ }
293
+ }
280
294
  if (token.length >= 4 && token.slice(-1) === "s") return token.slice(0, -1);
281
295
  return token;
282
296
  }
package/lib/storefront.js CHANGED
@@ -1854,6 +1854,9 @@ var SEARCH_MAX_FACET_KEYS = 32;
1854
1854
  var SEARCH_MAX_FACET_VALUES = 64;
1855
1855
  var SEARCH_MAX_VALUE_LEN = 256;
1856
1856
  var SEARCH_CONTROL_BYTE_RE = /[\x00-\x1f\x7f]/;
1857
+ // Global twin for strip-all replaces (the non-global one above is used for
1858
+ // presence tests; `.replace` needs the `g` flag to clear every byte).
1859
+ var SEARCH_CONTROL_BYTE_RE_G = /[\x00-\x1f\x7f]/g;
1857
1860
 
1858
1861
  // Parse `?key=value` repeats off a parsed URL into the
1859
1862
  // `{ facetKey: [value, ...] }` shape the searchFacets primitive
@@ -1942,13 +1945,20 @@ function renderSearch(opts) {
1942
1945
  } else {
1943
1946
  var assetPrefix = opts.asset_prefix || "/assets/";
1944
1947
  var fmt = _priceFormatter(opts);
1948
+ // `?from=search&sq=<query>` marks the click as originating in a ranked
1949
+ // result list. The PDP route reads it to log a click event against the
1950
+ // active ranking weight set (the impression/click signals the admin
1951
+ // search-metrics view aggregates); `sq` carries the query the list was
1952
+ // ranked for, which recordSearchEvent requires. Works with JS off; no
1953
+ // beacon. Mirrored byte-for-byte by worker/render/search.js.
1954
+ var resultLinkSuffix = "?from=search&sq=" + encodeURIComponent(qTrim.slice(0, 200));
1945
1955
  var cards = products.map(function (p) {
1946
1956
  var priceStr = p.starting_price_minor != null
1947
1957
  ? fmt(p.starting_price_minor, p.starting_price_currency || "USD")
1948
1958
  : "—";
1949
1959
  var imageUrl = p.hero_media ? assetPrefix + p.hero_media.r2_key : null;
1950
1960
  var imageAlt = p.hero_media ? (p.hero_media.alt_text || p.title) : null;
1951
- return _buildProductCard({ title: p.title, price: priceStr, slug: p.slug, image_url: imageUrl, image_alt: imageAlt });
1961
+ return _buildProductCard({ title: p.title, price: priceStr, slug: p.slug + resultLinkSuffix, image_url: imageUrl, image_alt: imageAlt });
1952
1962
  }).join("\n");
1953
1963
  resultsInner = "<section class=\"search-grid\"><div class=\"grid\">" + cards + "</div></section>";
1954
1964
  }
@@ -10661,6 +10671,88 @@ function mount(router, deps) {
10661
10671
  } catch (_e) { /* drop-silent — analytics is supplementary to every request */ }
10662
10672
  }
10663
10673
 
10674
+ // Search-ranking impression. Resolves the active weight set, then logs a
10675
+ // single list-level impression against it. The metrics view aggregates
10676
+ // impressions per weights_slug to compute CTR / conversion, so the slug
10677
+ // is the join key; query is denormalised for operator inspection. No
10678
+ // active set → no-op (recordSearchEvent would throw SEARCH_WEIGHTS_NOT
10679
+ // _FOUND, which the catch swallows). HOT PATH: fire-and-forget +
10680
+ // drop-silent — a metrics write must never affect the search response.
10681
+ function _recordSearchImpression(req, term) {
10682
+ if (!deps.searchRanking) return;
10683
+ try {
10684
+ var sid = null;
10685
+ try { sid = _readSidCookie(req); } catch (_e) { sid = null; }
10686
+ Promise.resolve(deps.searchRanking.activeWeights())
10687
+ .then(function (active) {
10688
+ if (!active || !active.slug) return; // ranking not configured → nothing to attribute
10689
+ var ev = { query: term, event_type: "impression", weights_slug: active.slug };
10690
+ if (sid) ev.session_id = sid;
10691
+ return deps.searchRanking.recordSearchEvent(ev);
10692
+ })
10693
+ .catch(function () { /* drop-silent — impression bump never fails the search */ });
10694
+ } catch (_e) { /* drop-silent — supplementary to every search */ }
10695
+ }
10696
+
10697
+ // Search-ranking click. Logged from the PDP route when the inbound link
10698
+ // carried `?from=search`. Attributes the click to whatever weight set is
10699
+ // active at click time — the same set new impressions log against — so
10700
+ // CTR stays coherent (an operator rarely flips the active set mid-session;
10701
+ // if they do, both new impressions and new clicks follow the new set).
10702
+ // `productId` is denormalised for inspection; the metrics view keys on the
10703
+ // weights_slug + time window. HOT PATH: fire-and-forget + drop-silent.
10704
+ function _recordSearchClick(req, term, productId) {
10705
+ if (!deps.searchRanking) return;
10706
+ // recordSearchEvent requires a non-empty query; a click that lost its
10707
+ // `sq` marker can't be attributed, so skip rather than fire a call that
10708
+ // would throw.
10709
+ if (typeof term !== "string" || term.trim().length === 0) return;
10710
+ try {
10711
+ var sid = null;
10712
+ try { sid = _readSidCookie(req); } catch (_e) { sid = null; }
10713
+ Promise.resolve(deps.searchRanking.activeWeights())
10714
+ .then(function (active) {
10715
+ if (!active || !active.slug) return;
10716
+ var ev = { query: term, event_type: "click", weights_slug: active.slug };
10717
+ if (productId) ev.product_id = productId;
10718
+ if (sid) ev.session_id = sid;
10719
+ return deps.searchRanking.recordSearchEvent(ev);
10720
+ })
10721
+ .catch(function () { /* drop-silent — click bump never fails the PDP */ });
10722
+ } catch (_e) { /* drop-silent — supplementary to every PDP view */ }
10723
+ }
10724
+
10725
+ // Facet-usage events. One per applied facet value the shopper narrowed
10726
+ // by, so the admin facet-usage rollup reflects real use. `filters` is the
10727
+ // `{ key: [value, ...] }` shape the search handler already parsed +
10728
+ // validated against the live facet defs. HOT PATH: fire-and-forget +
10729
+ // drop-silent — a usage write must never affect the search response.
10730
+ function _recordFacetUses(req, filters) {
10731
+ if (!deps.searchFacets) return;
10732
+ try {
10733
+ var sid = null;
10734
+ try { sid = _readSidCookie(req); } catch (_e) { sid = null; }
10735
+ var sessionId = sid || "anon";
10736
+ // searchFacets is a per-request factory (bound to a catalog); the
10737
+ // usage write needs no catalog, so an instance with an empty list
10738
+ // adapter is enough for recordFacetUse.
10739
+ var sf = deps.searchFacets({ list: function () { return Promise.resolve({ rows: [] }); } });
10740
+ var keys = Object.keys(filters);
10741
+ for (var k = 0; k < keys.length; k += 1) {
10742
+ var values = filters[keys[k]];
10743
+ if (!Array.isArray(values)) continue;
10744
+ for (var v = 0; v < values.length; v += 1) {
10745
+ (function (key, value) {
10746
+ try {
10747
+ Promise.resolve(sf.recordFacetUse({ key: key, value: value, session_id: sessionId }))
10748
+ .catch(function () { /* drop-silent — usage bump never fails the search */ });
10749
+ } catch (_e) { /* drop-silent — skip a value that fails validation */ }
10750
+ })(keys[k], values[v]);
10751
+ }
10752
+ }
10753
+ } catch (_e) { /* drop-silent — supplementary to every search */ }
10754
+ }
10755
+
10664
10756
  // Resolve the active trust badges for a container-only placement and
10665
10757
  // concatenate each one's sanitized renderHtml. Fires an impression per
10666
10758
  // rendered badge (fire-and-forget — the counter is drop-silent on the hot
@@ -12353,12 +12445,20 @@ function mount(router, deps) {
12353
12445
  shop_name: shopName,
12354
12446
  cart_count: cartCount,
12355
12447
  }, _requestUrls(req), ccy)));
12448
+ // One hygiene pass for every search sink. The autocomplete query log
12449
+ // (searchSuggestions.recordQuery) strips control bytes + lowercases
12450
+ // before it writes; the analytics funnel must see the SAME shape so
12451
+ // "Popular searches" and the analytics "top search terms" aggregate
12452
+ // the same rows. Strip control bytes, trim, lowercase once here — a
12453
+ // query that was only control bytes / whitespace collapses to "" and
12454
+ // every sink below skips it.
12455
+ var searchTerm = q.replace(SEARCH_CONTROL_BYTE_RE_G, "").trim().toLowerCase();
12356
12456
  // Consent-gated funnel event — feeds the top-search-terms aggregate.
12357
- // Only a real (non-empty) query counts; the typed term is bounded to
12358
- // 200 chars above (recordEvent caps search_q at 256). Container-served
12457
+ // Only a real (non-empty) query counts; the term is bounded to 200
12458
+ // chars above (recordEvent caps search_q at 256). Container-served
12359
12459
  // only; fire-and-forget + drop-silent.
12360
- if (q.trim().length > 0) {
12361
- _recordAnalyticsEvent(req, { event_type: "search_query", search_q: q.trim() });
12460
+ if (searchTerm.length > 0) {
12461
+ _recordAnalyticsEvent(req, { event_type: "search_query", search_q: searchTerm });
12362
12462
  }
12363
12463
  // Log the query for the admin "Popular searches" view + the autocomplete
12364
12464
  // popular-terms group. Observability sink — drop-silent by design: a
@@ -12367,10 +12467,10 @@ function mount(router, deps) {
12367
12467
  // hashes it before any write, so neither value is recoverable). Only a
12368
12468
  // real (non-empty) query is logged. Alongside it, a self-gated retention
12369
12469
  // sweep runs at most once a day to prune rows past the 90-day window.
12370
- if (deps.searchSuggestions && q.trim().length > 0) {
12470
+ if (deps.searchSuggestions && searchTerm.length > 0) {
12371
12471
  try {
12372
12472
  deps.searchSuggestions.recordQuery({
12373
- q: q.trim(),
12473
+ q: searchTerm,
12374
12474
  session_id: _readSidCookie(req) || "anon",
12375
12475
  result_count: totalCount,
12376
12476
  }).catch(function () { /* drop-silent — logging never fails the search */ });
@@ -12384,6 +12484,20 @@ function mount(router, deps) {
12384
12484
  } catch (_e2) { /* drop-silent — retention is best-effort */ }
12385
12485
  }
12386
12486
  }
12487
+ // Search-ranking impression — one event per rendered result list,
12488
+ // keyed to the active weight set (the slug the admin search-metrics
12489
+ // view aggregates CTR / conversion against). recordSearchEvent rejects
12490
+ // a missing weight set, so this is a no-op until the operator activates
12491
+ // one. Observability sink — drop-silent by design: a metrics hiccup
12492
+ // must never fail the search the shopper just ran.
12493
+ if (deps.searchRanking && searchTerm.length > 0) {
12494
+ _recordSearchImpression(req, searchTerm);
12495
+ }
12496
+ // Facet-narrowing usage — one event per applied facet value, so the
12497
+ // admin can see which facets shoppers actually use. Drop-silent.
12498
+ if (deps.searchFacets && searchTerm.length > 0 && filters && Object.keys(filters).length > 0) {
12499
+ _recordFacetUses(req, filters);
12500
+ }
12387
12501
  });
12388
12502
 
12389
12503
  // Autocomplete data for the header search box — the island (search-suggest.js)
@@ -12602,6 +12716,18 @@ function mount(router, deps) {
12602
12716
  // most-viewed-products aggregate. Container-served only (anonymous PDPs
12603
12717
  // are edge-cached and never reach here); fire-and-forget + drop-silent.
12604
12718
  _recordAnalyticsEvent(req, { event_type: "pdp_view", product_id: product.id });
12719
+ // Search-ranking click — a PDP reached via a ranked result list carries
12720
+ // `?from=search&sq=<query>`. Defensive reader: a missing / unrelated
12721
+ // marker is the common case (direct nav, related-rail) and logs nothing;
12722
+ // a blank / over-long `sq` is dropped by the primitive's own validation
12723
+ // (caught drop-silent in _recordSearchClick). The click is attributed to
12724
+ // the active weight set, the same set new impressions log against, so the
12725
+ // admin search-metrics CTR stays coherent.
12726
+ if (deps.searchRanking && pdpUrl && pdpUrl.searchParams.get("from") === "search") {
12727
+ var clickQ = pdpUrl.searchParams.get("sq");
12728
+ if (typeof clickQ === "string" && clickQ.length > 200) clickQ = clickQ.slice(0, 200);
12729
+ _recordSearchClick(req, clickQ, product.id);
12730
+ }
12605
12731
  _send(res, 200, html);
12606
12732
  });
12607
12733
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.4.11",
3
+ "version": "0.4.12",
4
4
  "description": "Open-source framework built on blamejs. Vendored stack, zero npm runtime deps, PQC-first crypto, security-on by default.",
5
5
  "main": "lib/index.js",
6
6
  "scripts": {