[object Object]

← back to Dw Domain Fleet

render.js: scrubVendor() removes vendor names from displayed title + card-meta sku + /product slug + productPage h1/sku (fleet vendor-leak fix, 2026-05-29 audit). Link-safe: data-handle/data-sku raw, route resolves clean-slug OR raw-handle, DW-store CTA uses raw handle

09c24d75316a352d37f7dd60757c97607306d464 · 2026-05-29 22:24:44 -0700 · Steve

Files touched

Diff

commit 09c24d75316a352d37f7dd60757c97607306d464
Author: Steve <steve@designerwallcoverings.com>
Date:   Fri May 29 22:24:44 2026 -0700

    render.js: scrubVendor() removes vendor names from displayed title + card-meta sku + /product slug + productPage h1/sku (fleet vendor-leak fix, 2026-05-29 audit). Link-safe: data-handle/data-sku raw, route resolves clean-slug OR raw-handle, DW-store CTA uses raw handle
---
 shared/render.js | 112 ++++++++++++++++++-------------------------------------
 1 file changed, 37 insertions(+), 75 deletions(-)

diff --git a/shared/render.js b/shared/render.js
index 3a763ca..04fa3ee 100644
--- a/shared/render.js
+++ b/shared/render.js
@@ -21,8 +21,9 @@ function clean(s) {
  */
 function productSlug(p) {
   const h = (p && p.handle) || '';
-  const wc = h.replace(/wallpapers/gi, 'wallcoverings').replace(/wallpaper/gi, 'wallcovering');
-  return redactVendorSlug(wc);   // strip 3rd-party vendor tokens — server.js resolves via productSlug(x)===key so links stay resolvable; old real-handle URLs still resolve via the handle branch
+  let s = scrubVendor(h).replace(/wallpapers/gi, 'wallcoverings').replace(/wallpaper/gi, 'wallcovering');
+  s = s.replace(/-{2,}/g, '-').replace(/^-+|-+$/g, '');
+  return s || h;   // never empty — route resolves both this AND the raw handle
 }
 
 /* scrubTags — drop any tag carrying a 3rd-party vendor token before rendering.
@@ -63,73 +64,31 @@ function scrubTags(tags) {
   return tags.filter(t => t && !hasVendorToken(t));
 }
 
-/* redactVendorSlug — strip any hyphen-delimited segment carrying a 3rd-party
- * vendor token from a slug/handle/sku shown in the fleet UI. Audit 2026-05-29:
- * vendor names leaked via the product handle into /product/<slug> hrefs, og:url
- * and the visible sku — a vector the 2026-05-28 chip scrub (scrubTags) never
- * touched. Driven by the same VENDORS_DENYLIST. Used by productSlug (the fleet's
- * OWN /product/ route key — server.js resolves via render.productSlug(x)===key,
- * so recomputing the redacted value keeps links resolvable; old real-handle URLs
- * still resolve via the handle branch). The REAL p.handle is left intact for the
- * main-store canonical + buy-sample links (they legitimately point at
- * designerwallcoverings.com/products/<real-handle>). House brands carry no
- * denylist token, so they pass through untouched. Longer vendor names are listed
- * before their prefixes in VENDORS_DENYLIST ("lee jofa modern" before "lee jofa")
- * so the longer match strips first. */
-const VENDOR_SLUG_RES = VENDORS_DENYLIST.map(v => {
-  const s = String(v).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
-  return new RegExp('(^|-)' + s + '(?=-|$)', 'gi');
-});
-function redactVendorSlug(slug) {
-  let out = String(slug == null ? '' : slug);
-  for (let i = 0; i < VENDOR_SLUG_RES.length; i++) out = out.replace(VENDOR_SLUG_RES[i], '$1');
-  out = out.replace(/-{2,}/g, '-').replace(/^-+|-+$/g, '');
-  return out || String(slug == null ? '' : slug);   // never empty (route key must be non-empty)
-}
-
-/* displayTitle / scrubTitleLead — strip a 3rd-party vendor baked into the HEAD
- * of a product name before it reaches a visible surface (card-title, data-title
- * attr → quick-view modal title + aria-label, productPage <h1>). scrubTags only
- * touches the tag array; titles like "Versace 4 Metallic Wallcovering" or
- * "Romo Bellisario Stripe" leaked the vendor straight through (audit 2026-05-28
- * addendum: designermagnetics card-title "Versace …"). Mirrors stripLeadVendor in
- * _shared/api-vendor-redact.js but is driven by THIS file's VENDORS_DENYLIST so
- * the chip + title scrubbers stay on one denylist. Only the LEADING token is
- * stripped (trailing "| Vendor | Collection" pipe-segments are already dropped by
- * split('|')[0] upstream); a token mid-word ("Romono") won't match because each
- * matcher requires a non-alphanumeric boundary after the vendor. If stripping the
- * vendor leaves nothing meaningful, fall back to the house brand. House-brand-led
- * names are never touched. */
-const HOUSE_BRAND = /^(hollywood|phillipe?\s*romano|retro\s*walls?|designer\s*wallcoverings?|maison\s*lustre)\b/i;
-const TITLE_GENERIC_REMAINDER = /^(wall\s*coverings?|wall\s*papers?|fabrics?|textiles?|murals?|sample|collection)?[\s\-,.|]*$/i;
-// One anchored matcher per denylist vendor, built once at module load: vendor
-// words at the very start (loose on &/and/punctuation), an optional trailing
-// noun (design/wallcovering/…), then a separator — so only a leading vendor with
-// a real word boundary after it is stripped.
-const LEAD_VENDOR_RES = VENDORS_DENYLIST.map(v =>
-  new RegExp('^' + v.replace(/[^a-z0-9]+/gi, '[^a-z0-9]+') +
-    '(?:[^a-z0-9]+(?:design|wallcoverings?|wallpapers?|fabrics?|textiles?|collection))?[\\s\\-:,|]+', 'i')
-);
-function scrubTitleLead(name) {
-  if (!name || typeof name !== 'string') return name;
-  if (HOUSE_BRAND.test(name.trim())) return name; // keep house-brand-led names
-  for (let i = 0; i < LEAD_VENDOR_RES.length; i++) {
-    const m = name.match(LEAD_VENDOR_RES[i]);
-    if (m) {
-      const rest = name.slice(m[0].length).trim();
-      if (TITLE_GENERIC_REMAINDER.test(rest) || rest.length < 4) return 'Designer Wallcovering';
-      return rest;
-    }
-  }
-  return name;
-}
-// Canonical customer-facing title derivation — leading product-name pipe-segment,
-// "wallpaper"→"wallcovering" cleaned, leading vendor stripped, with the sku/handle
-// fallback. Used by both card() and productPage() so the two stay identical.
-function displayTitle(p) {
-  const lead = clean(p && p.title ? p.title : '').split('|')[0].trim();
-  const scrubbed = scrubTitleLead(lead);
-  return scrubbed || ((p && (p.sku || p.handle) || '').replace(/-Sample$/i, '')) || 'Designer Wallcovering';
+/* scrubVendor — REMOVE any 3rd-party vendor name from a DISPLAYED string
+ * (product title, the visible sku/handle in card-meta, and the /product slug).
+ * Where scrubTags drops a whole tag, this strips just the vendor token in place.
+ * Longest-match-first so "lee jofa modern" beats "lee jofa"; separators are
+ * flexible (\b…[^a-z0-9]+…\b) so it catches "Brunschwig & Fils" (title) AND
+ * "brunschwig-fils" (slug). House brands aren't on the denylist → untouched.
+ * SAFE for slugs: server.js resolves /product/:handle by raw handle OR
+ * productSlug(x), and the buy-sample CTA uses the untouched p.handle — so
+ * cleaning the slug never breaks a product link. (2026-05-29 fleet vendor-leak
+ * audit: slug/handle + visible card-title vector.) */
+const _VENDOR_SCRUB = VENDORS_DENYLIST
+  .concat(['brunschwig and fils', 'brunschwig & fils', 'lee jofa modern',
+           'gp j baker', 'cole and son', 'clarke and clarke', 'morris and co'])
+  .slice().sort((a, b) => b.length - a.length)
+  .map(v => new RegExp('\\b' + v.split(/\s+/).map(w => w.replace(/[^a-z0-9]/gi, '')).join('[^a-z0-9]+') + '\\b', 'ig'));
+function scrubVendor(s) {
+  if (s == null) return s;
+  let o = String(s);
+  for (let i = 0; i < _VENDOR_SCRUB.length; i++) o = o.replace(_VENDOR_SCRUB[i], ' ');
+  return o
+    .replace(/\s{2,}/g, ' ')
+    .replace(/-{2,}/g, '-')
+    .replace(/^[\s\-–—|·,&]+|[\s\-–—|·,&]+$/g, '')
+    .replace(/\s{2,}/g, ' ')
+    .trim();
 }
 
 const DW = 'https://designerwallcoverings.com';
@@ -358,11 +317,12 @@ function jsonld(cfg) {
  */
 function card(p) {
   const hx = dominantHex(p);
-  const t = displayTitle(p);
+  const t = scrubVendor(clean(p.title).split('|')[0].trim())
+            || scrubVendor((p.sku||p.handle||'').replace(/-Sample$/i,''))
+            || 'Designer Wallcovering';
   const href = '/product/' + encodeURIComponent(productSlug(p));
-  const dsku = redactVendorSlug(p.sku || p.handle);   // visible/meta sku — vendor stripped (real handle kept on data-handle for the main-store buy link)
   return `<div class="card" data-handle="${esc(p.handle)}"
-    data-title="${esc(t)}" data-sku="${esc(dsku)}"
+    data-title="${esc(t)}" data-sku="${esc(p.sku||p.handle)}"
     data-img="${esc(p.image_url)}" data-tags="${esc(scrubTags(p.tags).slice(0,12).join(', '))}"
     data-price="${esc(p.price)}">
   <a class="card-link" href="${esc(href)}" aria-label="${esc(t)}">
@@ -370,7 +330,7 @@ function card(p) {
     <div class="card-body">
       <div class="card-title">${esc(t)}</div>
       <div class="card-meta"><span class="dot" style="background:${hx}"></span>
-        Designer Wallcoverings · ${esc(dsku)}</div>
+        Designer Wallcoverings · ${esc(scrubVendor(p.sku||p.handle))}</div>
       <div class="card-sample">View Details</div>
     </div>
   </a></div>`;
@@ -637,8 +597,10 @@ function heroRotate(n){
  * (carry the site name + tagline), satisfying the panel SEO must-do.
  */
 function productPage(cfg, p, related) {
-  const name = displayTitle(p);
-  const sku = redactVendorSlug(p.sku || p.handle);   // displayed sku (title/meta/schema) — vendor stripped; canonical + buy-link below keep the REAL handle
+  const name = scrubVendor(clean(p.title).split('|')[0].trim())
+            || scrubVendor((p.sku||p.handle||'').replace(/-Sample$/i,''))
+            || 'Designer Wallcovering';
+  const sku = scrubVendor(p.sku || p.handle);   // display-only; raw p.handle still used for DW-store links
   const canonical = `${DW}/products/${encodeURIComponent(p.handle)}`;
   // per-site-unique title + meta — site name makes each fleet copy distinct
   const title = `${name} — ${sku} | ${cfg.siteName}`;

← ee30c6a fix: strip 3rd-party vendor tokens from product slug/handle  ·  back to Dw Domain Fleet  ·  render.js scrubVendor: fix regex builder (split on non-alnum 9450ca4 →