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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
6 changes: 6 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
119 changes: 119 additions & 0 deletions scripts/check-hreflang.mjs
Original file line number Diff line number Diff line change
@@ -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(/<link\b[^>]*>/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 (/<meta\s+http-equiv=["']refresh["']/i.test(html)) continue;
const relative = path.relative(dist, dir).split(path.sep).join('/');
const route = relative ? `/${relative}/` : '/';
const canonicalRoute = route.startsWith('/embed/') ? route.replace('/embed/', '/crossing/') : route;
const canonical = links(html).filter((a) => 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();
30 changes: 8 additions & 22 deletions scripts/prerender.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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, '..');
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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*<link\s+rel=["']alternate["']\s+hreflang=[^>]*>/gi, '');
const hreflangTags = head.hreflangs
.map((h) => ` <link rel="alternate" hreflang="${esc(h.lang)}" href="${esc(h.href)}" />`)
.join('\n');
html = html.replace(/<\/head>/i, `${hreflangTags}\n </head>`);
}
// Always replace shell alternates, including routes without translated URLs.
html = html.replace(/\s*<link\b(?=[^>]*\brel=["']alternate["'])(?=[^>]*\bhreflang=)[^>]*>/gi, '');
const hreflangTags = (head.hreflangs ?? pageHreflangs(head.canonical, head.lang || 'en'))
.map((h) => ` <link rel="alternate" hreflang="${esc(h.lang)}" href="${esc(h.href)}" />`)
.join('\n');
html = html.replace(/<\/head>/i, `${hreflangTags}\n </head>`);

// Escape any literal "</script" inside the JSON body so a value like
// "</script><script>alert(1)</script>" can't break out of the
Expand Down
3 changes: 3 additions & 0 deletions scripts/smoke-routes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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}`);
Expand All @@ -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 = [];
Expand Down
66 changes: 66 additions & 0 deletions scripts/test-hreflang.mjs
Original file line number Diff line number Diff line change
@@ -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; }
});
30 changes: 30 additions & 0 deletions src/lib/hreflang.js
Original file line number Diff line number Diff line change
@@ -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');
}
Loading
Loading