[object Object]

← back to Interiordesignershowroom

seo: breadcrumbs (nav + BreadcrumbList) across facet/room/guide/looks pages, multi-JSON-LD layout (Cody-hardened)

5b5102f13d505a406f133c22d9808bf672783b01 · 2026-08-03 10:16:22 -0700 · Steve Abrams

Files touched

Diff

commit 5b5102f13d505a406f133c22d9808bf672783b01
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Aug 3 10:16:22 2026 -0700

    seo: breadcrumbs (nav + BreadcrumbList) across facet/room/guide/looks pages, multi-JSON-LD layout (Cody-hardened)
---
 lib/render.js       | 28 ++++++++++++++++++++++++++--
 public/css/site.css | 10 ++++++++++
 server.js           | 42 ++++++++++++++++++++++++++++++------------
 3 files changed, 66 insertions(+), 14 deletions(-)

diff --git a/lib/render.js b/lib/render.js
index 8c2276c..a88ae7b 100644
--- a/lib/render.js
+++ b/lib/render.js
@@ -158,6 +158,30 @@ function roomThumbs(products = []) {
   return `<div class="room-thumbs" role="list" aria-label="Items in this room">${items}</div><script src="/js/room-thumbs.js" defer></script>`;
 }
 
+// Breadcrumbs from a single [{name, url}] trail — the LAST item is the current page
+// (rendered as plain text, no link). Returns BOTH the visible <nav> and the matching
+// BreadcrumbList JSON-LD so the two never disagree. url is absolute for the schema.
+function breadcrumbNav(items = []) {
+  if (items.length < 2) return '';
+  // Visible links are root-relative (strip the origin) so internal nav never leaves
+  // the current host on staging/localhost; the JSON-LD keeps absolute URLs per spec.
+  const rel = (u) => (u || '').replace(SITE.url, '') || '/';
+  const parts = items.map((it, i) => (i === items.length - 1)
+    ? `<span aria-current="page">${esc(it.name)}</span>`
+    : `<a href="${esc(rel(it.url))}">${esc(it.name)}</a>`);
+  return `<nav class="breadcrumbs" aria-label="Breadcrumb">${parts.join('<span class="crumb-sep" aria-hidden="true">›</span>')}</nav>`;
+}
+function breadcrumbJsonld(items = []) {
+  if (items.length < 2) return null;
+  return {
+    '@context': 'https://schema.org', '@type': 'BreadcrumbList',
+    itemListElement: items.map((it, i) => ({
+      '@type': 'ListItem', position: i + 1, name: it.name,
+      item: absUrl(it.url) || it.url,
+    })),
+  };
+}
+
 function layout({ title, description, canonical, jsonld, image, body, activeNav }) {
   const ogImage = absUrl(image) || `${SITE.url}/img/og.png`;
   const nav = [
@@ -208,7 +232,7 @@ function layout({ title, description, canonical, jsonld, image, body, activeNav
 <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
 <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@300;400;500&family=DM+Serif+Display&display=swap">
 <link rel="stylesheet" href="/css/site.css">
-${jsonld ? `<script type="application/ld+json">${JSON.stringify(jsonld)}</script>` : ''}
+${(Array.isArray(jsonld) ? jsonld : [jsonld]).filter(Boolean).map((j) => `<script type="application/ld+json">${JSON.stringify(j)}</script>`).join('')}
 </head>
 <body>
 ${disclosureBar()}
@@ -240,4 +264,4 @@ ${disclosureBar()}
 </html>`;
 }
 
-module.exports = { SITE, esc, money, layout, productCard, disclosureBar, DISCLOSURE_SHORT, orgWebsiteJsonld, productListJsonld, absUrl, sceneFigure, roomThumbs };
+module.exports = { SITE, esc, money, layout, productCard, disclosureBar, DISCLOSURE_SHORT, orgWebsiteJsonld, productListJsonld, breadcrumbNav, breadcrumbJsonld, absUrl, sceneFigure, roomThumbs };
diff --git a/public/css/site.css b/public/css/site.css
index e875d72..fbc1e86 100644
--- a/public/css/site.css
+++ b/public/css/site.css
@@ -1788,3 +1788,13 @@ body.admin-view .card{outline:2px solid #e6c98a;outline-offset:-2px}
 .room-menu__panel a:hover { background: var(--bg, #f4efe8); color: var(--accent); }
 .room-menu__panel a[aria-current="page"] { background: var(--ink); color: #fff; }
 @media (max-width: 520px) { .room-menu__label { display: none; } .room-menu__panel { position: fixed; left: 12px; right: 12px; min-width: 0; } }
+
+/* ── Breadcrumbs (facet / room / guide pages) ───────────────────────────── */
+.breadcrumbs {
+  display: flex; flex-wrap: wrap; align-items: center; gap: 8px;
+  margin: 0 0 14px; font-size: .74rem; letter-spacing: .04em; color: var(--muted, #8a8178);
+}
+.breadcrumbs a { color: var(--muted, #8a8178); text-decoration: none; transition: color .12s ease; }
+.breadcrumbs a:hover { color: var(--accent); text-decoration: underline; }
+.breadcrumbs [aria-current="page"] { color: var(--ink); font-weight: 500; }
+.breadcrumbs .crumb-sep { color: var(--line); }
diff --git a/server.js b/server.js
index dbe5e1b..9480c5d 100644
--- a/server.js
+++ b/server.js
@@ -3,7 +3,7 @@ const express = require('express');
 const path = require('path');
 const fs = require('fs');
 const db = require('./lib/db');
-const { SITE, esc, layout, productCard, orgWebsiteJsonld, productListJsonld, sceneFigure, roomThumbs } = require('./lib/render');
+const { SITE, esc, layout, productCard, orgWebsiteJsonld, productListJsonld, breadcrumbNav, breadcrumbJsonld, absUrl, sceneFigure, roomThumbs } = require('./lib/render');
 const catalog = require('./lib/catalog');
 const rooms = require('./lib/rooms');
 const scene = require('./lib/scene');
@@ -105,9 +105,10 @@ app.get('/', async (_req, res, next) => {
 
 // Shared faceted catalog renderer for /shop and /rooms. Filters are URL-addressable
 // so every facet is a real drillable link (Steve's "data points href to deeper data").
-async function renderCatalog(res, { f, basePath, title, description, canonical, activeNav, heading }) {
+async function renderCatalog(res, { f, basePath, title, description, canonical, activeNav, heading, crumbs }) {
   const [products, counts] = await Promise.all([catalog.fetchProducts(f), catalog.facetCounts(f)]);
   const body = `<section>
+      ${breadcrumbNav(crumbs)}
       <h1>${esc(heading)}</h1>
       ${catalog.activeChips(f, basePath)}
       <div class="catalog">
@@ -115,7 +116,8 @@ async function renderCatalog(res, { f, basePath, title, description, canonical,
         <div class="catalog-main">${gridControls(products.length)}${grid(products)}</div>
       </div>
     </section><script src="/js/grid.js"></script>`;
-  res.send(layout({ title, description, canonical, jsonld: productListJsonld(products, canonical), body, activeNav }));
+  const jsonld = [productListJsonld(products, canonical), breadcrumbJsonld(crumbs)].filter(Boolean);
+  res.send(layout({ title, description, canonical, jsonld, body, activeNav }));
 }
 
 // SEO for /shop under filters. Only a whitelist of "clean" facet shapes — a single
@@ -143,26 +145,31 @@ const ROOM_SLUGS = new Set(ROOMS.map(([s]) => s)); // rooms that have a real nav
 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 crumbBase = [{ name: 'Home', url: `${SITE.url}/` }, { name: 'Shop', url: `${SITE.url}/shop` }];
   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;
+    title: 'Shop the Showroom', description: 'Filter curated designer pieces by room, style, color, or price.',
+    crumbs: crumbBase };
+  let heading, title, description, crumbs;
+  const canonical = `${SITE.url}/shop${catalog.toQuery(f)}`;
   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.`;
+    crumbs = [...crumbBase, { name: CAP(v), url: canonical }];
   } 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.`;
+    crumbs = [...crumbBase, { name: roomLabel(f.room), url: `${SITE.url}/rooms/${f.room}` }, { name: CAP(v), url: canonical }];
   } 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 };
+  return { canonical, heading, title, description, crumbs };
 }
 
 app.get('/shop', async (req, res, next) => {
@@ -170,7 +177,7 @@ app.get('/shop', async (req, res, next) => {
     const f = catalog.parseFilters(req.query);
     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 });
+      heading: seo.heading, title: seo.title, description: seo.description, canonical: seo.canonical, crumbs: seo.crumbs });
   } catch (e) { next(e); }
 });
 
@@ -184,9 +191,11 @@ app.get('/rooms/:room', async (req, res, next) => {
     if (!match) return next();
     const label = match[1];
     const f = { ...catalog.parseFilters(req.query), room };
+    const crumbs = [{ name: 'Home', url: `${SITE.url}/` }, { name: 'Shop', url: `${SITE.url}/shop` },
+      { name: label, url: `${SITE.url}/rooms/${room}` }];
     await renderCatalog(res, { f, basePath: '/shop', heading: label,
       title: `${label} Furniture & Decor`, description: `Curated ${label.toLowerCase()} pieces from top design brands.`,
-      canonical: `${SITE.url}/rooms/${room}`, activeNav: `/rooms/${room}` });
+      canonical: `${SITE.url}/rooms/${room}`, activeNav: `/rooms/${room}`, crumbs });
   } catch (e) { next(e); }
 });
 
@@ -212,13 +221,17 @@ app.get('/guides/:slug', async (req, res, next) => {
       const r = await db.query(`SELECT ${COLS.PRODUCT} FROM products WHERE id = ANY($1)`, [g.product_ids]);
       picks = r.rows;
     }
-    const jsonld = { '@context': 'https://schema.org', '@type': 'Article', headline: g.title, description: g.dek, image: g.hero_image, datePublished: g.created_at };
+    const article = { '@context': 'https://schema.org', '@type': 'Article', headline: g.title, description: g.dek, image: g.hero_image ? absUrl(g.hero_image) : undefined, datePublished: g.created_at };
+    const crumbs = [{ name: 'Home', url: `${SITE.url}/` }, { name: 'Guides', url: `${SITE.url}/guides` },
+      { name: g.title, url: `${SITE.url}/guides/${g.slug}` }];
     const body = `<article class="guide">
+      ${breadcrumbNav(crumbs)}
       <h1>${esc(g.title)}</h1><p class="dek">${esc(g.dek || '')}</p>
       ${g.hero_image ? `<img class="guide-hero" src="${esc(g.hero_image)}" alt="${esc(g.title)}">` : ''}
       <div class="guide-body">${md(g.body_md || '')}</div>
       ${picks.length ? `<h2>Shop this guide</h2><div class="grid">${picks.map(productCard).join('')}</div>` : ''}
     </article>`;
+    const jsonld = [article, breadcrumbJsonld(crumbs)].filter(Boolean);
     res.send(layout({ title: g.title, description: g.dek, canonical: `${SITE.url}/guides/${g.slug}`, jsonld, image: g.hero_image, body, activeNav: '/guides' }));
   } catch (e) { next(e); }
 });
@@ -541,14 +554,16 @@ app.get('/looks', async (_req, res, next) => {
     }).join('');
 
     const emptyState = `<p class="subtle">No rooms yet — <a href="/build">be the first to build one</a>.</p>`;
+    const crumbs = [{ name: 'Home', url: `${SITE.url}/` }, { name: 'Rooms', url: `${SITE.url}/looks` }];
     const body = `<section>
+      ${breadcrumbNav(crumbs)}
       <div class="builder-head">
         <h1>Rooms</h1>
         <a class="cta sm" href="/build" aria-label="Build a new room">+ Build a room</a>
       </div>
       <div class="look-grid">${cards || emptyState}</div>
     </section>`;
-    res.send(layout({ title: 'Shop the Look — Curated Rooms', description: 'Browse shoppable rooms built from real designer pieces.', canonical: `${SITE.url}/looks`, body, activeNav: '/looks' }));
+    res.send(layout({ title: 'Shop the Look — Curated Rooms', description: 'Browse shoppable rooms built from real designer pieces.', canonical: `${SITE.url}/looks`, jsonld: breadcrumbJsonld(crumbs), body, activeNav: '/looks' }));
   } catch (e) { next(e); }
 });
 
@@ -557,7 +572,9 @@ app.get('/room/:slug', async (req, res, next) => {
     const data = await rooms.getRoom(req.params.slug);
     if (!data) return next();
     const { room, products, paint } = data;
-    const jsonld = { '@context': 'https://schema.org', '@type': 'ItemList', name: room.title, numberOfItems: products.length };
+    const itemList = { '@context': 'https://schema.org', '@type': 'ItemList', name: room.title, numberOfItems: products.length };
+    const crumbs = [{ name: 'Home', url: `${SITE.url}/` }, { name: 'Rooms', url: `${SITE.url}/looks` },
+      { name: room.title, url: `${SITE.url}/room/${room.slug}` }];
 
     // Wall color (Samplize) block — editorial treatment
     const paintBlock = paint ? `
@@ -592,6 +609,7 @@ app.get('/room/:slug', async (req, res, next) => {
       : `<div class="room-empty"><p>No shoppable pieces in this room yet.</p></div>`;
 
     const body = `<article class="room-page">
+      ${breadcrumbNav(crumbs)}
       <div class="room-head">
         <h1>${esc(room.title)}</h1>
         <p class="subtle">${esc(styleLabel)}${styleLabel && products.length ? ' · ' : ''}${products.length ? `${products.length} shoppable piece${products.length === 1 ? '' : 's'}` : ''}</p>
@@ -608,7 +626,7 @@ app.get('/room/:slug', async (req, res, next) => {
       title: room.title,
       description: room.note || `A shoppable ${styleLabel || 'room'} — ${products.length} designer pieces.`,
       canonical: `${SITE.url}/room/${room.slug}`,
-      jsonld, image: room.scene_image, body, activeNav: '/looks'
+      jsonld: [itemList, breadcrumbJsonld(crumbs)].filter(Boolean), image: room.scene_image, body, activeNav: '/looks'
     }));
   } catch (e) { next(e); }
 });

← ecc8fe0 nav: promote kitchen/bathroom to rooms + upper-right room ha  ·  back to Interiordesignershowroom  ·  admin: per-product click analytics column (total, 7d, last-c 82f2795 →