[object Object]

← back to Interiordesignershowroom

seo: indexable facet landing pages — sitemap + self-canonical whitelist, ratio-gated, orphan rooms 404 (Cody-hardened)

6a15502bed8df848d6a8ddd0aab15d6ec38d8c3f · 2026-08-03 09:50:23 -0700 · Steve Abrams

Files touched

Diff

commit 6a15502bed8df848d6a8ddd0aab15d6ec38d8c3f
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Aug 3 09:50:23 2026 -0700

    seo: indexable facet landing pages — sitemap + self-canonical whitelist, ratio-gated, orphan rooms 404 (Cody-hardened)
---
 lib/catalog.js |  15 ++++++++-
 server.js      | 103 ++++++++++++++++++++++++++++++++++++++++++++++++++++-----
 2 files changed, 109 insertions(+), 9 deletions(-)

diff --git a/lib/catalog.js b/lib/catalog.js
index f5454b5..ac62522 100644
--- a/lib/catalog.js
+++ b/lib/catalog.js
@@ -105,4 +105,17 @@ async function fetchProducts(f, limit = 200) {
   return r.rows;
 }
 
-module.exports = { parseFilters, fetchProducts, facetCounts, facetRail, activeChips, toQuery, productCard, AFFILIATE_ENABLED, DIMENSIONS };
+// True (un-capped) count of products matching a filter, plus the total visible
+// catalog size — used to decide whether a facet page is a genuine landing page or
+// a near-duplicate of /shop (a facet that IS most of the catalog isn't a filter).
+// One round-trip; the gate-only total subquery carries no params.
+async function matchAndTotal(f) {
+  const { sql, params } = buildWhere(f);
+  const gateOnly = buildWhere({}).sql;
+  const r = await db.query(
+    `SELECT (SELECT count(*) FROM products WHERE ${sql}) AS n,
+            (SELECT count(*) FROM products WHERE ${gateOnly}) AS total`, params);
+  return { n: Number(r.rows[0].n), total: Number(r.rows[0].total) };
+}
+
+module.exports = { parseFilters, fetchProducts, matchAndTotal, facetCounts, facetRail, activeChips, toQuery, productCard, AFFILIATE_ENABLED, DIMENSIONS };
diff --git a/server.js b/server.js
index b01e7f3..0dff063 100644
--- a/server.js
+++ b/server.js
@@ -121,19 +121,71 @@ async function renderCatalog(res, { f, basePath, title, description, canonical,
   res.send(layout({ title, description, canonical, jsonld: productListJsonld(products, canonical), body, activeNav }));
 }
 
+// SEO for /shop under filters. Only a whitelist of "clean" facet shapes — a single
+// style/color, or a room × one-of-{style,color} — earns a SELF-canonical + unique
+// title/description/H1 (these are exactly the shapes emitted in sitemap.xml, so the
+// two signals agree). Every other combo (price, search, network, deep multi-facet)
+// consolidates back to /shop so infinite thin variants don't fragment ranking.
+const CAP = (s) => String(s || '').replace(/(^|[\s-])\w/g, (m) => m.toUpperCase());
+const roomLabel = (r) => CAP(String(r).replace(/-/g, ' '));
+// A facet page is a genuine, indexable landing page only when it is DIFFERENTIATED
+// from /shop: enough inventory to stand alone (>= MIN) but not so much that it's
+// basically the whole catalog re-served (<= MAX_RATIO of the visible total). Modern
+// (93.6%) and neutral (49%) fail the ceiling — proven near-duplicates of /shop
+// (200/200 and 175/200 shared cards) — so they consolidate back to /shop instead.
+const SITEMAP_FACET_MIN = 12;
+const SITEMAP_FACET_MAX_RATIO = 0.40;
+const ROOM_SLUGS = new Set(ROOMS.map(([s]) => s)); // rooms that have a real nav/category
+
+// SEO for /shop under filters. Only a whitelist of "clean" facet shapes — a single
+// style/color, or a whitelisted-room × one-of-{style,color} — earns a SELF-canonical
+// + unique title/description/H1, AND only when its count clears MIN and stays under
+// MAX_RATIO. This set is byte-identical to what sitemap.xml emits, so the two signals
+// agree. Everything else (price, search, network, deep multi-facet, near-total facets,
+// orphan rooms) consolidates back to /shop.
+async function facetSeo(f) {
+  const keys = ['room', 'style', 'color', 'network', 'max', 'q'].filter((k) => f[k]);
+  const only = (set) => keys.length === set.length && set.every((k) => f[k]);
+  const generic = { canonical: `${SITE.url}/shop`, heading: 'The Showroom',
+    title: 'Shop the Showroom', description: 'Filter curated designer pieces by room, style, color, or price.' };
+  let heading, title, description;
+  if ((f.style || f.color) && !f.room && only([f.style ? 'style' : 'color'])) {
+    const v = f.style || f.color;
+    heading = f.style ? `${CAP(v)}-Style Furniture & Decor` : `${CAP(v)} Furniture & Decor`;
+    title = heading;
+    description = `Shop a curated selection of ${v} ${f.style ? 'style ' : ''}furniture, lighting and decor from top designer brands.`;
+  } else if (f.room && ROOM_SLUGS.has(f.room) && (f.style || f.color) && only(['room', f.style ? 'style' : 'color'])) {
+    const v = f.style || f.color;
+    heading = `${CAP(v)} ${roomLabel(f.room)}`;
+    title = `${CAP(v)} ${roomLabel(f.room)} — Furniture & Decor`;
+    description = `Shop ${v} pieces curated for the ${roomLabel(f.room).toLowerCase()} — designer furniture, lighting and decor.`;
+  } else {
+    return generic;
+  }
+  // Count gate: differentiated-from-/shop check (mirrors the sitemap thresholds).
+  const { n, total } = await catalog.matchAndTotal(f);
+  if (n < SITEMAP_FACET_MIN || n > total * SITEMAP_FACET_MAX_RATIO) return generic;
+  return { canonical: `${SITE.url}/shop${catalog.toQuery(f)}`, heading, title, description };
+}
+
 app.get('/shop', async (req, res, next) => {
   try {
     const f = catalog.parseFilters(req.query);
-    await renderCatalog(res, { f, basePath: '/shop', heading: 'The Showroom',
-      title: 'Shop the Showroom', description: 'Filter curated designer pieces by room, style, color, or price.',
-      canonical: `${SITE.url}/shop`, activeNav: '/shop' });
+    const seo = await facetSeo(f);
+    await renderCatalog(res, { f, basePath: '/shop', activeNav: '/shop',
+      heading: seo.heading, title: seo.title, description: seo.description, canonical: seo.canonical });
   } catch (e) { next(e); }
 });
 
 app.get('/rooms/:room', async (req, res, next) => {
   try {
     const room = req.params.room;
-    const label = (ROOMS.find(([s]) => s === room) || [null, room])[1];
+    // Only serve rooms that are real nav categories. The wildcard used to mint a
+    // self-canonical page for ANY slug the DB happened to hold (kitchen/paint/
+    // bathroom), creating orphan pages that compete for authority. 404 the rest.
+    const match = ROOMS.find(([s]) => s === room);
+    if (!match) return next();
+    const label = match[1];
     const f = { ...catalog.parseFilters(req.query), room };
     await renderCatalog(res, { f, basePath: '/shop', heading: label,
       title: `${label} Furniture & Decor`, description: `Curated ${label.toLowerCase()} pieces from top design brands.`,
@@ -208,22 +260,57 @@ app.get('/robots.txt', (_req, res) => {
 
 app.get('/sitemap.xml', async (_req, res, next) => {
   try {
-    const [{ rows: guides }, { rows: rooms }] = await Promise.all([
+    const GATE = catalog.AFFILIATE_ENABLED; // in-stock/not-suppressed added inline below
+    const [{ rows: guides }, { rows: rooms }, { rows: singles }, { rows: pairs }, { rows: totRows }] = await Promise.all([
       db.query(`SELECT slug, updated_at FROM guides WHERE published`),
       db.query(`SELECT slug, updated_at FROM rooms WHERE public`),
+      // single-facet landing pages (style, color) above the inventory threshold.
+      // Wrapped in a subquery so the threshold applies to BOTH halves of the union
+      // (a trailing HAVING would bind only to the last SELECT).
+      db.query(`
+        SELECT dim, val, n FROM (
+          SELECT 'style' AS dim, style AS val, count(*) AS n FROM products
+            WHERE in_stock AND NOT suppressed AND style IS NOT NULL AND ${GATE} GROUP BY style
+          UNION ALL
+          SELECT 'color', color, count(*) FROM products
+            WHERE in_stock AND NOT suppressed AND color IS NOT NULL AND ${GATE} GROUP BY color
+        ) t WHERE n >= $1`, [SITEMAP_FACET_MIN]),
+      // room × {style,color} long-tail pairs above the threshold ("modern bedroom")
+      db.query(`
+        SELECT 'style' AS dim, room, style AS val, count(*) AS n FROM products
+          WHERE in_stock AND NOT suppressed AND room IS NOT NULL AND style IS NOT NULL AND ${GATE}
+          GROUP BY room, style HAVING count(*) >= $1
+        UNION ALL
+        SELECT 'color', room, color, count(*) FROM products
+          WHERE in_stock AND NOT suppressed AND room IS NOT NULL AND color IS NOT NULL AND ${GATE}
+          GROUP BY room, color HAVING count(*) >= $1`, [SITEMAP_FACET_MIN]),
+      db.query(`SELECT count(*) AS n FROM products WHERE in_stock AND NOT suppressed AND ${GATE}`),
     ]);
     const iso = (d) => (d ? new Date(d).toISOString().slice(0, 10) : null);
-    // {loc, lastmod?} — static pages first, then room categories, guides, and the
-    // public visitor-built rooms (real indexable content that was missing before).
+    // Upper bound: drop facets that are basically the whole catalog (near-dupes of
+    // /shop), and drop room×facet pairs for orphan rooms with no nav/category page.
+    // This keeps the sitemap set IDENTICAL to facetSeo's self-canonical whitelist.
+    const maxN = Number(totRows[0].n) * SITEMAP_FACET_MAX_RATIO;
+    const keepSingle = (r) => Number(r.n) <= maxN;
+    const keepPair = (r) => ROOM_SLUGS.has(r.room) && Number(r.n) <= maxN;
+    // Build facet URLs through catalog.toQuery so they are byte-identical to the
+    // canonical links the facet rail emits (same param order) — no duplicate URLs.
+    const facetLocs = [
+      ...singles.filter(keepSingle).map((r) => `shop${catalog.toQuery({}, { [r.dim]: r.val })}`),
+      ...pairs.filter(keepPair).map((r) => `shop${catalog.toQuery({ room: r.room }, { [r.dim]: r.val })}`),
+    ];
+    // {loc, lastmod?} — static pages first, then room categories, guides, the
+    // public visitor-built rooms, and now the indexable facet landing pages.
     const entries = [
       { loc: '' }, { loc: 'shop' }, { loc: 'looks' }, { loc: 'build' },
       { loc: 'guides' }, { loc: 'disclosure' }, { loc: 'privacy' },
       ...ROOMS.map(([s]) => ({ loc: `rooms/${s}` })),
       ...guides.map((g) => ({ loc: `guides/${g.slug}`, lastmod: iso(g.updated_at) })),
       ...rooms.map((r) => ({ loc: `room/${r.slug}`, lastmod: iso(r.updated_at) })),
+      ...facetLocs.map((loc) => ({ loc })),
     ];
     const urls = entries.map((e) =>
-      `<url><loc>${SITE.url}/${e.loc}</loc>${e.lastmod ? `<lastmod>${e.lastmod}</lastmod>` : ''}</url>`).join('');
+      `<url><loc>${SITE.url}/${esc(e.loc)}</loc>${e.lastmod ? `<lastmod>${e.lastmod}</lastmod>` : ''}</url>`).join('');
     res.type('application/xml').send(`<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${urls}</urlset>`);
   } catch (e) { next(e); }
 });

← 3e86ee5 IDS admin: implement EZ-Join DTD verdict (C+relabel), fix sm  ·  back to Interiordesignershowroom  ·  nav: promote kitchen/bathroom to rooms + upper-right room ha ecc8fe0 →