← 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
A public/marketplace/og-default.svgM src/marketplace/seo.jsM tests/marketplace/smoke.test.jsM tests/unit/marketplace-seo.test.js
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 →