← 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
M lib/render.jsM public/css/site.cssM server.js
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 →