[object Object]

← back to Wallco Ai

marketplace SEO: sitemap-index + BreadcrumbList JSON-LD + og-default fallback

3b4a28429798f6f155ee8ec206e4cd483a0badf2 · 2026-05-13 17:39:44 -0700 · SteveStudio2

Builds on the SEO foundation:
  * /sitemap-index.xml — sitemap-of-sitemaps, single discovery URL pointing
    crawlers at /sitemap.xml (catalog) and /sitemap-marketplace.xml.
  * BreadcrumbList JSON-LD now emitted on /designers/:slug (3-step trail:
    Wallco → Designers → Name) and /patterns/:slug (4-step trail: Wallco →
    Patterns → Designer → Title) for Google rich-results breadcrumb display.
  * public/marketplace/og-default.svg — real social-card fallback, replaces
    the dangling og-default.png reference in og:image/twitter:image when a
    pattern/designer has no image yet.
  * seo.js exports two new pure helpers: buildSitemapIndexXml(entries) and
    breadcrumbJsonLd(items).

Tests: +7 unit (now 27 total) + 4 smoke (now 36 total). All green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 3b4a28429798f6f155ee8ec206e4cd483a0badf2
Author: SteveStudio2 <steve@designerwallcoverings.com>
Date:   Wed May 13 17:39:44 2026 -0700

    marketplace SEO: sitemap-index + BreadcrumbList JSON-LD + og-default fallback
    
    Builds on the SEO foundation:
      * /sitemap-index.xml — sitemap-of-sitemaps, single discovery URL pointing
        crawlers at /sitemap.xml (catalog) and /sitemap-marketplace.xml.
      * BreadcrumbList JSON-LD now emitted on /designers/:slug (3-step trail:
        Wallco → Designers → Name) and /patterns/:slug (4-step trail: Wallco →
        Patterns → Designer → Title) for Google rich-results breadcrumb display.
      * public/marketplace/og-default.svg — real social-card fallback, replaces
        the dangling og-default.png reference in og:image/twitter:image when a
        pattern/designer has no image yet.
      * seo.js exports two new pure helpers: buildSitemapIndexXml(entries) and
        breadcrumbJsonLd(items).
    
    Tests: +7 unit (now 27 total) + 4 smoke (now 36 total). All green.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 public/marketplace/og-default.svg  | 22 +++++++++++
 src/marketplace/seo.js             | 51 ++++++++++++++++++++++++-
 tests/marketplace/smoke.test.js    | 27 +++++++++++++
 tests/unit/marketplace-seo.test.js | 77 +++++++++++++++++++++++++++++++++++++-
 4 files changed, 173 insertions(+), 4 deletions(-)

