← 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
M lib/catalog.jsM server.js
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 →