← 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
A public/robots.txtM server.js
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, '&').replace(/"/g, '"').replace(/</g, '<');
+ 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 →