diff --git a/public/marketplace/og-default.svg b/public/marketplace/og-default.svg
new file mode 100644
index 0000000..5cbf928
--- /dev/null
+++ b/public/marketplace/og-default.svg
@@ -0,0 +1,22 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630" role="img" aria-label="Wallco Designer Marketplace">
+  <defs>
+    <linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
+      <stop offset="0" stop-color="#1f1a14"/>
+      <stop offset="1" stop-color="#3a2f24"/>
+    </linearGradient>
+    <pattern id="dots" x="0" y="0" width="40" height="40" patternUnits="userSpaceOnUse">
+      <circle cx="20" cy="20" r="1.2" fill="#d2b15c" opacity="0.18"/>
+    </pattern>
+  </defs>
+  <rect width="1200" height="630" fill="url(#bg)"/>
+  <rect width="1200" height="630" fill="url(#dots)"/>
+  <g fill="#f5efe2" font-family="Georgia, 'Times New Roman', serif">
+    <text x="80" y="240" font-size="92" font-weight="400" letter-spacing="-2">Wallco</text>
+    <text x="80" y="340" font-size="38" font-style="italic" fill="#d2b15c" opacity="0.95">Designer-warranted wallcoverings.</text>
+    <text x="80" y="392" font-size="28" opacity="0.75">Patterns from independent artists, sampled in your room before you commit.</text>
+  </g>
+  <g transform="translate(80,500)" fill="#f5efe2" font-family="Helvetica, Arial, sans-serif" font-size="22" opacity="0.7">
+    <text>wallco.ai · designerwallcoverings.com</text>
+  </g>
+</svg>
diff --git a/src/marketplace/seo.js b/src/marketplace/seo.js
index 06207e5..3a2b742 100644
--- a/src/marketplace/seo.js
+++ b/src/marketplace/seo.js
@@ -88,6 +88,38 @@ function buildSitemapXml(designers, patterns) {
   return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls.join('\n')}\n</urlset>\n`;
 }
 
+// ── Sitemap index
+// Wraps multiple sitemap URLs into a <sitemapindex> doc so crawlers fetch all
+// of them from a single discovery point (/sitemap-index.xml). Each entry is
+// { loc, lastmod? }; lastmod defaults to today when omitted.
+function buildSitemapIndexXml(entries) {
+  const today = new Date().toISOString().slice(0, 10);
+  const rows = (entries || []).filter(e => e && e.loc).map(e => {
+    const lm = (e.lastmod ? String(e.lastmod).slice(0, 10) : today);
+    return `  <sitemap><loc>${escXml(e.loc)}</loc><lastmod>${lm}</lastmod></sitemap>`;
+  });
+  return `<?xml version="1.0" encoding="UTF-8"?>\n<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${rows.join('\n')}\n</sitemapindex>\n`;
+}
+
+// ── BreadcrumbList JSON-LD
+// `items` is an ordered array of { name, url } from root → leaf. Google's
+// rich-results parser uses position to render the trail. URLs are absolutized
+// against SITE so the schema is correct even when the document is fetched from
+// a non-canonical host.
+function breadcrumbJsonLd(items) {
+  const list = (items || []).filter(i => i && i.name && i.url).map((i, idx) => ({
+    '@type': 'ListItem',
+    position: idx + 1,
+    name: i.name,
+    item: absUrl(i.url),
+  }));
+  return {
+    '@context': 'https://schema.org',
+    '@type': 'BreadcrumbList',
+    itemListElement: list,
+  };
+}
+
 // ── Designer meta + JSON-LD Person
 function designerJsonLd(d) {
   const url = `${SITE}/designers/${d.slug}`;
@@ -114,9 +146,14 @@ function designerMetaBlock(d) {
   const descRaw  = d.bio || `${d.display_name} on Wallco — designer-warranted wallcovering patterns.`;
   const desc     = truncate(descRaw, 200);
   const url      = `${SITE}/designers/${d.slug}`;
-  const image    = absUrl(d.cover_url || d.avatar_url || '/marketplace/og-default.png');
+  const image    = absUrl(d.cover_url || d.avatar_url || '/marketplace/og-default.svg');
   const robots   = (d.status === 'approved') ? 'index,follow' : 'noindex,nofollow';
   const jsonLd   = designerJsonLd(d);
+  const crumbs   = breadcrumbJsonLd([
+    { name: 'Wallco',    url: '/' },
+    { name: 'Designers', url: '/designers' },
+    { name: d.display_name, url: `/designers/${d.slug}` },
+  ]);
 
   return [
     `<title>${escHtml(title)}</title>`,
@@ -134,6 +171,7 @@ function designerMetaBlock(d) {
     `<meta name="twitter:description" content="${escAttr(desc)}" />`,
     `<meta name="twitter:image" content="${escAttr(image)}" />`,
     `<script type="application/ld+json">${jsonLdSafe(jsonLd)}</script>`,
+    `<script type="application/ld+json">${jsonLdSafe(crumbs)}</script>`,
   ].join('\n');
 }
 
@@ -211,9 +249,15 @@ function patternMetaBlock(p, d) {
   const title  = `${p.title} by ${designerName} — Wallco`;
   const desc   = truncate(p.description || `${p.title} — designer-warranted wallcovering pattern on Wallco by ${designerName}.`, 200);
   const url    = `${SITE}/patterns/${p.slug}`;
-  const image  = absUrl(p.original_image_url || p.thumbnail_url || '/marketplace/og-default.png');
+  const image  = absUrl(p.original_image_url || p.thumbnail_url || '/marketplace/og-default.svg');
   const robots = (p.status === 'approved') ? 'index,follow' : 'noindex,nofollow';
   const blocks = patternJsonLd(p, d);
+  const crumbs = breadcrumbJsonLd([
+    { name: 'Wallco',    url: '/' },
+    { name: 'Patterns',  url: '/patterns' },
+    ...(d ? [{ name: d.display_name, url: `/designers/${d.slug}` }] : []),
+    { name: p.title, url: `/patterns/${p.slug}` },
+  ]);
 
   const tags = [].concat(p.style_tags || [], p.motif_tags || [], p.color_tags || [], p.room_tags || []).filter(Boolean);
   const keywords = tags.length ? `<meta name="keywords" content="${escAttr(tags.join(', '))}" />` : '';
@@ -235,6 +279,7 @@ function patternMetaBlock(p, d) {
     `<meta name="twitter:description" content="${escAttr(desc)}" />`,
     `<meta name="twitter:image" content="${escAttr(image)}" />`,
     ...blocks.map(b => `<script type="application/ld+json">${jsonLdSafe(b)}</script>`),
+    `<script type="application/ld+json">${jsonLdSafe(crumbs)}</script>`,
   ].filter(Boolean).join('\n');
 }
 
@@ -259,6 +304,8 @@ function injectIntoHead(html, metaBlock) {
 module.exports = {
   SITE,
   buildSitemapXml,
+  buildSitemapIndexXml,
+  breadcrumbJsonLd,
   designerJsonLd,
   designerMetaBlock,
   patternJsonLd,
diff --git a/tests/marketplace/smoke.test.js b/tests/marketplace/smoke.test.js
index 576ed2e..1801a10 100644
--- a/tests/marketplace/smoke.test.js
+++ b/tests/marketplace/smoke.test.js
@@ -249,6 +249,33 @@ t('GET /patterns/:slug renders Product + CreativeWork JSON-LD', async () => {
   assert.ok(/<link rel="canonical" href="https:\/\/wallco\.ai\/patterns\/wildflower-reverie-astrid-mauve"/.test(r.body));
 });
 
+t('GET /sitemap-index.xml lists both sitemaps', async () => {
+  const r = await req('GET', '/sitemap-index.xml');
+  assert.strictEqual(r.status, 200);
+  assert.ok(/<sitemapindex /.test(r.body), 'sitemapindex envelope missing');
+  assert.ok(r.body.includes('<loc>https://wallco.ai/sitemap.xml</loc>'));
+  assert.ok(r.body.includes('<loc>https://wallco.ai/sitemap-marketplace.xml</loc>'));
+});
+
+t('Designer + pattern pages include BreadcrumbList JSON-LD', async () => {
+  const a = await req('GET', '/designers/astrid-mauve');
+  assert.ok(/"@type":"BreadcrumbList"/.test(a.body), 'designer breadcrumb missing');
+  const b = await req('GET', '/patterns/wildflower-reverie-astrid-mauve');
+  assert.ok(/"@type":"BreadcrumbList"/.test(b.body), 'pattern breadcrumb missing');
+});
+
+t('GET /marketplace/og-default.svg returns SVG', async () => {
+  const r = await req('GET', '/marketplace/og-default.svg');
+  assert.strictEqual(r.status, 200);
+  assert.ok(/<svg/.test(r.body), 'expected SVG body');
+});
+
+t('robots.txt advertises sitemap-index', async () => {
+  const r = await req('GET', '/robots.txt');
+  assert.strictEqual(r.status, 200);
+  assert.ok(/Sitemap: https:\/\/wallco\.ai\/sitemap-index\.xml/.test(r.body), 'sitemap-index not advertised');
+});
+
 (async () => {
   let pass = 0, fail = 0;
   for (const test of tests) {
diff --git a/tests/unit/marketplace-seo.test.js b/tests/unit/marketplace-seo.test.js
index 371585b..a852c99 100644
--- a/tests/unit/marketplace-seo.test.js
+++ b/tests/unit/marketplace-seo.test.js
@@ -6,7 +6,8 @@ const { test, describe } = require('node:test');
 const assert = require('node:assert/strict');
 
 const seo = require('../../src/marketplace/seo.js');
-const { buildSitemapXml, designerMetaBlock, designerJsonLd,
+const { buildSitemapXml, buildSitemapIndexXml, breadcrumbJsonLd,
+        designerMetaBlock, designerJsonLd,
         patternMetaBlock, patternJsonLd, injectIntoHead } = seo;
 
 // Pull the first JSON-LD payload out of a meta block we just rendered.
@@ -86,6 +87,63 @@ describe('buildSitemapXml', () => {
   });
 });
 
+// ---------------------------------------------------------------------------
+// buildSitemapIndexXml
+// ---------------------------------------------------------------------------
+describe('buildSitemapIndexXml', () => {
+  test('wraps entries in <sitemapindex> envelope', () => {
+    const xml = buildSitemapIndexXml([
+      { loc: 'https://wallco.ai/sitemap.xml',             lastmod: '2026-05-13' },
+      { loc: 'https://wallco.ai/sitemap-marketplace.xml', lastmod: '2026-05-13' },
+    ]);
+    assert.match(xml, /^<\?xml/);
+    assert.match(xml, /<sitemapindex xmlns="http:\/\/www\.sitemaps\.org\/schemas\/sitemap\/0\.9">/);
+    assert.match(xml, /<sitemap><loc>https:\/\/wallco\.ai\/sitemap\.xml<\/loc><lastmod>2026-05-13<\/lastmod><\/sitemap>/);
+    assert.match(xml, /<sitemap><loc>https:\/\/wallco\.ai\/sitemap-marketplace\.xml<\/loc><lastmod>2026-05-13<\/lastmod><\/sitemap>/);
+    assert.match(xml, /<\/sitemapindex>/);
+  });
+
+  test('defaults lastmod to today when omitted', () => {
+    const xml = buildSitemapIndexXml([{ loc: 'https://x.test/s.xml' }]);
+    const today = new Date().toISOString().slice(0, 10);
+    assert.ok(xml.includes(`<lastmod>${today}</lastmod>`));
+  });
+
+  test('skips entries without a loc', () => {
+    const xml = buildSitemapIndexXml([{ loc: 'https://x.test/s.xml' }, { loc: '' }, undefined]);
+    const n = (xml.match(/<sitemap>/g) || []).length;
+    assert.equal(n, 1);
+  });
+});
+
+// ---------------------------------------------------------------------------
+// breadcrumbJsonLd
+// ---------------------------------------------------------------------------
+describe('breadcrumbJsonLd', () => {
+  test('numbers items in order, absolutizes URLs', () => {
+    const ld = breadcrumbJsonLd([
+      { name: 'Wallco',    url: '/' },
+      { name: 'Designers', url: '/designers' },
+      { name: 'Astrid',    url: '/designers/astrid-mauve' },
+    ]);
+    assert.equal(ld['@type'], 'BreadcrumbList');
+    assert.equal(ld.itemListElement.length, 3);
+    assert.equal(ld.itemListElement[0].position, 1);
+    assert.equal(ld.itemListElement[2].name, 'Astrid');
+    assert.equal(ld.itemListElement[2].item, 'https://wallco.ai/designers/astrid-mauve');
+  });
+
+  test('preserves already-absolute URLs', () => {
+    const ld = breadcrumbJsonLd([{ name: 'x', url: 'https://other.test/x' }]);
+    assert.equal(ld.itemListElement[0].item, 'https://other.test/x');
+  });
+
+  test('drops malformed items', () => {
+    const ld = breadcrumbJsonLd([null, { name: 'a' }, { url: '/b' }, { name: 'c', url: '/c' }]);
+    assert.equal(ld.itemListElement.length, 1);
+  });
+});
+
 // ---------------------------------------------------------------------------
 // designerJsonLd
 // ---------------------------------------------------------------------------
@@ -144,6 +202,17 @@ describe('designerMetaBlock', () => {
     assert.match(html, /<meta name="robots" content="noindex,nofollow" \/>/);
   });
 
+  test('emits Person + BreadcrumbList JSON-LD', () => {
+    const html = designerMetaBlock(d);
+    const blocks = allJsonLd(html);
+    assert.equal(blocks.length, 2);
+    assert.equal(blocks[0]['@type'], 'Person');
+    assert.equal(blocks[1]['@type'], 'BreadcrumbList');
+    // 3-step trail: Wallco → Designers → Astrid Mauve
+    assert.equal(blocks[1].itemListElement.length, 3);
+    assert.equal(blocks[1].itemListElement[2].item, 'https://wallco.ai/designers/astrid-mauve');
+  });
+
   test('escapes HTML-special chars in name/bio', () => {
     const html = designerMetaBlock({
       display_name: 'A<X>B & "C"', slug: 'a-x-b', status: 'approved',
@@ -213,9 +282,13 @@ describe('patternMetaBlock', () => {
     assert.match(html, /<link rel="canonical" href="https:\/\/wallco\.ai\/patterns\/p1" \/>/);
     assert.match(html, /<meta name="keywords"/);
     const blocks = allJsonLd(html);
-    assert.equal(blocks.length, 2);
+    assert.equal(blocks.length, 3);
     assert.equal(blocks[0]['@type'], 'Product');
     assert.equal(blocks[1]['@type'], 'CreativeWork');
+    assert.equal(blocks[2]['@type'], 'BreadcrumbList');
+    // Breadcrumb trail: Wallco → Patterns → Designer → Pattern title
+    assert.equal(blocks[2].itemListElement.length, 4);
+    assert.equal(blocks[2].itemListElement[3].name, 'Sun Vines');
   });
 
   test('JSON-LD payload is safe against script-closing injection in title', () => {

← 3b1a002 brand: gucci viewer adopts universal corner-nav (bag/person/  ·  back to Wallco Ai  ·  feat: 18 geometric-block-leaves designs + 2 fill-in runners d14bd81 →