mirror of
https://github.com/kevinveenbirkenbach/homepage.veen.world.git
synced 2026-08-24 13:14:32 +00:00
The interface ships translated; page content stays English until a LibreTranslate instance fills app/i18n/content/ through make i18n. A string without a catalogue entry falls back to its English source, so a half-filled catalogue degrades instead of breaking. Translation runs after ConfigurationResolver.resolve_links(), on a copy. resolve_links matches by the `name` field, so translating it beforehand would break every `link:` reference in the configuration. negotiate() normalises to the primary subtag itself. Werkzeug's best_match returns an exact match before it considers a primary-tag fallback, so the Chrome default `de-DE,en;q=0.8` resolves to English there. "/" carries Vary: Accept-Language, without which a shared cache pins the first visitor's language for everyone. The route rule lists the known codes as a converter argument. A bare "/<lang>/" answers /robots.txt and /favicon.ico with a permanently cacheable 308 to their trailing-slash form. Templates gain lang, dir, the RTL stylesheet, a canonical URL and 30 hreflang alternates. Those are the first external URLs in this app: ProxyFix takes the scheme from X-Forwarded-Proto so they do not claim http:// behind a TLS-terminating proxy, X-Forwarded-Host stays untrusted because nginx passes a client-supplied one through, and TRUSTED_HOSTS lets Flask reject a forged Host outright. Flask only autoescapes .html/.htm/.xml/.xhtml/.svg, so every *.html.j2 template interpolated configuration raw. Enabling it changes two lines of the shipped page, both an apostrophe. read_catalog degrades an unreadable catalogue to English rather than serving a 500, and drops non-string entries that would otherwise render as "42". i18n_sync writes atomically, never overwrites an existing entry, refuses to touch a catalogue it could not parse, and leaves the file alone when a run translated nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
229 lines
7.2 KiB
JavaScript
229 lines
7.2 KiB
JavaScript
// cypress/e2e/i18n.spec.js
|
|
|
|
const GERMAN_BROWSER = { headers: { 'Accept-Language': 'de-DE,de;q=0.9,en;q=0.8' } };
|
|
|
|
describe('Language negotiation', () => {
|
|
it('serves English to an English browser', () => {
|
|
cy.visit('/', { headers: { 'Accept-Language': 'en-US,en;q=0.9' } });
|
|
cy.get('html').should('have.attr', 'lang', 'en');
|
|
cy.get('footer.footer a.iframe-link').should('contain.text', 'Imprint');
|
|
});
|
|
|
|
it('serves German to a German browser', () => {
|
|
cy.visit('/', GERMAN_BROWSER);
|
|
cy.get('html').should('have.attr', 'lang', 'de');
|
|
cy.get('footer.footer a.iframe-link').should('contain.text', 'Impressum');
|
|
});
|
|
|
|
it('falls back to English for an unsupported browser language', () => {
|
|
cy.visit('/', { headers: { 'Accept-Language': 'xx-XX' } });
|
|
cy.get('html').should('have.attr', 'lang', 'en');
|
|
});
|
|
|
|
it('lets the URL prefix override the browser language', () => {
|
|
cy.visit('/fr/', GERMAN_BROWSER);
|
|
cy.get('html').should('have.attr', 'lang', 'fr');
|
|
cy.get('footer.footer a.iframe-link').should('contain.text', 'Mentions légales');
|
|
});
|
|
|
|
it('rejects an unsupported language code', () => {
|
|
cy.request({ url: '/xx/', failOnStatusCode: false })
|
|
.its('status')
|
|
.should('eq', 404);
|
|
});
|
|
|
|
it('does not turn unrelated single-segment paths into redirects', () => {
|
|
['/robots.txt', '/favicon.ico', '/sitemap.xml'].forEach(path => {
|
|
cy.request({ url: path, failOnStatusCode: false, followRedirect: false })
|
|
.its('status')
|
|
.should('eq', 404);
|
|
});
|
|
});
|
|
|
|
it('marks the negotiated route as varying by language', () => {
|
|
cy.request('/')
|
|
.its('headers.vary')
|
|
.should('contain', 'Accept-Language');
|
|
});
|
|
});
|
|
|
|
describe('Language switcher', () => {
|
|
beforeEach(() => {
|
|
cy.viewport(1280, 720);
|
|
cy.visit('/en/');
|
|
});
|
|
|
|
it('names the active language and offers all thirty', () => {
|
|
cy.get('#navbarDropdownLanguage')
|
|
.should('have.attr', 'data-bs-toggle', 'dropdown')
|
|
.and('contain.text', 'English');
|
|
|
|
cy.get('#navbarDropdownLanguage')
|
|
.parent('.nav-item')
|
|
.find('> .dropdown-menu a.dropdown-item')
|
|
.should('have.length', 30);
|
|
});
|
|
|
|
it('marks the active language', () => {
|
|
cy.get('#navbarDropdownLanguage').click();
|
|
cy.get('.dropdown-menu a.dropdown-item.active[hreflang="en"]').should('exist');
|
|
});
|
|
|
|
it('navigates to the chosen language', () => {
|
|
cy.get('#navbarDropdownLanguage').click();
|
|
cy.get('.dropdown-menu a.dropdown-item[hreflang="de"]')
|
|
.should('have.text', 'Deutsch')
|
|
.click();
|
|
|
|
cy.url().should('match', /\/de\/$/);
|
|
cy.get('html').should('have.attr', 'lang', 'de');
|
|
});
|
|
});
|
|
|
|
describe('Translated interface strings', () => {
|
|
it('translates the strings rendered by the templates', () => {
|
|
cy.visit('/de/');
|
|
cy.get('#dynamicCopyButton').should('have.text', 'Kopieren');
|
|
cy.get('#dynamicChildrenSection h6').should('have.text', 'Optionen:');
|
|
cy.get('#dynamicAlternativesSection h6').should('have.text', 'Alternativen:');
|
|
cy.get('.modal-footer button').should('have.text', 'Schließen');
|
|
});
|
|
|
|
it('exposes the catalogue to client-side code', () => {
|
|
cy.visit('/de/');
|
|
cy.window().its('I18N').should('deep.include', {
|
|
Open: 'Öffnen',
|
|
'Open Link': 'Link öffnen',
|
|
'Identifier copied to clipboard!': 'Kennung in die Zwischenablage kopiert!',
|
|
});
|
|
});
|
|
|
|
it('leaves the catalogue untranslated in the source language', () => {
|
|
cy.visit('/en/');
|
|
cy.window().its('I18N').should('deep.include', { Open: 'Open' });
|
|
});
|
|
});
|
|
|
|
describe('Strings translated by modal.js', () => {
|
|
const item = {
|
|
name: 'Test Item',
|
|
identifier: 'ABC123',
|
|
icon: { class: 'fa fa-test' },
|
|
alternatives: [
|
|
{ name: 'Alt One', identifier: 'ALT1', icon: { class: 'fa fa-alt1' } },
|
|
],
|
|
};
|
|
|
|
beforeEach(() => {
|
|
cy.visit('/de/');
|
|
cy.window().then(win => {
|
|
cy.stub(win.navigator.clipboard, 'writeText').resolves();
|
|
cy.stub(win, 'alert');
|
|
});
|
|
});
|
|
|
|
it('translates the button of a list entry', () => {
|
|
cy.window().invoke('openDynamicPopup', item);
|
|
cy.get('#dynamicAlternativesList button').should('have.text', 'Öffnen');
|
|
});
|
|
|
|
it('translates the link label when the entry has no description', () => {
|
|
cy.window().invoke('openDynamicPopup', {
|
|
...item,
|
|
url: 'https://example.com',
|
|
description: null,
|
|
});
|
|
cy.get('#dynamicModalLinkHref').should('have.text', 'Link öffnen');
|
|
});
|
|
|
|
it('translates the clipboard confirmation', () => {
|
|
cy.window().invoke('openDynamicPopup', item);
|
|
cy.get('#dynamicCopyButton').click();
|
|
cy.window()
|
|
.its('alert')
|
|
.should('have.been.calledWith', 'Kennung in die Zwischenablage kopiert!');
|
|
});
|
|
});
|
|
|
|
describe('Right-to-left languages', () => {
|
|
it('flips the document and loads the RTL stylesheet', () => {
|
|
cy.visit('/ar/');
|
|
cy.get('html').should('have.attr', 'dir', 'rtl');
|
|
cy.get('link[href*="bootstrap.rtl.min.css"]').should('exist');
|
|
cy.get('link[href*="vendor/bootstrap/css/bootstrap.min.css"]').should('not.exist');
|
|
cy.get('body').should('have.css', 'direction', 'rtl');
|
|
});
|
|
|
|
it('keeps left-to-right languages on the default stylesheet', () => {
|
|
cy.visit('/en/');
|
|
cy.get('html').should('have.attr', 'dir', 'ltr');
|
|
cy.get('link[href*="bootstrap.rtl.min.css"]').should('not.exist');
|
|
});
|
|
|
|
it('actually serves the RTL stylesheet it links to', () => {
|
|
cy.visit('/ar/');
|
|
cy.get('link[href*="bootstrap.rtl.min.css"]')
|
|
.should('have.attr', 'href')
|
|
.then(href => {
|
|
cy.request(href).its('status').should('eq', 200);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Translated interface details', () => {
|
|
it('translates the strings only a screen reader sees', () => {
|
|
cy.visit('/de/');
|
|
|
|
cy.get('#dynamicModal .btn-close').should('have.attr', 'aria-label', 'Schließen');
|
|
});
|
|
|
|
it('translates the alert headings', () => {
|
|
cy.visit('/fr/');
|
|
|
|
cy.get('#dynamicModalWarning h5').should('contain.text', 'Avertissement');
|
|
cy.get('#dynamicModalInfo h5').should('contain.text', 'Informations');
|
|
});
|
|
|
|
it('translates the language switcher tooltip', () => {
|
|
cy.visit('/de/');
|
|
|
|
cy.get('#navbarDropdownLanguage').should('have.attr', 'title', 'Sprache');
|
|
});
|
|
|
|
it('tags each switcher entry with its own language', () => {
|
|
cy.visit('/en/');
|
|
|
|
cy.get('.dropdown-menu a.dropdown-item[hreflang="ja"]').should(
|
|
'have.attr',
|
|
'lang',
|
|
'ja',
|
|
);
|
|
});
|
|
|
|
it('offers the switcher in the header only', () => {
|
|
cy.viewport(1280, 720);
|
|
cy.visit('/en/');
|
|
|
|
cy.get('#navbarNavheader #navbarDropdownLanguage').should('exist');
|
|
cy.get('#navbarNavfooter #navbarDropdownLanguage').should('not.exist');
|
|
});
|
|
});
|
|
|
|
describe('Search engine metadata', () => {
|
|
beforeEach(() => {
|
|
cy.visit('/de/');
|
|
});
|
|
|
|
it('declares an alternate for every language plus a default', () => {
|
|
cy.get('link[rel="alternate"][hreflang]').should('have.length', 31);
|
|
cy.get('link[rel="alternate"][hreflang="x-default"]').should('exist');
|
|
cy.get('link[rel="alternate"][hreflang="ja"]')
|
|
.should('have.attr', 'href')
|
|
.and('match', /\/ja\/$/);
|
|
});
|
|
|
|
it('points the canonical URL at the language actually served', () => {
|
|
cy.get('link[rel="canonical"]').should('have.attr', 'href').and('match', /\/de\/$/);
|
|
});
|
|
});
|