diff --git a/CHANGELOG.md b/CHANGELOG.md index 962ad5bb5..b58d47ef1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # CHANGELOG +## Unreleased — route-specific language alternates + +- Generated crossing, best-time, comparison and other single-URL pages replace + inherited homepage hreflangs with their own canonical URL. Existing published + EN/ES blog, methodology and privacy pairs remain reciprocal; browser language + preferences do not create translated URLs. +- SPA metadata updates replace stale language alternates and restore homepage + alternates on reset, preserving canonical URLs and non-language alternate links. +- Added all-generated-page and desktop/mobile navigation checks, including + language switching, translated article entry and Back. Providers remain blocked + or mocked in browser tests. Sitemap inventory is a separate follow-up; release + and production verification evidence belongs to the scoped pull request. + ## 2026-10-12 — CBP report times and fetch labels - Dashboard and crossing cards distinguish snapshot retrieval from the official diff --git a/ROADMAP.md b/ROADMAP.md index 76b825964..b19090ce4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -21,6 +21,12 @@ cross-border experience; it is a product hypothesis, not a claim of demand. ### Now: earn trust in the free entry point +- [~] Correct route hreflangs in generated HTML and SPA navigation while + preserving actual EN/ES pairs. Publication requires independent review and + complete release checks; exact production verification belongs to the PR. +- [ ] Investigate dynamic walk-or-drive sitemap inventory separately from the + language-alternate correction; do not expand routes in that scoped change. + - [x] Separate CBP lane report times from snapshot retrieval on dashboard and crossing detail cards. Preserve raw report text and unknown dates; release verification belongs to the scoped pull request. diff --git a/package.json b/package.json index 706d9514f..ca1023408 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "scripts": { "dev": "vite", "test": "npm run test:ads && npm run test:writer && npm run test:refresh && npm run test:publish && npm run test:hours && npm run test:trust && npm run test:mdx && npm run test:metadata && npm run test:audit && npm run test:bundle && npm run test:routes", - "test:metadata": "node --test scripts/test-faq-scope.mjs", + "test:metadata": "node --test scripts/test-faq-scope.mjs scripts/test-hreflang.mjs", "test:ads": "node --test scripts/test-ad-slot-diagnostics.mjs", "test:publish": "node --test scripts/test-publish-data.mjs scripts/test-public-crossings.mjs", "publish:validate": "node scripts/validate-publish-data.mjs", diff --git a/scripts/check-hreflang.mjs b/scripts/check-hreflang.mjs new file mode 100644 index 000000000..89094833f --- /dev/null +++ b/scripts/check-hreflang.mjs @@ -0,0 +1,119 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const origin = 'https://borderpulse.com'; +const enArticle = '/blog/san-ysidro-vs-otay-mesa/'; +const esArticle = '/blog/san-ysidro-vs-otay-cual-es-mas-rapida/'; +const single = (route, lang = 'en') => [{ lang, href: origin + route }, { lang: 'x-default', href: origin + route }]; +const pair = (en, es) => [{ lang: 'en', href: origin + en }, { lang: 'es', href: origin + es }, { lang: 'x-default', href: origin + en }]; +const attrs = (tag) => Object.fromEntries([...tag.matchAll(/([\w:-]+)\s*=\s*(["'])(.*?)\2/gs)].map((m) => [m[1].toLowerCase(), m[3]])); +const links = (html) => [...html.matchAll(/]*>/gi)].map((m) => attrs(m[0])); +const alternates = (html) => links(html).filter((a) => a.rel === 'alternate' && a.hreflang).map((a) => ({ lang: a.hreflang, href: a.href })); + +export function checkBuiltHreflangs(dist = path.join(root, 'dist')) { + const posts = JSON.parse(fs.readFileSync(path.join(dist, 'data/blog/index.json'), 'utf8')).posts; + const expected = new Map([ + ['/methodology/', pair('/methodology/', '/metodologia/')], + ['/metodologia/', pair('/methodology/', '/metodologia/')], + ['/privacy/', pair('/privacy/', '/privacidad/')], + ['/privacidad/', pair('/privacy/', '/privacidad/')], + ]); + for (const post of posts) { + const twins = post.translationKey ? posts.filter((p) => p.translationKey === post.translationKey) : []; + const en = twins.find((p) => p.lang === 'en'); + const es = twins.find((p) => p.lang === 'es'); + const route = `/blog/${post.slug}/`; + expected.set(route, en && es ? pair(`/blog/${en.slug}/`, `/blog/${es.slug}/`) : single(route, post.lang || 'en')); + } + let count = 0; + const inspect = (dir) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const file = path.join(dir, entry.name); + if (entry.isDirectory()) { inspect(file); continue; } + if (entry.name !== 'index.html') continue; + const html = fs.readFileSync(file, 'utf8'); + if (/ a.rel === 'canonical'); + assert.deepEqual(canonical.map((a) => a.href), [origin + canonicalRoute], `${route}: canonical`); + const actual = alternates(html); + assert.deepEqual(actual, expected.get(route) || single(canonicalRoute), `${route}: route alternates`); + for (const alternate of actual) { + const target = new URL(alternate.href); + assert.equal(target.origin, origin); + const targetHtml = fs.readFileSync(path.join(dist, target.pathname, 'index.html'), 'utf8'); + assert.ok(alternates(targetHtml).some((a) => a.href === origin + canonicalRoute), `${route}: missing reciprocal alternate on ${target.pathname}`); + } + count++; + } + }; + inspect(dist); + console.log(`[hreflang] PASS: ${count} generated pages; exact route alternates, canonicals and reciprocal targets`); +} + +export async function checkHreflangNavigation(page, baseUrl) { + await page.evaluate(() => { + localStorage.setItem('borderPulse_language', 'en'); + localStorage.setItem('borderPulse_region', 'ALL'); + }); + await page.goto(new URL('/', baseUrl).href); + await page.getByRole('searchbox', { name: 'Search crossings by name or city', exact: true }).waitFor(); + const marker = await page.evaluate(() => (window.hreflangDocument = crypto.randomUUID())); + const inspect = async (route, expected = single(route)) => { + await page.waitForURL((url) => url.pathname === route); + await page.waitForFunction(({ canonical, expected }) => { + const actual = Array.from(document.querySelectorAll('link[rel="alternate"][hreflang]'), (link) => ({ lang: link.hreflang, href: link.href })); + return document.querySelector('link[rel="canonical"]')?.href === canonical && JSON.stringify(actual) === JSON.stringify(expected); + }, { canonical: origin + route, expected }); + assert.equal(await page.evaluate(() => window.hreflangDocument), marker, 'must stay in same SPA document'); + assert.deepEqual(await page.locator('link[rel="canonical"]').evaluateAll((nodes) => nodes.map((node) => node.href)), [origin + route]); + }; + const click = async (route) => page.locator(`#root a[href="${route}"]:visible`).first().click(); + await inspect('/'); + await page.getByRole('button', { name: 'Cambiar a español', exact: true }).filter({ visible: true }).click(); + await page.waitForFunction(() => document.documentElement.lang === 'es'); + await inspect('/'); // Shared URL; no invented Spanish route. + await page.getByRole('button', { name: 'Switch to English', exact: true }).filter({ visible: true }).click(); + await page.getByRole('searchbox', { name: 'Search crossings by name or city', exact: true }).fill('San Ysidro'); + await click('/crossing/san-ysidro/'); + await inspect('/crossing/san-ysidro/'); + await page.getByRole('button', { name: 'Cambiar a español', exact: true }).filter({ visible: true }).click(); + await page.waitForFunction(() => document.documentElement.lang === 'es'); + await inspect('/crossing/san-ysidro/'); + await page.getByRole('button', { name: 'Switch to English', exact: true }).filter({ visible: true }).click(); + await click('/best-time/san-ysidro/'); + await inspect('/best-time/san-ysidro/'); + await page.goBack(); + await inspect('/crossing/san-ysidro/'); + await click('/compare/san-ysidro-vs-otay-mesa/'); + await inspect('/compare/san-ysidro-vs-otay-mesa/'); + const menu = page.getByRole('button', { name: 'Open menu', exact: true }); + if (await menu.isVisible()) await menu.click(); + await page.getByRole('navigation', { name: 'Main navigation', exact: true }).getByRole('link', { name: 'Blog', exact: true }).click(); + await page.getByRole('heading', { level: 1, name: 'Border Pulse Blog', exact: true }).waitFor(); + await inspect('/blog/'); + await click(enArticle); + await inspect(enArticle, pair(enArticle, esArticle)); + await page.locator('#root article').getByRole('link', { name: 'Español', exact: true }).click(); + await inspect(esArticle, pair(enArticle, esArticle)); + await page.goBack(); + await inspect(enArticle, pair(enArticle, esArticle)); + await click('/'); + await inspect('/'); + // A fresh translated entry also has to clear its prerendered pair on exit. + await page.goto(new URL(esArticle, baseUrl).href); + await page.locator('#root article[lang="es"]').waitFor(); + const entryExpected = pair(enArticle, esArticle); + assert.deepEqual(await page.locator('link[rel="alternate"][hreflang]').evaluateAll((nodes) => nodes.map((node) => ({ lang: node.hreflang, href: node.href }))), entryExpected); + await click('/'); + await page.getByRole('searchbox', { name: 'Search crossings by name or city', exact: true }).waitFor(); + assert.deepEqual(await page.locator('link[rel="alternate"][hreflang]').evaluateAll((nodes) => nodes.map((node) => ({ lang: node.hreflang, href: node.href }))), single('/')); + console.log('[hreflang] PASS: SPA home/language switch → crossing → best-time → Back → compare → blog → EN/ES article → Back → home'); +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) checkBuiltHreflangs(); diff --git a/scripts/prerender.mjs b/scripts/prerender.mjs index 333813fbb..a8040f37b 100644 --- a/scripts/prerender.mjs +++ b/scripts/prerender.mjs @@ -5,6 +5,7 @@ import matter from 'gray-matter'; import { mdxBodyToHtml } from './lib/mdx-to-html.mjs'; import { hasPedestrianLane } from '../src/lib/crossingAvailability.js'; import { COMPARE_PAIRS } from '../src/lib/comparePairs.js'; +import { pageHreflangs, blogHreflangs } from '../src/lib/hreflang.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const root = path.resolve(__dirname, '..'); @@ -135,19 +136,7 @@ function renderBlogPostHead(post, author, allPosts) { const datePublished = `${post.date}T12:00:00Z`; const dateModified = `${post.updated || post.date}T12:00:00Z`; - // Build hreflang pairs if this post has a translation twin. - const hreflangs = []; - if (post.translationKey && allPosts) { - const en = allPosts.find( - (p) => p.translationKey === post.translationKey && p.lang === 'en', - ); - const es = allPosts.find( - (p) => p.translationKey === post.translationKey && p.lang === 'es', - ); - if (en) hreflangs.push({ lang: 'en', href: `${BASE}/blog/${en.slug}/` }); - if (es) hreflangs.push({ lang: 'es', href: `${BASE}/blog/${es.slug}/` }); - if (en) hreflangs.push({ lang: 'x-default', href: `${BASE}/blog/${en.slug}/` }); - } + const hreflangs = blogHreflangs(post, allPosts || []); const blogPosting = { '@context': 'https://schema.org', @@ -290,15 +279,12 @@ function rewriteIndex(indexHtml, head) { } } - // Replace any existing hreflang tags with post-specific ones (the shell has - // homepage-default hreflangs from index.html that we must override per page). - if (head.hreflangs && head.hreflangs.length) { - html = html.replace(/\s*]*>/gi, ''); - const hreflangTags = head.hreflangs - .map((h) => ` `) - .join('\n'); - html = html.replace(/<\/head>/i, `${hreflangTags}\n `); - } + // Always replace shell alternates, including routes without translated URLs. + html = html.replace(/\s*]*\brel=["']alternate["'])(?=[^>]*\bhreflang=)[^>]*>/gi, ''); + const hreflangTags = (head.hreflangs ?? pageHreflangs(head.canonical, head.lang || 'en')) + .map((h) => ` `) + .join('\n'); + html = html.replace(/<\/head>/i, `${hreflangTags}\n `); // Escape any literal "" can't break out of the diff --git a/scripts/smoke-routes.mjs b/scripts/smoke-routes.mjs index 0384d1536..412b6a67c 100644 --- a/scripts/smoke-routes.mjs +++ b/scripts/smoke-routes.mjs @@ -9,6 +9,7 @@ import { checkComparisonGuide } from './check-comparison-guide.mjs'; import { checkReportTimes } from './check-report-times.mjs'; import { checkAdSlotDiagnostics } from './check-ad-slot-diagnostics.mjs'; import { assertNoGlobalFaq, checkBuiltFaqScope, checkFaqNavigation } from './check-faq-scope.mjs'; +import { checkBuiltHreflangs, checkHreflangNavigation } from './check-hreflang.mjs'; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const distDir = path.resolve(root, 'dist'); @@ -339,6 +340,7 @@ async function runProfile(browser, profile, manifest, expectedCrossingCount) { activeErrors = []; try { await checkFaqNavigation(page, baseUrl); + await checkHreflangNavigation(page, baseUrl); if (activeErrors.length) throw new Error(activeErrors.join(' | ')); } catch (error) { failures.push(`${profile.name} FAQ scope navigation: ${error.stack || error.message}`); @@ -350,6 +352,7 @@ async function runProfile(browser, profile, manifest, expectedCrossingCount) { async function main() { checkBuiltFaqScope(distDir); + checkBuiltHreflangs(distDir); const manifest = loadRouteManifest(); if (process.argv.includes('--growth-only')) { manifest.canonicals = []; diff --git a/scripts/test-hreflang.mjs b/scripts/test-hreflang.mjs new file mode 100644 index 000000000..55a2b0f52 --- /dev/null +++ b/scripts/test-hreflang.mjs @@ -0,0 +1,66 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { pageHreflangs, blogHreflangs } from '../src/lib/hreflang.js'; + +const base = 'https://borderpulse.com'; +test('single URL routes point to themselves without inventing translations', () => { + for (const route of ['/', '/crossing/san-ysidro/', '/best-time/san-ysidro/', '/compare/san-ysidro-vs-otay-mesa/']) { + assert.deepEqual(pageHreflangs(base + route), [{ lang: 'en', href: base + route }, { lang: 'x-default', href: base + route }]); + } +}); +test('only published EN/ES blog twins create reciprocal alternates', () => { + const en = { slug: 'english', lang: 'en', translationKey: 'guide' }; + const es = { slug: 'espanol', lang: 'es', translationKey: 'guide' }; + const expected = [{ lang: 'en', href: base + '/blog/english/' }, { lang: 'es', href: base + '/blog/espanol/' }, { lang: 'x-default', href: base + '/blog/english/' }]; + assert.deepEqual(blogHreflangs(en, [en, es]), expected); + assert.deepEqual(blogHreflangs(es, [en, es]), expected); + for (const post of [en, es, { slug: 'unpaired', lang: 'es' }]) { + assert.deepEqual(blogHreflangs(post, [post]), [{ lang: post.lang, href: `${base}/blog/${post.slug}/` }, { lang: 'x-default', href: `${base}/blog/${post.slug}/` }]); + } +}); +test('existing methodology and privacy twins remain reciprocal', () => { + for (const [en, es] of [['methodology', 'metodologia'], ['privacy', 'privacidad']]) { + assert.deepEqual(pageHreflangs(`${base}/${en}/`), pageHreflangs(`${base}/${es}/`, 'es')); + assert.equal(pageHreflangs(`${base}/${en}/`).length, 3); + } +}); + +test('SPA metadata replaces stale/duplicate hreflangs and preserves other alternate links', async () => { + const { updatePageMeta, resetPageMeta } = await import('../src/lib/seo.js'); + const nodes = []; + const create = (tag, attrs = {}) => { + const node = { + tag, attrs: { ...attrs }, + setAttribute(name, value) { this.attrs[name] = value; }, + remove() { nodes.splice(nodes.indexOf(this), 1); }, + }; + return node; + }; + const matches = (node, selector) => node.tag === selector.split('[')[0] && + [...selector.matchAll(/\[([^=\]]+)(?:="([^"]*)")?\]/g)].every(([, key, value]) => + value === undefined ? key in node.attrs : node.attrs[key] === value); + const previous = globalThis.document; + globalThis.document = { + documentElement: { lang: 'en' }, + head: { appendChild(node) { nodes.push(node); } }, + createElement: create, + querySelector: (selector) => nodes.find((node) => matches(node, selector)), + querySelectorAll: (selector) => nodes.filter((node) => matches(node, selector)), + }; + const rss = create('link', { rel: 'alternate', type: 'application/rss+xml', href: base + '/rss.xml' }); + nodes.push(rss, create('link', { rel: 'alternate', hreflang: 'en', href: base + '/' }), create('link', { rel: 'alternate', hreflang: 'en', href: base + '/' })); + const actual = () => nodes.filter((node) => 'hreflang' in node.attrs).map((node) => ({ lang: node.attrs.hreflang, href: node.attrs.href })); + try { + const canonical = base + '/crossing/san-ysidro/'; + updatePageMeta({ canonical }); + assert.deepEqual(actual(), pageHreflangs(canonical)); + assert.ok(nodes.includes(rss)); + updatePageMeta({ title: 'Partial update' }); + assert.deepEqual(actual(), pageHreflangs(canonical)); + updatePageMeta({ canonical: base + '/blog/example/', hreflangs: [] }); + assert.deepEqual(actual(), []); + resetPageMeta(); + assert.deepEqual(actual(), pageHreflangs(base + '/')); + assert.ok(nodes.includes(rss)); + } finally { globalThis.document = previous; } +}); diff --git a/src/lib/hreflang.js b/src/lib/hreflang.js new file mode 100644 index 000000000..178d7453c --- /dev/null +++ b/src/lib/hreflang.js @@ -0,0 +1,30 @@ +const BASE = 'https://borderpulse.com'; + +// Only these routes have separately crawlable translated pages. A language +// preference on a shared URL does not create a Spanish alternate URL. +export function pageHreflangs(canonical, lang = 'en') { + for (const [en, es] of [['methodology', 'metodologia'], ['privacy', 'privacidad']]) { + const enUrl = `${BASE}/${en}/`; + const esUrl = `${BASE}/${es}/`; + if (canonical === enUrl || canonical === esUrl) { + return [{ lang: 'en', href: enUrl }, { lang: 'es', href: esUrl }, { lang: 'x-default', href: enUrl }]; + } + } + return [{ lang, href: canonical }, { lang: 'x-default', href: canonical }]; +} + +export function blogHreflangs(post, posts) { + const canonical = `${BASE}/blog/${post.slug}/`; + if (post.translationKey) { + const en = posts.find((p) => p.translationKey === post.translationKey && p.lang === 'en'); + const es = posts.find((p) => p.translationKey === post.translationKey && p.lang === 'es'); + if (en && es) { + return [ + { lang: 'en', href: `${BASE}/blog/${en.slug}/` }, + { lang: 'es', href: `${BASE}/blog/${es.slug}/` }, + { lang: 'x-default', href: `${BASE}/blog/${en.slug}/` }, + ]; + } + } + return pageHreflangs(canonical, post.lang || 'en'); +} diff --git a/src/lib/seo.js b/src/lib/seo.js index 75a34a338..1f4c9479b 100644 --- a/src/lib/seo.js +++ b/src/lib/seo.js @@ -2,6 +2,18 @@ * seo.js — lightweight helpers to set tags from SPA routes. * No extra deps; manipulates head directly. */ +import { pageHreflangs } from './hreflang.js'; + +function setHreflangs(hreflangs) { + document.querySelectorAll('link[rel="alternate"][hreflang]').forEach((link) => link.remove()); + for (const { lang, href } of hreflangs) { + const link = document.createElement('link'); + link.setAttribute('rel', 'alternate'); + link.setAttribute('hreflang', lang); + link.setAttribute('href', href); + document.head.appendChild(link); + } +} function setMeta(name, content, attr = 'name') { if (!content) return; @@ -16,9 +28,10 @@ function setMeta(name, content, attr = 'name') { /** * Update page-level SEO tags. Call from useEffect in each page component. - * Pass null/undefined for any field to skip it. + * Pass null/undefined for a field to skip it. A canonical update also replaces + * language alternates; explicit hreflangs (including []) override the defaults. */ -export function updatePageMeta({ title, description, ogTitle, ogDescription, ogImage, ogUrl, canonical, lang }) { +export function updatePageMeta({ title, description, ogTitle, ogDescription, ogImage, ogUrl, canonical, lang, hreflangs }) { if (lang) document.documentElement.lang = lang; if (title) document.title = title; if (description) setMeta('description', description); @@ -34,6 +47,7 @@ export function updatePageMeta({ title, description, ogTitle, ogDescription, ogI document.head.appendChild(link); } link.setAttribute('href', canonical); + setHreflangs(hreflangs ?? pageHreflangs(canonical, lang || 'en')); } } @@ -41,6 +55,7 @@ export function updatePageMeta({ title, description, ogTitle, ogDescription, ogI * Reset meta tags to defaults (call on unmount or route change). */ export function resetPageMeta() { + setHreflangs(pageHreflangs('https://borderpulse.com/')); document.documentElement.lang = 'en'; document.title = 'Border Pulse | Real-Time US-Mexico Border Crossing Wait Times'; setMeta('description', 'Live wait times at US-Mexico border crossings. Official CBP data, refreshed regularly. Bilingual EN/ES.'); diff --git a/src/pages/BlogPost.jsx b/src/pages/BlogPost.jsx index bd6faa516..41e5e4453 100644 --- a/src/pages/BlogPost.jsx +++ b/src/pages/BlogPost.jsx @@ -8,6 +8,7 @@ import { mdxComponents } from '@/components/blog/MdxComponents'; import { LangContext } from '@/lib/LangContext'; import { updatePageMeta, resetPageMeta } from '@/lib/seo'; import { track } from '@/lib/analytics'; +import { blogHreflangs } from '@/lib/hreflang'; const STRINGS = { en: { @@ -74,7 +75,10 @@ export default function BlogPost() { const url = `https://borderpulse.com/blog/${slug}/`; const imagePath = fm.ogImage || fm.hero; const ogImg = imagePath ? `https://borderpulse.com${imagePath}` : undefined; - updatePageMeta({ title, description: desc, ogTitle: title, ogDescription: desc, ogUrl: url, ogImage: ogImg, canonical: url, lang: fm.lang || 'en' }); + const publishedPosts = [...listPosts({ lang: 'en' }), ...listPosts({ lang: 'es' })] + .map((p) => ({ ...p.frontmatter, slug: p.slug })); + const hreflangs = blogHreflangs({ ...fm, slug }, publishedPosts); + updatePageMeta({ title, description: desc, ogTitle: title, ogDescription: desc, ogUrl: url, ogImage: ogImg, canonical: url, lang: fm.lang || 'en', hreflangs }); return () => resetPageMeta(); }, [post, slug]);