[object Object]

← back to Prestige Car Wash

SEO infra: robots.txt + generated /sitemap.xml + homepage OG/Twitter tags — contrarian-gated (robust desc regex + dup-og guard)

82b259c182180ec68e5c62fc043f24b846175d4e · 2026-07-26 14:35:18 -0700 · steve@designerwallcoverings.com

Files touched

Diff

commit 82b259c182180ec68e5c62fc043f24b846175d4e
Author: steve@designerwallcoverings.com <steve@designerwallcoverings.com>
Date:   Sun Jul 26 14:35:18 2026 -0700

    SEO infra: robots.txt + generated /sitemap.xml + homepage OG/Twitter tags — contrarian-gated (robust desc regex + dup-og guard)
---
 public/robots.txt |  4 ++++
 server.js         | 42 +++++++++++++++++++++++++++++++++++++++---
 2 files changed, 43 insertions(+), 3 deletions(-)

diff --git a/public/robots.txt b/public/robots.txt
new file mode 100644
index 0000000..7e00c83
--- /dev/null
+++ b/public/robots.txt
@@ -0,0 +1,4 @@
+User-agent: *
+Allow: /
+
+Sitemap: https://prestigehandcarwash.com/sitemap.xml
diff --git a/server.js b/server.js
index 5e024bb..ff91204 100644
--- a/server.js
+++ b/server.js
@@ -406,24 +406,60 @@ function buildJsonLd() {
   if (same.length) ld.sameAs = same;
   return ld;
 }
+// Open Graph / Twitter tags for the homepage — reuses the page's own <title> and meta
+// description so social previews stay in sync with on-page SEO. og:image is a real asset.
+function buildSocialTags(html) {
+  // Bail if the HTML already carries og: tags (e.g. added directly to the file) so we
+  // never emit duplicates that make Facebook's debugger flag the markup. (contrarian gate)
+  if (/property=["']og:title["']/i.test(html)) return '';
+  const p = places() || {};
+  const url = (p.website || '').replace(/\/$/, '');
+  const title = (html.match(/<title>([^<]*)<\/title>/i) || [])[1] || p.name || 'Prestige Car Wash';
+  // Attribute-order-independent description extraction, with a real fallback (never a
+  // blank social card). Handles both name-first and content-first, single or double quotes.
+  const descMatch = html.match(/<meta\b[^>]*\bname=["']description["'][^>]*\bcontent=["']([^"']*)["']/i)
+                 || html.match(/<meta\b[^>]*\bcontent=["']([^"']*)["'][^>]*\bname=["']description["']/i);
+  const desc = (descMatch || [])[1] || `${p.name || 'Prestige Car Wash'} — no-pressure flat pricing, two-bucket hand wash, detailing & ceramic coating in the San Fernando Valley.`;
+  const img = url ? url + '/media/svc-full-service.png' : '';
+  const esc = s => String(s).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
+  const tags = [
+    ['og:type', 'website'], ['og:site_name', p.name || 'Prestige Car Wash'],
+    ['og:title', title], ['og:description', desc], url && ['og:url', url], img && ['og:image', img],
+    ['twitter:card', img ? 'summary_large_image' : 'summary'],
+    ['twitter:title', title], ['twitter:description', desc], img && ['twitter:image', img]
+  ].filter(Boolean);
+  return tags.map(([k, v]) => k.startsWith('twitter')
+    ? `<meta name="${k}" content="${esc(v)}">`
+    : `<meta property="${k}" content="${esc(v)}">`).join('\n');
+}
 function homepageHtml() {
   // Built fresh per request (sync file read + one string replace = microseconds) so the
   // JSON-LD NEVER drifts from live places.json — a stale NAP is the exact thing Google
   // penalizes, which would defeat the point of injecting it. (contrarian gate, 2026-07-26)
   let html = fs.readFileSync(path.join(__dirname, 'public', 'index.html'), 'utf8');
+  let inject = buildSocialTags(html);
   const ld = buildJsonLd();
   if (ld) {
     // escape "<" so the JSON can't break out of the <script> tag
-    const json = JSON.stringify(ld).replace(/</g, '\\u003c');
-    html = html.replace('</head>', `<script type="application/ld+json">${json}</script>\n</head>`);
+    inject += `\n<script type="application/ld+json">${JSON.stringify(ld).replace(/</g, '\\u003c')}</script>`;
   }
-  return html;
+  return html.replace('</head>', inject + '\n</head>');
 }
 app.get('/', (req, res) => {
   try { res.type('html').send(homepageHtml()); }
   catch { res.sendFile(path.join(__dirname, 'public', 'index.html')); }
 });
 
+// XML sitemap generated from the public URL — kept in one place, no static file to drift.
+app.get('/sitemap.xml', (req, res) => {
+  const base = ((places() || {}).website || '').replace(/\/$/, '');
+  if (!base) return res.status(404).end();
+  const urls = ['/', '/services', '/contact'];
+  const xml = '<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n' +
+    urls.map(u => `  <url><loc>${base}${u}</loc></url>`).join('\n') + '\n</urlset>';
+  res.type('application/xml').send(xml);
+});
+
 app.use(express.static(path.join(__dirname, 'public'), { extensions: ['html'] }));
 
 app.listen(PORT, () => {

← cbe52eb SEO: server-inject AutoWash/LocalBusiness JSON-LD from place  ·  back to Prestige Car Wash  ·  SEO: enrich AutoWash JSON-LD with hasOfferCatalog (real serv 157f32a →