Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down
16 changes: 11 additions & 5 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
137 changes: 137 additions & 0 deletions scripts/check-document-language.mjs
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
14 changes: 14 additions & 0 deletions scripts/smoke-routes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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(() => {});
Expand Down
8 changes: 8 additions & 0 deletions scripts/test-hreflang.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
});
2 changes: 1 addition & 1 deletion src/Layout.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
Expand Down
3 changes: 2 additions & 1 deletion src/lib/seo.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
2 changes: 1 addition & 1 deletion src/pages/Privacy.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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]);

Expand Down
Loading