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
66 changes: 48 additions & 18 deletions web/e2e/blog.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ test.describe('@smoke blog', () => {
// The editor syncs the URL to its own mode on every render. If that sync
// ever stops excluding the blog, the page will be right and the address
// will say /app/basic — which is the link a reader copies.
await expect(page).toHaveURL(new RegExp(`/blog/${SLUG}$`));
await expect(page).toHaveURL(new RegExp(`/blog/${SLUG}/$`));
await expect(page).toHaveTitle(/— Stabileo$/);
});

Expand All @@ -71,7 +71,30 @@ test.describe('@smoke blog', () => {
await boot(page, `/?route=%2Fblog%2F${SLUG}`);

await expect(page.locator('.post-title')).toBeVisible();
await expect(page).toHaveURL(new RegExp(`/blog/${SLUG}$`));
/*
* No trailing slash required here, and that is deliberate. This route
* carries no language prefix — it is the shape links handed out before
* the prefixes existed — and App.svelte restores those verbatim rather
* than rewriting somebody's saved address. A PREFIXED route is
* normalised through publicHref; see the case below.
*/
await expect(page).toHaveURL(new RegExp(`/blog/${SLUG}/?$`));

/*
* The same handoff WITH a language prefix — the form the site published
* before the slashes and that people already hold. That one IS normalised
* through publicHref, so the address bar and the page's own canonical do
* not disagree.
*
* Folded into this test rather than given its own, on purpose: one browser
* runs all 327 e2e cases with `workers: 1`, and it wedges near the end with
* `browser.newContext: Test ended`. Every added case brings that forward.
* Two navigations in one context cost nothing; a second test costs a
* context. See the note in .github/workflows/ci.yml.
*/
await page.goto(`/?route=%2Fes%2Fblog%2F${SLUG}`);
await expect(page.locator('.post-title')).toBeVisible();
await expect(page).toHaveURL(new RegExp(`/es/blog/${SLUG}/$`));
});

