diff --git a/CHANGELOG.md b/CHANGELOG.md index a321d9f7..bf0e8ffc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,18 @@ # CHANGELOG -## Unreleased — bounded language metadata navigation checks +## 2026-10-13 — Document language ownership + +- Metadata cleanup preserves the document language during crossing data and + aggregate updates. Shared pages retain the browser's EN/ES preference; + translated article, methodology and privacy routes declare their own content + language. Privacy text, canonicals and language alternates are unchanged. +- Added a regression that fails on the previous English reset, plus local-only + desktop/mobile checks for delayed aggregates, feed refresh, switching, + Back/Forward and content-specific routes with contradictory preferences. + External providers and service workers stay blocked in browser checks. +- Full release checks and exact publication evidence belong to the scoped PR. + +## 2026-10-12 — Bounded language metadata navigation checks (PR130) - Bound language metadata predicate waits and the combined metadata fixture, with route diagnostics. A failed predicate now fails the release gate instead @@ -10,7 +22,7 @@ Existing cleanup can reset document language on a background data update; that separate lifecycle issue is outside this metadata-alternate correction. -## Unreleased — route-specific language alternates +## 2026-10-12 — Route-specific language alternates (PR129) - Generated crossing, best-time, comparison and other single-URL pages replace inherited homepage hreflangs with their own canonical URL. Existing published diff --git a/ROADMAP.md b/ROADMAP.md index b19090ce..9333e154 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -21,11 +21,17 @@ 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] Correct route hreflangs and bound metadata-navigation checks (PR129/130). + Production was verified on October 12 at `2b40cb2b`, including reciprocal + pairs and exact served assets. Those releases are complete; preserve them. +- [x] Preserve document language through metadata cleanup, data/aggregate + updates and navigation. Content-specific EN/ES routes keep their own language; + implementation checks and exact publication evidence belong to the scoped PR. +- [ ] Prioritize stable pedestrian-capability curation for walk-or-drive route + inventory. Sitemap and prerender currently share a predicate based on lane + objects in each snapshot, so the inventory fluctuates as reports change. + Keep this separate from document-language ownership; do not invent capability + or expand routes without source evidence. - [x] Separate CBP lane report times from snapshot retrieval on dashboard and crossing detail cards. Preserve raw report text and unknown dates; release diff --git a/scripts/check-document-language.mjs b/scripts/check-document-language.mjs new file mode 100644 index 00000000..94b24d33 --- /dev/null +++ b/scripts/check-document-language.mjs @@ -0,0 +1,137 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; + +const crossingPath = '/crossing/san-ysidro/'; +const enArticle = '/blog/san-ysidro-vs-otay-mesa/'; +const esArticle = '/blog/san-ysidro-vs-otay-cual-es-mas-rapida/'; +const keys = ['borderPulse_language', 'borderPulse_region', 'borderPulse_theme', 'borderPulse_geoConsent', 'borderPulse_waitHistory_v1']; + +// Run only against a local preview. Every data response is synthetic; the +// caller blocks external providers and service workers before opening a page. +export async function checkDocumentLanguage(page, context, baseUrl) { + const origin = new URL(baseUrl).origin; + assert.ok(['127.0.0.1', 'localhost'].includes(new URL(baseUrl).hostname), 'language fixture requires a local preview'); + const saved = await page.evaluate(names => Object.fromEntries(names.map(name => [name, localStorage.getItem(name)])), keys); + const snapshot = JSON.parse(fs.readFileSync(new URL('../public/data/crossings.json', import.meta.url), 'utf8')); + const crossing = snapshot.crossings.find(row => row.port_number === '250401'); + assert.ok(crossing, 'San Ysidro source row must exist'); + const aggregate = JSON.parse(fs.readFileSync(new URL('../public/data/aggregates/san-ysidro.json', import.meta.url), 'utf8')); + assert.ok(aggregate.by_hour?.length, 'delayed aggregate must contain a historical pattern'); + let releaseAggregate; + const aggregateGate = new Promise(resolve => { releaseAggregate = resolve; }); + let aggregateRequests = 0; + let feedRequests = 0; + const feedMatch = url => url.origin === origin && ['/data/crossings.json', '/api/public/crossings'].includes(url.pathname); + const aggregateMatch = url => url.origin === origin && url.pathname === '/data/aggregates/san-ysidro.json'; + const feedHandler = route => { + feedRequests++; + snapshot.fetched_at = new Date().toISOString(); + return route.fulfill({ contentType: 'application/json', body: JSON.stringify(snapshot) }); + }; + const aggregateHandler = async route => { + aggregateRequests++; + await aggregateGate; + await route.fulfill({ contentType: 'application/json', body: JSON.stringify(aggregate) }); + }; + const inspect = async (route, lang) => { + console.log(`[document-language] ${route} expected ${lang}`); + await page.waitForURL(url => url.pathname === route, { timeout: 5_000 }); + try { + await page.waitForFunction(({ route, lang }) => document.documentElement.lang === lang && + document.querySelector('link[rel="canonical"]')?.href === `https://borderpulse.com${route}`, + { route, lang }, { timeout: 2_000 }); + } catch (error) { + const actual = await page.evaluate(() => ({ lang: document.documentElement.lang, + canonical: document.querySelector('link[rel="canonical"]')?.href })); + throw new Error(`${route}: expected ${lang}; observed ${JSON.stringify(actual)}`, { cause: error }); + } + assert.equal(await page.locator('html').getAttribute('lang'), lang); + }; + const click = route => page.locator(`#root a[href="${route}"]:visible`).first().click(); + const switchTo = lang => page.getByRole('button', { + name: lang === 'es' ? 'Cambiar a español' : 'Switch to English', exact: true, + }).filter({ visible: true }).click(); + page.setDefaultTimeout(5_000); + await context.route(feedMatch, feedHandler); + await context.route(aggregateMatch, aggregateHandler); + try { + await page.evaluate(() => { + localStorage.setItem('borderPulse_language', 'es'); + localStorage.setItem('borderPulse_region', 'ALL'); + localStorage.setItem('borderPulse_geoConsent', 'denied'); + }); + await page.goto(new URL(crossingPath, baseUrl).href); + await page.locator('#root').getByRole('link', { name: 'Todos los cruces', exact: true }).waitFor(); + await inspect(crossingPath, 'es'); + assert.ok(aggregateRequests > 0, 'crossing must request the held aggregate'); + releaseAggregate(); + await page.waitForFunction(() => document.querySelector('meta[name="description"]')?.content.includes('tendencias históricas'), null, { timeout: 5_000 }); + await inspect(crossingPath, 'es'); + + crossing.port_status = 'Open'; + crossing.current_wait_time = 51; + crossing.lanes.passenger_standard = { status: 'delay', delay_minutes: 51, lanes_open: 1, update_time: 'At 11:00 am PST' }; + await Promise.all([ + page.waitForResponse(response => feedMatch(new URL(response.url())), { timeout: 5_000 }), + page.evaluate(() => window.dispatchEvent(new Event('online'))), + ]); + await page.locator('[data-testid="border-crossing-card"][data-port-number="250401"]').getByText('51', { exact: true }).first().waitFor(); + await inspect(crossingPath, 'es'); + await switchTo('en'); + await inspect(crossingPath, 'en'); + await switchTo('es'); + await inspect(crossingPath, 'es'); + await click('/best-time/san-ysidro/'); + await inspect('/best-time/san-ysidro/', 'es'); + await page.goBack(); + await inspect(crossingPath, 'es'); + await page.goForward(); + await inspect('/best-time/san-ysidro/', 'es'); + await click('/'); + await inspect('/', 'es'); + const menu = page.getByRole('button', { name: 'Open menu', exact: true }); + if (await menu.isVisible()) await menu.click(); + await click('/blog/'); + await inspect('/blog/', 'es'); + // Fixed article content owns its language even with a Spanish UI preference. + await click(enArticle); + await page.locator('#root article[lang="en"]').waitFor(); + await inspect(enArticle, 'en'); + await page.locator('#root article').getByRole('link', { name: 'Español', exact: true }).click(); + await inspect(esArticle, 'es'); + await page.goBack(); + await inspect(enArticle, 'en'); + await page.goForward(); + await inspect(esArticle, 'es'); + await click('/'); + await inspect('/', 'es'); + // Explicit EN/ES routes must not inherit a contradictory UI preference. + for (const [route, lang] of [['/methodology/', 'en'], ['/metodologia/', 'es'], ['/privacy/', 'en'], ['/privacidad/', 'es']]) { + await page.goto(new URL(route, baseUrl).href); + await inspect(route, lang); + await switchTo(lang === 'en' ? 'es' : 'en'); + await inspect(route, lang); + await page.evaluate(() => { + localStorage.setItem('borderPulse_language', 'es'); + }); + } + assert.ok(feedRequests > 1, 'must exercise a genuine feed refresh'); + assert.ok(aggregateRequests > 0, 'must exercise delayed aggregate arrival'); + console.log('[document-language] PASS: delayed aggregate, feed refresh, switching, Back/Forward, shared pages and EN/ES content-owned pages'); + } finally { + releaseAggregate(); + try { + await page.goto(new URL('/methodology/', baseUrl).href); + await page.evaluate(values => { + for (const [name, value] of Object.entries(values)) { + if (value === null) localStorage.removeItem(name); + else localStorage.setItem(name, value); + } + }, saved); + } finally { + await context.unroute(feedMatch, feedHandler); + await context.unroute(aggregateMatch, aggregateHandler); + page.setDefaultTimeout(10_000); + } + } +} diff --git a/scripts/smoke-routes.mjs b/scripts/smoke-routes.mjs index 35576d72..93453733 100644 --- a/scripts/smoke-routes.mjs +++ b/scripts/smoke-routes.mjs @@ -10,6 +10,7 @@ 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'; +import { checkDocumentLanguage } from './check-document-language.mjs'; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const distDir = path.resolve(root, 'dist'); @@ -338,6 +339,19 @@ async function runProfile(browser, profile, manifest, expectedCrossingCount) { clearTimeout(reportDeadline); } activeErrors = []; + const languageDeadline = setTimeout(() => { + console.error(`[document-language] ${profile.name}: exceeded 60-second fixture deadline`); + page.close().catch(() => {}); + }, 60_000); + try { + await checkDocumentLanguage(page, context, baseUrl); + if (activeErrors.length) throw new Error(activeErrors.join(' | ')); + } catch (error) { + failures.push(`${profile.name} document language: ${error.stack || error.message}`); + } finally { + clearTimeout(languageDeadline); + } + activeErrors = []; const metadataDeadline = setTimeout(() => { console.error(`[metadata-navigation] ${profile.name}: exceeded 60-second fixture deadline`); page.close().catch(() => {}); diff --git a/scripts/test-hreflang.mjs b/scripts/test-hreflang.mjs index 55a2b0f5..6d9ca8e1 100644 --- a/scripts/test-hreflang.mjs +++ b/scripts/test-hreflang.mjs @@ -59,8 +59,16 @@ test('SPA metadata replaces stale/duplicate hreflangs and preserves other altern assert.deepEqual(actual(), pageHreflangs(canonical)); updatePageMeta({ canonical: base + '/blog/example/', hreflangs: [] }); assert.deepEqual(actual(), []); + document.documentElement.lang = 'es'; resetPageMeta(); + assert.equal(document.documentElement.lang, 'es', 'metadata cleanup must preserve the language selected by its owner'); assert.deepEqual(actual(), pageHreflangs(base + '/')); assert.ok(nodes.includes(rss)); + for (const lang of ['en', 'es']) { + updatePageMeta({ canonical: `${base}/blog/content-${lang}/`, lang }); + assert.equal(document.documentElement.lang, lang, 'explicit content language must still take ownership'); + resetPageMeta(); + assert.equal(document.documentElement.lang, lang, 'cleanup must not overwrite the content owner'); + } } finally { globalThis.document = previous; } }); diff --git a/src/Layout.jsx b/src/Layout.jsx index 26e98b8c..526617cd 100644 --- a/src/Layout.jsx +++ b/src/Layout.jsx @@ -33,7 +33,7 @@ export default function Layout({ children }) { useEffect(() => { const pageOwnsLanguage = /^\/blog\/[^/]+\/?$/.test(location.pathname) || - /^\/(methodology|metodologia)\/?$/.test(location.pathname); + /^\/(methodology|metodologia|privacy|privacidad)\/?$/.test(location.pathname); if (pageOwnsLanguage) return; document.documentElement.lang = language; }, [language, location.pathname]); diff --git a/src/lib/seo.js b/src/lib/seo.js index 1f4c9479..3f7b4063 100644 --- a/src/lib/seo.js +++ b/src/lib/seo.js @@ -53,10 +53,11 @@ export function updatePageMeta({ title, description, ogTitle, ogDescription, ogI /** * Reset meta tags to defaults (call on unmount or route change). + * Document language belongs to Layout or the content-specific page. Metadata + * effects also clean up during data updates, so they must not reset language. */ 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.'); setMeta('og:title', 'Border Pulse | Real-Time US-Mexico Border Crossing Wait Times', 'property'); diff --git a/src/pages/Privacy.jsx b/src/pages/Privacy.jsx index 04148a1c..68bdfa34 100644 --- a/src/pages/Privacy.jsx +++ b/src/pages/Privacy.jsx @@ -12,7 +12,7 @@ export default function Privacy({ lang = 'en' }) { const description = es ? 'Aviso sobre preferencias del navegador, analítica y anuncios de Adsterra en Border Pulse.' : 'Notice about browser preferences, analytics, and Adsterra advertising on Border Pulse.'; - updatePageMeta({ title, description, ogTitle: title, ogDescription: description, ogUrl: canonical, canonical }); + updatePageMeta({ title, description, ogTitle: title, ogDescription: description, ogUrl: canonical, canonical, lang: es ? 'es' : 'en' }); return () => resetPageMeta(); }, [es]);