[object Object]

← back to Philipperomano

philipperomano SEO: add canonical + Open Graph + Twitter card + listing h1 (DTD verdict B)

8aa8581e4a0f3d82303a120cff54be6f63176719 · 2026-06-03 20:20:22 -0700 · Steve

Storefront had ZERO canonical/og/twitter and no h1 on the listing page.
Extended layout() with canonical/ogImage/ogTitle/ogDesc params injecting:
canonical, og:type/site_name/title/description/url/image, twitter:card.
Listing route: canonical = path + cat/type only (DTD-B, drops volatile
sort/page/q to consolidate faceted duplicates), og:image = first product;
added an sr-only h1. Detail route: per-product canonical /p/<handle> +
og:image (detail already had a visible h1). Added .sr-only utility CSS.
Verified on a free port: homepage + faceted + detail all correct, grid/sort/
density intact, JS unaffected. Local — needs deploy.

Files touched

Diff

commit 8aa8581e4a0f3d82303a120cff54be6f63176719
Author: Steve <steve@designerwallcoverings.com>
Date:   Wed Jun 3 20:20:22 2026 -0700

    philipperomano SEO: add canonical + Open Graph + Twitter card + listing h1 (DTD verdict B)
    
    Storefront had ZERO canonical/og/twitter and no h1 on the listing page.
    Extended layout() with canonical/ogImage/ogTitle/ogDesc params injecting:
    canonical, og:type/site_name/title/description/url/image, twitter:card.
    Listing route: canonical = path + cat/type only (DTD-B, drops volatile
    sort/page/q to consolidate faceted duplicates), og:image = first product;
    added an sr-only h1. Detail route: per-product canonical /p/<handle> +
    og:image (detail already had a visible h1). Added .sr-only utility CSS.
    Verified on a free port: homepage + faceted + detail all correct, grid/sort/
    density intact, JS unaffected. Local — needs deploy.
---
 server.js | 28 ++++++++++++++++++++++++----
 1 file changed, 24 insertions(+), 4 deletions(-)

diff --git a/server.js b/server.js
index 84ad35e..a05e5b6 100644
--- a/server.js
+++ b/server.js
@@ -426,13 +426,20 @@ function memoMailto(p) {
   return `mailto:${PR_EMAIL}?subject=${sub}&body=${body}`;
 }
 
-const layout = (title, body, { q='', type='', cat='' } = {}) => `<!doctype html>
+const PR_ORIGIN = 'https://philipperomano.com';
+const PR_DESC = 'Phillipe Romano commercial wallcoverings, naturals, and fabrics. Order memo samples free.';
+const layout = (title, body, { q='', type='', cat='', canonical='', ogImage='', ogTitle='', ogDesc='' } = {}) => `<!doctype html>
 <html lang="en">
 <head>
 <meta charset="utf-8">
 <meta name="viewport" content="width=device-width,initial-scale=1">
 <title>${esc(title)} — Phillipe Romano</title>
-<meta name="description" content="Phillipe Romano commercial wallcoverings, naturals, and fabrics. Order memo samples free.">
+<meta name="description" content="${esc(PR_DESC)}">
+${canonical ? `<link rel="canonical" href="${esc(canonical)}">\n` : ''}<meta property="og:type" content="website">
+<meta property="og:site_name" content="Phillipe Romano">
+<meta property="og:title" content="${esc(ogTitle || (title + ' — Phillipe Romano'))}">
+<meta property="og:description" content="${esc(ogDesc || PR_DESC)}">
+${canonical ? `<meta property="og:url" content="${esc(canonical)}">\n` : ''}${ogImage ? `<meta property="og:image" content="${esc(ogImage)}">\n` : ''}<meta name="twitter:card" content="${ogImage ? 'summary_large_image' : 'summary'}">
 <style>
   :root {
     --bg:#faf7f2; --fg:#1a1714; --muted:#7c736a; --rule:#d8cec0;
@@ -443,6 +450,7 @@ const layout = (title, body, { q='', type='', cat='' } = {}) => `<!doctype html>
   *{box-sizing:border-box;margin:0;padding:0}
   html,body{font-family:var(--sans);background:var(--bg);color:var(--fg);line-height:1.5}
   a{color:inherit;text-decoration:none}
+  .sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
   header.top{padding:24px 32px;border-bottom:1px solid var(--rule);background:#fff;display:flex;justify-content:space-between;align-items:flex-end;gap:24px;flex-wrap:wrap}
   .brand{font-family:var(--serif);font-weight:600;font-size:32px;letter-spacing:.04em;text-transform:uppercase}
   .brand small{display:block;font-family:var(--sans);font-size:11px;letter-spacing:.18em;font-weight:500;color:var(--muted);margin-top:4px}
@@ -653,6 +661,7 @@ app.get('/', (req, res) => {
 
   const body = `
     <main>
+      <h1 class="sr-only">Phillipe Romano — ${esc(cat === 'naturals' ? 'Naturals' : (type || 'All Products'))}</h1>
       <div class="meta-line">
         ${q || type ? `Showing ${total.toLocaleString()} ${type ? `<strong style="color:var(--fg)">${esc(type)}</strong>` : ''} ${q ? `matching "<strong style="color:var(--fg)">${esc(q)}</strong>"` : ''}` : `${total.toLocaleString()} products available`}
       </div>
@@ -715,7 +724,15 @@ app.get('/', (req, res) => {
       })();
     </script>
   `;
-  res.set('Content-Type','text/html').send(layout(`${cat === 'naturals' ? 'Naturals' : (type || 'All Products')}${q ? ` · ${q}` : ''}`, body, { q, type, cat }));
+  // Canonical (DTD verdict B): keep content filters (cat, type), drop volatile
+  // view params (sort, page, q) so all sorted/paginated/searched views of a
+  // listing consolidate to one canonical. og:image = first product on the page.
+  const canonParams = new URLSearchParams();
+  if (cat) canonParams.set('cat', cat);
+  if (type) canonParams.set('type', type);
+  const canonical = PR_ORIGIN + '/' + (canonParams.toString() ? '?' + canonParams.toString() : '');
+  const ogImage = (items[0] && items[0].image_url) || '';
+  res.set('Content-Type','text/html').send(layout(`${cat === 'naturals' ? 'Naturals' : (type || 'All Products')}${q ? ` · ${q}` : ''}`, body, { q, type, cat, canonical, ogImage }));
 });
 
 app.get('/p/:handle', (req, res) => {
@@ -740,7 +757,10 @@ app.get('/p/:handle', (req, res) => {
       </div>
     </div>
   `;
-  res.set('Content-Type','text/html').send(layout(p.title, body));
+  res.set('Content-Type','text/html').send(layout(p.title, body, {
+    canonical: PR_ORIGIN + '/p/' + encodeURIComponent(p.handle || ''),
+    ogImage: p.image_url || '',
+  }));
 });
 
 app.get('/health', (_req, res) => res.json({ ok: true, products: ENRICHED.length, types: TYPES }));

← 6f1ad72 gitignore: add full backup-file pattern set (*.bak-*, *.pre-  ·  back to Philipperomano  ·  Scrub residual vendor names from products.json (title/vendor 9ddbf64 →