test('the browser back button returns to the post', async ({ page }) => {
Expand Down Expand Up @@ -140,7 +163,7 @@ test.describe('@smoke blog', () => {
await page.locator('.landing.blog select.nav-lang').selectOption(to);

await expect(page.locator('.post-title')).toHaveText(TITLES[to]);
await expect(page).toHaveURL(new RegExp(`/${to}/blog/${SLUG}$`));
await expect(page).toHaveURL(new RegExp(`/${to}/blog/${SLUG}/$`));
await expect(page.locator('html')).toHaveAttribute('lang', to);
// The picker reports where you are, not where you were.
await expect(page.locator('.landing.blog select.nav-lang')).toHaveValue(to);
Expand All @@ -157,19 +180,26 @@ test.describe('@smoke blog', () => {

await page.locator('.landing.blog select.nav-lang').selectOption('en');

await expect(page).toHaveURL(/\/en\/blog$/);
await expect(page).toHaveURL(/\/en\/blog\/$/);
await expect(lead).toContainText(/code checks/i);
await expect(page.locator(`.post-card[data-slug="${SLUG}"] .post-card-title`)).toHaveText(TITLES.en);
});

test('the browser back button undoes a language switch', async ({ page }) => {
await boot(page, `/pt/blog/${SLUG}`);
/*
* Booted on the slashed address on purpose. In production the host 301s
* `/pt/blog/x` to it before the application loads, so that is the URL a
* reader is ever on; `vite preview` serves both, so booting unslashed
* here would test a state the site cannot actually be in — and then
* `goBack` would return to it.
*/
await boot(page, `/pt/blog/${SLUG}/`);
await page.locator('.landing.blog select.nav-lang').selectOption('es');
await expect(page).toHaveURL(new RegExp(`/es/blog/${SLUG}$`));
await expect(page).toHaveURL(new RegExp(`/es/blog/${SLUG}/$`));

await page.goBack();

await expect(page).toHaveURL(new RegExp(`/pt/blog/${SLUG}$`));
await expect(page).toHaveURL(new RegExp(`/pt/blog/${SLUG}/$`));
await expect(page.locator('.post-title')).toHaveText(TITLES.pt);
});

Expand Down Expand Up @@ -348,7 +378,7 @@ test.describe('@smoke blog', () => {
await expect(back).toBeVisible();
await expect(back).toHaveText('Blog');
// A real href, so it is crawlable and middle-clickable, not a button.
await expect(back).toHaveAttribute('href', '/es/blog');
await expect(back).toHaveAttribute('href', '/es/blog/');
// And it sits before the GitHub box, where the ask put it.
const order = await page.locator('.landing.blog .nav-actions > *').evaluateAll((els) =>
els.map((e) => e.className.toString().split(' ')[0]),
Expand All @@ -357,7 +387,7 @@ test.describe('@smoke blog', () => {
expect(order[1]).toBe('nav-gh');

await back.click();
await expect(page).toHaveURL(/\/es\/blog$/);
await expect(page).toHaveURL(/\/es\/blog\/$/);
await expect(page.locator('.post-card')).not.toHaveCount(0);
// Now on the index, it is gone.
await expect(page.locator('.landing.blog .nav-blog-link')).toHaveCount(0);
Expand All @@ -376,7 +406,7 @@ test.describe('@smoke blog', () => {
expect(data.datePublished).toMatch(/^\d{4}-\d{2}-\d{2}$/);
expect(data.author.map((a: { name: string }) => a.name)).toContain('Bautista Chesta');
// It must claim the address it is actually served at, in this language.
expect(data.url).toBe(`https://stabileo.com/es/blog/${SLUG}`);
expect(data.url).toBe(`https://stabileo.com/es/blog/${SLUG}/`);
expect(data.mainEntityOfPage['@id']).toBe(data.url);
});

Expand All @@ -388,28 +418,28 @@ test.describe('@smoke blog', () => {
await expect(page.locator('script[type="application/ld+json"]')).toHaveCount(0);
await expect(page.locator('link[rel="canonical"]')).toHaveAttribute(
'href',
'https://stabileo.com/es/blog',
'https://stabileo.com/es/blog/',
);

await boot(page, `/pt/blog/${SLUG}`);
await expect(page.locator('link[rel="canonical"]')).toHaveAttribute(
'href',
`https://stabileo.com/pt/blog/${SLUG}`,
`https://stabileo.com/pt/blog/${SLUG}/`,
);
// And it points at its siblings, which is how they get discovered at all.
const alts = await page
.locator('link[rel="alternate"][hreflang]')
.evaluateAll((els) => els.map((e) => `${e.getAttribute('hreflang')} ${e.getAttribute('href')}`).sort());
expect(alts).toEqual([
`en https://stabileo.com/en/blog/${SLUG}`,
`es https://stabileo.com/es/blog/${SLUG}`,
`pt https://stabileo.com/pt/blog/${SLUG}`,
`en https://stabileo.com/en/blog/${SLUG}/`,
`es https://stabileo.com/es/blog/${SLUG}/`,
`pt https://stabileo.com/pt/blog/${SLUG}/`,
// x-default names the English version of THIS POST. English because the
// root declares /en as its canonical, so pointing the default at the
// root would name a URL that is not canonical for itself — and this
// post's path because a default that jumps to the home page sends every
// unmatched reader away from the thing they were about to be shown.
`x-default https://stabileo.com/en/blog/${SLUG}`,
`x-default https://stabileo.com/en/blog/${SLUG}/`,
]);
});

Expand Down Expand Up @@ -459,7 +489,7 @@ test.describe('@smoke blog', () => {
await expect(page.locator('.landing .hero-ctas .hero-blog')).toHaveCount(0);

await link.click();
await expect(page).toHaveURL(/\/blog$/);
await expect(page).toHaveURL(/\/blog\/$/);
await expect(page.locator('.post-card')).not.toHaveCount(0);
});

Expand All @@ -471,7 +501,7 @@ test.describe('@smoke blog', () => {
await expect(section).toBeVisible();

await section.locator('.btn-primary').click();
await expect(page).toHaveURL(/\/blog$/);
await expect(page).toHaveURL(/\/blog\/$/);
await expect(page.locator('.post-card')).not.toHaveCount(0);
});

Expand Down
10 changes: 5 additions & 5 deletions web/e2e/landing.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1079,7 +1079,7 @@ test.describe('@landing landing page', () => {
expect(h.lang).toBe('en');
// The root serves English and consolidates into /en rather than
// competing with it: two URLs, one indexed page.
expect(h.canonicals).toEqual(['https://stabileo.com/en']);
expect(h.canonicals).toEqual(['https://stabileo.com/en/']);

/*
* The description is the hero's own lead, not the fallback string
Expand All @@ -1093,7 +1093,7 @@ test.describe('@landing landing page', () => {
// og:url names the page, and the page here is /en — the same address
// the canonical above declares. Sharing the root should produce a card
// for the English landing, not for a doorway.
expect(one(h, 'og:url')).toBe('https://stabileo.com/en');
expect(one(h, 'og:url')).toBe('https://stabileo.com/en/');
expect(one(h, 'og:site_name')).toBe('Stabileo');
expect(one(h, 'og:locale')).toBe('en_US');
// One tag per alternate language, which is how Open Graph reads them.
Expand Down Expand Up @@ -1134,7 +1134,7 @@ test.describe('@landing landing page', () => {
}
// One canonical, and it names the language of the page rather than the
// bare root — the root is a doorway, /en is the page.
expect(h.canonicals).toEqual(['https://stabileo.com/en']);
expect(h.canonicals).toEqual(['https://stabileo.com/en/']);
expect(h.title).toBe(EN_TITLE);
expect(one(h, 'og:title')).toBe(EN_TITLE);
// The description sharpens to the live hero copy.
Expand All @@ -1154,7 +1154,7 @@ test.describe('@landing landing page', () => {
expect(one(h, 'og:title')).toBe(h.title);
expect(one(h, 'og:locale')).toBe('es_AR');
expect(h.meta['og:locale:alternate']).toEqual(['en_US', 'pt_BR']);
expect(h.canonicals).toEqual(['https://stabileo.com/es']);
expect(h.canonicals).toEqual(['https://stabileo.com/es/']);
// The description is the hero lead, which now carries the positioning:
// a free, open platform with three modes, rather than live re-solving.
expect(one(h, 'description')).toMatch(/plataforma gratuita y abierta/);
Expand All @@ -1177,7 +1177,7 @@ test.describe('@landing landing page', () => {
expect(one(h, 'og:title')).toBe(h.title);
expect(one(h, 'og:locale')).toBe('pt_BR');
expect(h.meta['og:locale:alternate']).toEqual(['en_US', 'es_AR']);
expect(h.canonicals).toEqual(['https://stabileo.com/pt']);
expect(h.canonicals).toEqual(['https://stabileo.com/pt/']);
expect(one(h, 'twitter:description')).toBe(one(h, 'description'));
});

Expand Down
14 changes: 8 additions & 6 deletions web/e2e/prerender.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,9 @@
}

async function expectLinks(page: Page, path: string, locale: Locale) {
const canonicalPath = path === '/' ? '' : path;
// Trailing slash: the address the host serves, and now the one the page
// declares. See publicHref in src/lib/i18n/public-routes.ts.
const canonicalPath = `${path === '/' ? '' : path}/`;
await expect(page.locator('link[rel="canonical"]')).toHaveAttribute(
'href',
`${ORIGIN}/${locale}${canonicalPath}`,
Expand Down Expand Up @@ -171,7 +173,7 @@
const data = JSON.parse(raw!);
expect(data['@type']).toBe('BlogPosting');
expect(data.inLanguage).toBe(locale);
expect(data.url).toBe(`${ORIGIN}/${locale}/blog/${SLUG}`);
expect(data.url).toBe(`${ORIGIN}/${locale}/blog/${SLUG}/`);
} finally {
await close();
}
Expand All @@ -190,12 +192,12 @@
const hrefs = await page
.locator('#prerender a[href^="/es"]')
.evaluateAll((els) => els.map((e) => e.getAttribute('href') ?? ''));
expect(hrefs, 'the landing must link to the blog index').toContain('/es/blog');
expect(hrefs, 'the landing must link to the blog index').toContain('/es/blog/');
// Named by shape rather than by slug: the section previews whatever is
// newest, so pinning a slug here would fail on the next post rather than
// on a broken link.
expect(
hrefs.filter((h) => /^\/es\/blog\/[^/]+$/.test(h)),
hrefs.filter((h) => /^\/es\/blog\/[^/]+\/$/.test(h)),
'and straight into a post, in the reader’s language',
).not.toHaveLength(0);
} finally {
Expand All @@ -210,7 +212,7 @@
try {
await expectPrerenderedShape(page);
await expect(page.locator('#prerender')).toContainText(HERO.en);
await expect(page.locator('link[rel="canonical"]')).toHaveAttribute('href', `${ORIGIN}/en`);
await expect(page.locator('link[rel="canonical"]')).toHaveAttribute('href', `${ORIGIN}/en/`);
} finally {
await close();
}
Expand All @@ -231,7 +233,7 @@
try {
await page.goto('/?utm_source=review');
// Generous: this is the only test here that boots the application.
await expect(page).toHaveURL(/\/pt\?utm_source=review$/, { timeout: 30_000 });
await expect(page).toHaveURL(/\/pt\/\?utm_source=review$/, { timeout: 30_000 });
} finally {
await ctx.close();
}
Expand All @@ -240,7 +242,7 @@
test('the root does not hijack an application route bounced through 404.html', async ({ browser }) => {
// A shared model link arrives as /?route=… . Redirecting it to /pt would
// throw someone opening their own structure onto the marketing page.
const ctx = await browser.newContext({ locale: 'pt-BR' });

Check failure on line 245 in web/e2e/prerender.spec.ts

View workflow job for this annotation

GitHub Actions / e2e

[chromium] › e2e/prerender.spec.ts:242:3 › @smoke prerender › the root does not hijack an application route bounced through 404.html

1) [chromium] › e2e/prerender.spec.ts:242:3 › @smoke prerender › the root does not hijack an application route bounced through 404.html Error: browser.newContext: Test ended. Browser logs: [pid=4717][err] [0907/144053.460314:INFO:CONSOLE:2] "using deprecated parameters for the initialization function; pass a single object instead", source: http://127.0.0.1:6003/assets/dedaliano_engine-Dr4xRFVV.js (2) [pid=4717][err] [0907/144054.244386:INFO:CONSOLE:4311] "[solveCombinations3D parallel] Solve: 46 ms | Combine+envelope: 24 ms | Cases: 3 | Combos: 4 | Workers: 3", source: http://127.0.0.1:6003/assets/index-CPWVDWrz.js (4311) [pid=4717][err] [0907/144057.470646:INFO:CONSOLE:2] "using deprecated parameters for the initialization function; pass a single object instead", source: http://127.0.0.1:6003/assets/dedaliano_engine-Dr4xRFVV.js (2) [pid=4717][err] [0907/144058.069086:INFO:CONSOLE:4311] "[solveCombinations3D parallel] Solve: 25 ms | Combine+envelope: 21 ms | Cases: 3 | Combos: 4 | Workers: 3", source: http://127.0.0.1:6003/assets/index-CPWVDWrz.js (4311) [pid=4717][err] [0907/144102.881034:INFO:CONSOLE:2] "using deprecated parameters for the initialization function; pass a single object instead", source: http://127.0.0.1:6003/assets/dedaliano_engine-Dr4xRFVV.js (2) [pid=4717][err] [0907/144103.442416:INFO:CONSOLE:4311] "[solveCombinations3D parallel] Solve: 25 ms | Combine+envelope: 13 ms | Cases: 3 | Combos: 4 | Workers: 3", source: http://127.0.0.1:6003/assets/index-CPWVDWrz.js (4311) [pid=4717][err] [0907/144108.492545:INFO:CONSOLE:2] "using deprecated parameters for the initialization function; pass a single object instead", source: http://127.0.0.1:6003/assets/dedaliano_engine-Dr4xRFVV.js (2) [pid=4717][err] [0907/144109.059733:INFO:CONSOLE:4311] "[solveCombinations3D parallel] Solve: 25 ms | Combine+envelope: 16 ms | Cases: 3 | Combos: 4 | Workers: 3", source: http://127.0.0.1:6003/assets/index-CPWVDWrz.js (4311) [pid=4717][err] [0907/144114.026367:INFO:CONSOLE:2] "using deprecated parameters for the initialization function; pass a single object instead", source: http://127.0.0.1:6003/assets/dedaliano_engine-Dr4xRFVV.js (2) [pid=4717][err] [0907/144114.536369:INFO:CONSOLE:4311] "[solveCombinations3D parallel] Solve: 25 ms | Combine+envelope: 14 ms | Cases: 3 | Combos: 4 | Workers: 3", source: http://127.0.0.1:6003/assets/index-CPWVDWrz.js (4311) [pid=4717][err] [0907/144119.398226:INFO:CONSOLE:2] "using deprecated parameters for the initialization function; pass a single object instead", source: http://127.0.0.1:6003/assets/dedaliano_engine-Dr4xRFVV.js (2) [pid=4717][err] [0907/144120.045964:INFO:CONSOLE:4311] "[solveCombinations3D parallel] Solve: 33 ms | Combine+envelope: 22 ms | Cases: 3 | Combos: 4 | Workers: 3", source: http://127.0.0.1:6003/assets/index-CPWVDWrz.js (4311) [pid=4717][err] [0907/144124.806069:INFO:CONSOLE:2] "using deprecated parameters for the initialization function; pass a single object instead", source: http://127.0.0.1:6003/assets/dedaliano_engine-Dr4xRFVV.js (2) [pid=4717][err] [0907/144125.369302:INFO:CONSOLE:4311] "[solveCombinations3D parallel] Solve: 24 ms | Combine+envelope: 12 ms | Cases: 3 | Combos: 4 | Workers: 3", source: http://127.0.0.1:6003/assets/index-CPWVDWrz.js (4311) [pid=4717][err] [0907/144130.389435:INFO:CONSOLE:2] "using deprecated parameters for the initialization function; pass a single object instead", source: http://127.0.0.1:6003/assets/dedaliano_engine-Dr4xRFVV.js (2) [pid=4717][err] [0907/144130.947326:INFO:CONSOLE:4311] "[solveCombinations3D parallel] Solve: 24 ms | Combine+envelope: 14 ms | Cases: 3 | Combos: 4 | Workers: 3", source: http://127.0.0.1:6003/assets/index-CPWVDWrz.js (4311) [pid=4717][err] [0907/144135.822425:INFO:CONSOLE:2] "using deprecated parameters for the initialization function; pass a single object instead", source: http://127.0.0.1:6003/assets/dedaliano_engine-Dr4xRFVV.js (2) [pid=4717][err] [0907/144136.467875:INFO:CONSOLE:4311] "[solveCombinations
const page = await ctx.newPage();
try {
await page.goto('/?route=%2Fapp%2Fbasic');
Expand Down
16 changes: 8 additions & 8 deletions web/scripts/__tests__/root-handoff.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,10 @@ function handoff(

describe('the root language handoff', () => {
it('sends a browser to the first language it asks for that we offer', () => {
expect(handoff(['es-AR'])).toBe('/es');
expect(handoff(['pt-BR', 'en-US'])).toBe('/pt');
expect(handoff(['es-AR'])).toBe('/es/');
expect(handoff(['pt-BR', 'en-US'])).toBe('/pt/');
// Not the first language, the first OFFERED one.
expect(handoff(['de-DE', 'es-AR'])).toBe('/es');
expect(handoff(['de-DE', 'es-AR'])).toBe('/es/');
});

it('leaves a browser alone when the fallback is what it wanted', () => {
Expand All @@ -55,10 +55,10 @@ describe('the root language handoff', () => {
// root lost its attribution, and only for the languages the prefixes
// exist to serve.
expect(handoff(['pt-BR'], { search: '?utm_source=x&utm_campaign=y' })).toBe(
'/pt?utm_source=x&utm_campaign=y',
'/pt/?utm_source=x&utm_campaign=y',
);
expect(handoff(['es-AR'], { hash: '#top' })).toBe('/es#top');
expect(handoff(['es-AR'], { search: '?a=1', hash: '#b' })).toBe('/es?a=1#b');
expect(handoff(['es-AR'], { hash: '#top' })).toBe('/es/#top');
expect(handoff(['es-AR'], { search: '?a=1', hash: '#b' })).toBe('/es/?a=1#b');
});

it('does not touch a route bounced through 404.html', () => {
Expand All @@ -82,7 +82,7 @@ describe('the root language handoff', () => {
navigator: { language: 'es-ES' },
});
vm.runInContext(rootHandoffScript(OFFERED, FALLBACK), context);
expect(replaced).toBe('/es');
expect(replaced).toBe('/es/');
});

it('is built from the list it is given, not from hardcoded languages', () => {
Expand All @@ -96,6 +96,6 @@ describe('the root language handoff', () => {
navigator: { languages: ['fr-FR'], language: 'fr-FR' },
});
vm.runInContext(rootHandoffScript([...OFFERED, 'fr'], FALLBACK), context);
expect(replaced).toBe('/fr');
expect(replaced).toBe('/fr/');
});
});
34 changes: 27 additions & 7 deletions web/scripts/prerender.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,24 @@ import { chromium, type Browser } from 'playwright';
import { createServer } from 'node:http';
import { readFile, writeFile, mkdir } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { join, dirname, extname } from 'node:path';
import { join, dirname, extname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { rootHandoffScript } from './root-handoff';

const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const DIST = join(ROOT, 'dist');
/**
* Where the prerendered site is written, and where the static server serves from.
*
* `dist/` for a real build. `PRERENDER_OUT` overrides it so a test can point this
* at a throwaway directory and check the published URLs against a build it made
* itself. The alternative — reading a checked-out `dist/` — proves less and costs
* more: it is gitignored, is rewritten by `build`, `build:only` and Playwright's
* `VITE_E2E=1` webServer alike, and is absent on a fresh clone. A gate that reads
* it either asserts against whichever build ran last, or skips itself.
*/
const DIST = process.env.PRERENDER_OUT
? resolve(process.env.PRERENDER_OUT)
: join(ROOT, 'dist');
const ORIGIN = 'https://stabileo.com';
const LOCALES = ['en', 'es', 'pt'] as const;
/**
Expand Down Expand Up @@ -155,7 +167,9 @@ async function capture(browser: Browser, base: string, locale: Locale, path: str
}
}, locale);

const href = path === '/' ? `/${locale}` : `/${locale}${path}`;
// Visited in the same slashed form the site publishes, so the capture
// happens on the address a reader actually lands on.
const href = path === '/' ? `/${locale}/` : `/${locale}${path}/`;
await page.goto(base + href, { waitUntil: 'networkidle' });
// The landing reveals sections on scroll; a capture of the top of the page
// would ship most of the copy inside elements that are still transparent.
Expand Down Expand Up @@ -212,7 +226,10 @@ ${extraScript ? `<script>${extraScript}</script>` : ''}

async function main() {
if (!existsSync(join(DIST, 'index.html'))) {
throw new Error('prerender: dist/index.html is missing — run the build first');
// Names the directory it actually looked in: with PRERENDER_OUT set that is
// not `dist/`, and a message that says otherwise sends the reader hunting
// through a tree this run never touched.
throw new Error(`prerender: ${join(DIST, 'index.html')} is missing — run the build first`);
}
await assertLocalesMatchTheApp();
/*
Expand Down Expand Up @@ -251,17 +268,20 @@ async function main() {
`<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">\n` +
LOCALES.flatMap((locale) =>
paths.map((path) => {
const loc = `${ORIGIN}/${locale}${path === '/' ? '' : path}`;
// Trailing slash, matching the canonical the page declares — see
// publicHref in src/lib/i18n/public-routes.ts. Without it every
// entry in this file was a URL that answers 301.
const loc = `${ORIGIN}/${locale}${path === '/' ? '' : path}/`;
const alts = LOCALES.map(
(l) =>
` <xhtml:link rel="alternate" hreflang="${l}" href="${ORIGIN}/${l}${path === '/' ? '' : path}"/>`,
` <xhtml:link rel="alternate" hreflang="${l}" href="${ORIGIN}/${l}${path === '/' ? '' : path}/"/>`,
).join('\n');
// x-default follows the page, exactly as it does in the pages'
// own <head> — see alternateUrls() in src/lib/i18n/public-routes.ts.
// This was `${ORIGIN}/${DEFAULT_LOCALE}` for every entry, which
// pointed the default of every URL in the file at the English home
// page instead of at the page declaring it.
const xDefault = `${ORIGIN}/${DEFAULT_LOCALE}${path === '/' ? '' : path}`;
const xDefault = `${ORIGIN}/${DEFAULT_LOCALE}${path === '/' ? '' : path}/`;
return ` <url>\n <loc>${loc}</loc>\n <lastmod>${TODAY}</lastmod>\n${alts}\n <xhtml:link rel="alternate" hreflang="x-default" href="${xDefault}"/>\n </url>`;
}),
).join('\n') +
Expand Down
5 changes: 4 additions & 1 deletion web/scripts/root-handoff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,10 @@ export function rootHandoffScript(offered: readonly string[], fallback: string):
if (offered.indexOf(code) !== -1) { want = code; break; }
}
if (want !== ${JSON.stringify(fallback)}) {
location.replace('/' + want + location.search + location.hash);
// Trailing slash: '/es' is a directory and the host answers it with a
// 301 to '/es/'. Without it a Spanish browser paid two redirects to
// reach the landing, and the second one was ours to avoid.
location.replace('/' + want + '/' + location.search + location.hash);
}
})();
`.trim();
Expand Down
Loading
Loading