From fc5b12e76b19ffe687e8cbf6cd1b2b0c978198d8 Mon Sep 17 00:00:00 2001 From: PianoNic <79938743+PianoNic@users.noreply.github.com> Date: Wed, 24 Jun 2026 23:43:18 +0200 Subject: [PATCH] Add per-language prerendered URLs with hreflang for multilingual SEO --- .claude/CLAUDE.md | 10 ++ functions/index.js | 25 +++++ public/_redirects | 9 ++ public/i18n/de.json | 22 ++++ public/i18n/en.json | 22 ++++ public/i18n/fr.json | 22 ++++ public/i18n/it.json | 22 ++++ public/i18n/rm.json | 22 ++++ public/sitemap.xml | 48 -------- scripts/postbuild.ts | 105 ++++++++++++++++-- src/app/app.config.ts | 3 +- src/app/app.routes.server.ts | 10 +- src/app/app.routes.ts | 25 ++++- src/app/app.ts | 11 +- src/app/components/footer/footer.html | 12 +- src/app/components/footer/footer.ts | 10 +- src/app/components/navigation/navigation.html | 10 +- src/app/components/navigation/navigation.ts | 8 +- src/app/services/lang.resolver.ts | 21 ++++ src/app/services/language.ts | 86 +++++++------- src/app/services/seo.ts | 98 ++++++++++++++++ src/index.html | 7 +- 22 files changed, 473 insertions(+), 135 deletions(-) create mode 100644 functions/index.js delete mode 100644 public/sitemap.xml create mode 100644 src/app/services/lang.resolver.ts create mode 100644 src/app/services/seo.ts diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 28f9547..c1f3599 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -44,6 +44,16 @@ The `.github/workflows/build.yml` CI is independent - it only verifies the build Stack is plain Angular standalone components. Look in `src/app/`. SCSS, no Tailwind. FontAwesome via `@fortawesome/angular-fontawesome`. +## i18n / SEO + +Every page lives under a language prefix: `/en`, `/de`, `/fr`, `/it`, `/rm` (+ `//privacy|impressum|terms`). There is **no unprefixed page**. + +- Routes are generated per language in `app.routes.ts`; `langResolver` (`services/lang.resolver.ts`) sets the active language and `await`s the translation load so each route prerenders with its language baked in (`app.routes.server.ts` lists the prerender params). +- `services/seo.ts` injects per-route ``, description, canonical, `og:`/twitter, and the full `hreflang` alternate set (5 langs + `x-default`) at SSR time. Per-page meta strings live under `meta.*` in each `public/i18n/<lang>.json`. +- `scripts/postbuild.ts` generates the multilingual `sitemap.xml` (with `xhtml:link` hreflang) and writes `404.html`; keep its `LANGS`/`PAGES` in sync when adding a language or page. +- `/` and the old unprefixed paths are redirected in `public/_redirects`; `functions/index.js` is a Cloudflare Pages Function that negotiates `Accept-Language` for `/` only (explicit `/<lang>` URLs are never auto-redirected). +- The language switcher navigates to the same sub-path under the new prefix — it does not swap text in place. + ## Release Driven by `application.properties`. `sync-version-on-release.yml` updates both `application.properties` and the top-level `version` in `package.json` when a release is published. diff --git a/functions/index.js b/functions/index.js new file mode 100644 index 0000000..ae40be4 --- /dev/null +++ b/functions/index.js @@ -0,0 +1,25 @@ +// Cloudflare Pages Function for the bare "/" route only. +// Negotiates the visitor's language from Accept-Language and 302-redirects to +// the matching /<lang> home. Explicit /<lang> URLs never hit this function, so +// they are always respected (important for SEO and shareable links). +// Falls back to the default language; the static `/ -> /en` rule in _redirects +// covers the case where Functions are unavailable. + +const LANGS = ['en', 'de', 'fr', 'it', 'rm']; +const DEFAULT_LANG = 'en'; + +function pickLang(acceptLanguage) { + if (!acceptLanguage) return DEFAULT_LANG; + for (const part of acceptLanguage.split(',')) { + const code = part.split(';')[0].trim().slice(0, 2).toLowerCase(); + if (LANGS.includes(code)) return code; + } + return DEFAULT_LANG; +} + +export function onRequest(context) { + const lang = pickLang(context.request.headers.get('accept-language')); + const url = new URL(context.request.url); + url.pathname = `/${lang}`; + return Response.redirect(url.toString(), 302); +} diff --git a/public/_redirects b/public/_redirects index e60bbd3..f53c5a3 100644 --- a/public/_redirects +++ b/public/_redirects @@ -1,3 +1,12 @@ +# Canonicalize host (www / http -> https apex) https://www.schuly.dev/* https://schuly.dev/:splat 301! http://www.schuly.dev/* https://schuly.dev/:splat 301! http://schuly.dev/* https://schuly.dev/:splat 301! + +# Old unprefixed paths -> default-language equivalents (preserve indexing) +/impressum /en/impressum 301 +/privacy /en/privacy 301 +/terms /en/terms 301 + +# Root -> default language. Browser-preferred language is handled client-side after load. +/ /en 302 diff --git a/public/i18n/de.json b/public/i18n/de.json index b29383b..6b6da4d 100644 --- a/public/i18n/de.json +++ b/public/i18n/de.json @@ -1,4 +1,26 @@ { + "meta": { + "home": { + "title": "Schuly · Offenes Schülerportal für Schulnetz & mehr", + "description": "Open-Source-Schülerportal für iOS, Android und Web. Schulnetz funktioniert sofort; weitere Schulsysteme über Backend-Plugins. Kostenlos." + }, + "privacy": { + "title": "Datenschutz · Schuly", + "description": "Wie Schuly deine personenbezogenen Daten nach revDSG und DSGVO verarbeitet." + }, + "impressum": { + "title": "Impressum · Schuly", + "description": "Rechtliche Angaben und Kontakt zu Schuly." + }, + "terms": { + "title": "Nutzungsbedingungen · Schuly", + "description": "Die Bedingungen für die Nutzung von Schuly." + }, + "notFound": { + "title": "Seite nicht gefunden · Schuly", + "description": "Diese Seite existiert auf schuly.dev nicht." + } + }, "nav": { "features": "Features", "screenshots": "Screenshots", diff --git a/public/i18n/en.json b/public/i18n/en.json index 7cb2366..f56bff9 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -1,4 +1,26 @@ { + "meta": { + "home": { + "title": "Schuly · Open student portal for Schulnetz & more", + "description": "Open-source student portal for iOS, Android, and Web. Schulnetz works out of the box; more school systems via backend plugins. Free." + }, + "privacy": { + "title": "Privacy Policy · Schuly", + "description": "How Schuly handles your personal data under the Swiss revDSG and the EU GDPR." + }, + "impressum": { + "title": "Imprint · Schuly", + "description": "Legal information and contact details for Schuly." + }, + "terms": { + "title": "Terms of Use · Schuly", + "description": "The terms that govern your use of Schuly." + }, + "notFound": { + "title": "Page not found · Schuly", + "description": "This page does not exist on schuly.dev." + } + }, "nav": { "features": "Features", "screenshots": "Screenshots", diff --git a/public/i18n/fr.json b/public/i18n/fr.json index 6bb2401..07cbc6b 100644 --- a/public/i18n/fr.json +++ b/public/i18n/fr.json @@ -1,4 +1,26 @@ { + "meta": { + "home": { + "title": "Schuly · Portail élève ouvert pour Schulnetz et plus", + "description": "Portail élève open source pour iOS, Android et le web. Schulnetz fonctionne d'emblée ; d'autres systèmes via des plugins backend. Gratuit." + }, + "privacy": { + "title": "Confidentialité · Schuly", + "description": "Comment Schuly traite tes données personnelles selon la revLPD et le RGPD." + }, + "impressum": { + "title": "Mentions légales · Schuly", + "description": "Informations légales et contact pour Schuly." + }, + "terms": { + "title": "Conditions d'utilisation · Schuly", + "description": "Les conditions régissant ton utilisation de Schuly." + }, + "notFound": { + "title": "Page introuvable · Schuly", + "description": "Cette page n'existe pas sur schuly.dev." + } + }, "nav": { "features": "Fonctionnalités", "screenshots": "Captures", diff --git a/public/i18n/it.json b/public/i18n/it.json index b2ef355..a8cc903 100644 --- a/public/i18n/it.json +++ b/public/i18n/it.json @@ -1,4 +1,26 @@ { + "meta": { + "home": { + "title": "Schuly · Portale studenti aperto per Schulnetz e altro", + "description": "Portale studenti open source per iOS, Android e web. Schulnetz funziona subito; altri sistemi tramite plugin backend. Gratuito." + }, + "privacy": { + "title": "Privacy · Schuly", + "description": "Come Schuly tratta i tuoi dati personali secondo la revLPD e il GDPR." + }, + "impressum": { + "title": "Note legali · Schuly", + "description": "Informazioni legali e contatto per Schuly." + }, + "terms": { + "title": "Condizioni d'uso · Schuly", + "description": "Le condizioni che regolano il tuo uso di Schuly." + }, + "notFound": { + "title": "Pagina non trovata · Schuly", + "description": "Questa pagina non esiste su schuly.dev." + } + }, "nav": { "features": "Funzionalità", "screenshots": "Screenshot", diff --git a/public/i18n/rm.json b/public/i18n/rm.json index cc95c7f..b141730 100644 --- a/public/i18n/rm.json +++ b/public/i18n/rm.json @@ -1,4 +1,26 @@ { + "meta": { + "home": { + "title": "Schuly · Portal da scolars avert per Schulnetz e dapli", + "description": "Portal da scolars open source per iOS, Android ed il web. Schulnetz funcziuna directamain; ulteriurs sistems via plugins backend. Gratuit." + }, + "privacy": { + "title": "Protecziun da datas · Schuly", + "description": "Co che Schuly tracta tias datas persunalas tenor revDSG e DSGVO." + }, + "impressum": { + "title": "Impressum · Schuly", + "description": "Infurmaziuns giuridicas e contact per Schuly." + }, + "terms": { + "title": "Cundiziuns d'utilisaziun · Schuly", + "description": "Las cundiziuns per l'utilisaziun da Schuly." + }, + "notFound": { + "title": "Pagina betg chattada · Schuly", + "description": "Questa pagina n'exista betg sin schuly.dev." + } + }, "nav": { "features": "Funcziuns", "screenshots": "Maletgs", diff --git a/public/sitemap.xml b/public/sitemap.xml deleted file mode 100644 index dae918c..0000000 --- a/public/sitemap.xml +++ /dev/null @@ -1,48 +0,0 @@ -<?xml version="1.0" encoding="UTF-8"?> -<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" - xmlns:image="http://www.google.com/schemas/sitemap-image/1.1"> - <url> - <loc>https://schuly.dev/</loc> - <lastmod>2026-05-28</lastmod> - <changefreq>weekly</changefreq> - <priority>1.0</priority> - <image:image> - <image:loc>https://schuly.dev/assets/app_icon.png</image:loc> - <image:title>Schuly app icon</image:title> - </image:image> - <image:image> - <image:loc>https://schuly.dev/assets/schuly-start-page.png</image:loc> - <image:title>Schuly start page</image:title> - </image:image> - <image:image> - <image:loc>https://schuly.dev/assets/schuly-agenda-page.png</image:loc> - <image:title>Schuly agenda page</image:title> - </image:image> - <image:image> - <image:loc>https://schuly.dev/assets/schuly-grades-page.png</image:loc> - <image:title>Schuly grades page</image:title> - </image:image> - <image:image> - <image:loc>https://schuly.dev/assets/schuly-absences-page.png</image:loc> - <image:title>Schuly absences page</image:title> - </image:image> - </url> - <url> - <loc>https://schuly.dev/impressum</loc> - <lastmod>2026-05-28</lastmod> - <changefreq>yearly</changefreq> - <priority>0.3</priority> - </url> - <url> - <loc>https://schuly.dev/privacy</loc> - <lastmod>2026-05-28</lastmod> - <changefreq>monthly</changefreq> - <priority>0.5</priority> - </url> - <url> - <loc>https://schuly.dev/terms</loc> - <lastmod>2026-05-28</lastmod> - <changefreq>monthly</changefreq> - <priority>0.4</priority> - </url> -</urlset> diff --git a/scripts/postbuild.ts b/scripts/postbuild.ts index 70557d9..3f52159 100644 --- a/scripts/postbuild.ts +++ b/scripts/postbuild.ts @@ -1,18 +1,101 @@ -import { existsSync, mkdirSync, copyFileSync } from 'node:fs'; +import { existsSync, mkdirSync, copyFileSync, rmSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; -// Renders the /404 route output to dist/.../browser/404.html so Cloudflare -// Pages serves it for any unknown URL with a real HTTP 404 status. - const browser = 'dist/SchulyWebsite/browser'; -const src = join(browser, '404', 'index.html'); -const dest = join(browser, '404.html'); -if (!existsSync(src)) { - console.warn(`[postbuild] skipped: ${src} not found.`); +const ORIGIN = 'https://schuly.dev'; +const LANGS = ['en', 'de', 'fr', 'it', 'rm'] as const; +const DEFAULT_LANG = 'en'; + +const PAGES: { path: string; changefreq: string; priority: string; images?: string[] }[] = [ + { + path: '', + changefreq: 'weekly', + priority: '1.0', + images: [ + 'assets/app_icon.png', + 'assets/schuly-start-page.png', + 'assets/schuly-agenda-page.png', + 'assets/schuly-grades-page.png', + 'assets/schuly-absences-page.png', + ], + }, + { path: '/impressum', changefreq: 'yearly', priority: '0.3' }, + { path: '/privacy', changefreq: 'monthly', priority: '0.5' }, + { path: '/terms', changefreq: 'monthly', priority: '0.4' }, +]; + +// 1. Render the /404 route output to 404.html so Cloudflare Pages serves it for +// any unknown URL with a real HTTP 404 status. +function writeNotFound() { + const src = join(browser, '404', 'index.html'); + const dest = join(browser, '404.html'); + if (!existsSync(src)) { + console.warn(`[postbuild] 404 skipped: ${src} not found.`); + return; + } + mkdirSync(browser, { recursive: true }); + copyFileSync(src, dest); + console.log(`[postbuild] wrote ${dest}`); +} + +// 2. The root '' route only redirects to /<default-lang>. Drop any prerendered +// root index.html so Cloudflare's `/ -> /en` edge redirect in _redirects +// applies instead of serving a static shell. +function dropRootIndex() { + const root = join(browser, 'index.html'); + if (existsSync(root)) { + rmSync(root); + console.log(`[postbuild] removed ${root} (root handled by _redirects)`); + } +} + +// 3. Generate a multilingual sitemap with hreflang alternates for every page. +function writeSitemap() { + const lastmod = new Date().toISOString().slice(0, 10); + + const urls = LANGS.flatMap(lang => + PAGES.map(page => { + const loc = `${ORIGIN}/${lang}${page.path}`; + const alternates = [ + ...LANGS.map(l => ` <xhtml:link rel="alternate" hreflang="${l}" href="${ORIGIN}/${l}${page.path}"/>`), + ` <xhtml:link rel="alternate" hreflang="x-default" href="${ORIGIN}/${DEFAULT_LANG}${page.path}"/>`, + ].join('\n'); + const images = (page.images ?? []) + .map(img => ` <image:image>\n <image:loc>${ORIGIN}/${img}</image:loc>\n </image:image>`) + .join('\n'); + return [ + ' <url>', + ` <loc>${loc}</loc>`, + alternates, + ` <lastmod>${lastmod}</lastmod>`, + ` <changefreq>${page.changefreq}</changefreq>`, + ` <priority>${page.priority}</priority>`, + images, + ' </url>', + ] + .filter(Boolean) + .join('\n'); + }), + ).join('\n'); + + const xml = `<?xml version="1.0" encoding="UTF-8"?> +<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" + xmlns:xhtml="http://www.w3.org/1999/xhtml" + xmlns:image="http://www.google.com/schemas/sitemap-image/1.1"> +${urls} +</urlset> +`; + + writeFileSync(join(browser, 'sitemap.xml'), xml); + console.log(`[postbuild] wrote ${join(browser, 'sitemap.xml')} (${LANGS.length * PAGES.length} urls)`); +} + +if (!existsSync(browser)) { + console.warn(`[postbuild] skipped: ${browser} not found.`); process.exit(0); } -mkdirSync(browser, { recursive: true }); -copyFileSync(src, dest); -console.log(`[postbuild] wrote ${dest}`); +writeNotFound(); +dropRootIndex(); +writeSitemap(); diff --git a/src/app/app.config.ts b/src/app/app.config.ts index 1455af7..9324a7e 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -1,5 +1,5 @@ import { ApplicationConfig, provideZonelessChangeDetection } from '@angular/core'; -import { provideRouter, withInMemoryScrolling } from '@angular/router'; +import { provideRouter, withInMemoryScrolling, withRouterConfig } from '@angular/router'; import { provideClientHydration, withEventReplay } from '@angular/platform-browser'; import { provideHttpClient, withFetch } from '@angular/common/http'; import { provideTranslateService } from '@ngx-translate/core'; @@ -16,6 +16,7 @@ export const appConfig: ApplicationConfig = { scrollPositionRestoration: 'top', anchorScrolling: 'enabled', }), + withRouterConfig({ paramsInheritanceStrategy: 'always' }), ), provideClientHydration(withEventReplay()), provideHttpClient(withFetch()), diff --git a/src/app/app.routes.server.ts b/src/app/app.routes.server.ts index 8df2a41..dffbd51 100644 --- a/src/app/app.routes.server.ts +++ b/src/app/app.routes.server.ts @@ -1,10 +1,14 @@ import { RenderMode, ServerRoute } from '@angular/ssr'; +import { LANG_CODES } from './services/language'; + +const langParams = async () => LANG_CODES.map(lang => ({ lang })); export const serverRoutes: ServerRoute[] = [ { path: '', renderMode: RenderMode.Prerender }, - { path: 'impressum', renderMode: RenderMode.Prerender }, - { path: 'privacy', renderMode: RenderMode.Prerender }, - { path: 'terms', renderMode: RenderMode.Prerender }, + { path: ':lang', renderMode: RenderMode.Prerender, getPrerenderParams: langParams }, + { path: ':lang/impressum', renderMode: RenderMode.Prerender, getPrerenderParams: langParams }, + { path: ':lang/privacy', renderMode: RenderMode.Prerender, getPrerenderParams: langParams }, + { path: ':lang/terms', renderMode: RenderMode.Prerender, getPrerenderParams: langParams }, { path: '404', renderMode: RenderMode.Prerender }, { path: '**', renderMode: RenderMode.Prerender }, ]; diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index b29941b..46f412d 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -4,12 +4,25 @@ import { Impressum } from './components/legal/impressum'; import { Privacy } from './components/legal/privacy'; import { Terms } from './components/legal/terms'; import { NotFound } from './pages/not-found/not-found'; +import { DEFAULT_LANG, LANG_CODES } from './services/language'; +import { langResolver } from './services/lang.resolver'; + +/** Pages that exist under every language prefix. `path` is the sub-path after `/<lang>`. */ +const pageRoutes: Routes = [ + { path: '', component: Home, data: { seo: 'home', path: '' } }, + { path: 'impressum', component: Impressum, data: { seo: 'impressum', path: '/impressum' } }, + { path: 'privacy', component: Privacy, data: { seo: 'privacy', path: '/privacy' } }, + { path: 'terms', component: Terms, data: { seo: 'terms', path: '/terms' } }, +]; export const routes: Routes = [ - { path: '', component: Home, pathMatch: 'full' }, - { path: 'impressum', component: Impressum, title: 'Impressum · Schuly' }, - { path: 'privacy', component: Privacy, title: 'Privacy Policy · Schuly' }, - { path: 'terms', component: Terms, title: 'Terms of Use · Schuly' }, - { path: '404', component: NotFound, title: '404 · Schuly' }, - { path: '**', component: NotFound, title: '404 · Schuly' }, + { path: '', pathMatch: 'full', redirectTo: `/${DEFAULT_LANG}` }, + ...LANG_CODES.map(lang => ({ + path: lang, + data: { lang }, + resolve: { lang: langResolver }, + children: pageRoutes, + })), + { path: '404', component: NotFound, data: { lang: DEFAULT_LANG, seo: 'notFound', path: '' }, resolve: { lang: langResolver } }, + { path: '**', component: NotFound, data: { lang: DEFAULT_LANG, seo: 'notFound', path: '' }, resolve: { lang: langResolver } }, ]; diff --git a/src/app/app.ts b/src/app/app.ts index bd0ec5e..fe101f9 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -1,7 +1,8 @@ -import { Component } from '@angular/core'; +import { Component, inject } from '@angular/core'; import { RouterOutlet } from '@angular/router'; import { Navigation } from './components/navigation/navigation'; import { Footer } from './components/footer/footer'; +import { SeoService } from './services/seo'; @Component({ selector: 'app-root', @@ -9,4 +10,10 @@ import { Footer } from './components/footer/footer'; templateUrl: './app.html', styleUrl: './app.scss', }) -export class App {} +export class App { + private seo = inject(SeoService); + + constructor() { + this.seo.init(); + } +} diff --git a/src/app/components/footer/footer.html b/src/app/components/footer/footer.html index 5695a69..9b5209c 100644 --- a/src/app/components/footer/footer.html +++ b/src/app/components/footer/footer.html @@ -19,15 +19,15 @@ <a href="https://github.com/schulydev/Schuly/releases" target="_blank" rel="noopener">{{ 'footer.links.releases' | translate }}</a> <a href="https://docs.schuly.dev" target="_blank" rel="noopener">{{ 'footer.links.docs' | translate }}</a> <a href="https://github.com/schulydev/Schuly/issues" target="_blank" rel="noopener">{{ 'footer.links.issues' | translate }}</a> - <a href="/#features">{{ 'footer.links.features' | translate }}</a> - <a href="/#faq">{{ 'footer.links.faq' | translate }}</a> - <a href="/#download">{{ 'footer.links.download' | translate }}</a> + <a [href]="base() + '#features'">{{ 'footer.links.features' | translate }}</a> + <a [href]="base() + '#faq'">{{ 'footer.links.faq' | translate }}</a> + <a [href]="base() + '#download'">{{ 'footer.links.download' | translate }}</a> </div> <div class="footer-col"> <div class="footer-col-title">{{ 'footer.cols.legal' | translate }}</div> - <a routerLink="/impressum">{{ 'footer.links.impressum' | translate }}</a> - <a routerLink="/privacy">{{ 'footer.links.privacy' | translate }}</a> - <a routerLink="/terms">{{ 'footer.links.terms' | translate }}</a> + <a [routerLink]="['/', lang(), 'impressum']">{{ 'footer.links.impressum' | translate }}</a> + <a [routerLink]="['/', lang(), 'privacy']">{{ 'footer.links.privacy' | translate }}</a> + <a [routerLink]="['/', lang(), 'terms']">{{ 'footer.links.terms' | translate }}</a> <a href="https://buymeacoffee.com/pianonic" target="_blank" rel="noopener">{{ 'footer.links.coffee' | translate }}</a> </div> </div> diff --git a/src/app/components/footer/footer.ts b/src/app/components/footer/footer.ts index 30de6b0..d876bd9 100644 --- a/src/app/components/footer/footer.ts +++ b/src/app/components/footer/footer.ts @@ -1,8 +1,9 @@ -import { Component } from '@angular/core'; +import { Component, computed, inject } from '@angular/core'; import { RouterLink } from '@angular/router'; import { TranslatePipe } from '@ngx-translate/core'; import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; import { faHeart } from '@fortawesome/free-solid-svg-icons'; +import { LanguageService } from '../../services/language'; @Component({ selector: 'app-footer', @@ -11,6 +12,13 @@ import { faHeart } from '@fortawesome/free-solid-svg-icons'; styleUrl: './footer.scss' }) export class Footer { + private language = inject(LanguageService); + protected readonly faHeart = faHeart; protected readonly year = new Date().getFullYear(); + + /** Active language prefix for in-page anchors, e.g. `/de`. */ + protected readonly base = computed(() => `/${this.language.current()}`); + /** Active language code for routerLink segments. */ + protected readonly lang = this.language.current; } diff --git a/src/app/components/navigation/navigation.html b/src/app/components/navigation/navigation.html index a55c946..6373338 100644 --- a/src/app/components/navigation/navigation.html +++ b/src/app/components/navigation/navigation.html @@ -1,14 +1,14 @@ <nav class="nav"> <div class="nav-inner"> - <a href="/" class="nav-logo"> + <a [href]="base()" class="nav-logo"> <img src="/assets/app_icon.png" alt="Schuly" width="28" height="28" /> <span>Schuly</span> </a> <div class="nav-links" [class.open]="mobileMenuOpen()"> - <a href="/#features" class="nav-link" (click)="closeMobileMenu()">{{ 'nav.features' | translate }}</a> - <a href="/#screenshots" class="nav-link" (click)="closeMobileMenu()">{{ 'nav.screenshots' | translate }}</a> - <a href="/#download" class="nav-link" (click)="closeMobileMenu()">{{ 'nav.download' | translate }}</a> + <a [href]="base() + '#features'" class="nav-link" (click)="closeMobileMenu()">{{ 'nav.features' | translate }}</a> + <a [href]="base() + '#screenshots'" class="nav-link" (click)="closeMobileMenu()">{{ 'nav.screenshots' | translate }}</a> + <a [href]="base() + '#download'" class="nav-link" (click)="closeMobileMenu()">{{ 'nav.download' | translate }}</a> <a href="https://docs.schuly.dev" target="_blank" rel="noopener" class="nav-link" (click)="closeMobileMenu()">{{ 'nav.docs' | translate }}</a> <a href="https://github.com/schulydev/Schuly" target="_blank" rel="noopener" class="nav-link" (click)="closeMobileMenu()">{{ 'nav.openSource' | translate }}</a> </div> @@ -18,7 +18,7 @@ <a href="https://github.com/schulydev/Schuly" target="_blank" rel="noopener" class="nav-github" [attr.aria-label]="'nav.githubAria' | translate"> <fa-icon [icon]="faGithub"></fa-icon> </a> - <a href="/#download" class="nav-cta">{{ 'nav.getSchuly' | translate }}</a> + <a [href]="base() + '#download'" class="nav-cta">{{ 'nav.getSchuly' | translate }}</a> <button class="nav-hamburger" (click)="toggleMobileMenu()" [attr.aria-label]="mobileMenuOpen() ? 'Close menu' : 'Open menu'"> <fa-icon [icon]="mobileMenuOpen() ? faTimes : faBars"></fa-icon> </button> diff --git a/src/app/components/navigation/navigation.ts b/src/app/components/navigation/navigation.ts index 77775de..fddc9d0 100644 --- a/src/app/components/navigation/navigation.ts +++ b/src/app/components/navigation/navigation.ts @@ -1,9 +1,10 @@ -import { Component, signal } from '@angular/core'; +import { Component, computed, inject, signal } from '@angular/core'; import { TranslatePipe } from '@ngx-translate/core'; import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; import { faGithub } from '@fortawesome/free-brands-svg-icons'; import { faBars, faTimes } from '@fortawesome/free-solid-svg-icons'; import { LanguageSwitcher } from '../language-switcher/language-switcher'; +import { LanguageService } from '../../services/language'; @Component({ selector: 'app-navigation', @@ -12,10 +13,15 @@ import { LanguageSwitcher } from '../language-switcher/language-switcher'; styleUrl: './navigation.scss' }) export class Navigation { + private language = inject(LanguageService); + protected readonly faGithub = faGithub; protected readonly faBars = faBars; protected readonly faTimes = faTimes; + /** Base path for the active language, e.g. `/de`, so in-page anchors stay within the localized home. */ + protected readonly base = computed(() => `/${this.language.current()}`); + mobileMenuOpen = signal(false); toggleMobileMenu() { diff --git a/src/app/services/lang.resolver.ts b/src/app/services/lang.resolver.ts new file mode 100644 index 0000000..863a99d --- /dev/null +++ b/src/app/services/lang.resolver.ts @@ -0,0 +1,21 @@ +import { inject } from '@angular/core'; +import { ResolveFn } from '@angular/router'; +import { TranslateService } from '@ngx-translate/core'; +import { map } from 'rxjs'; +import { DEFAULT_LANG, Lang, LanguageService, isLang } from './language'; + +/** + * Aligns the active language with the URL and waits for that language's + * translation file to load before the route activates. Returning the + * TranslateService observable makes the router (and therefore the prerenderer) + * block until the strings are available, so every prerendered page ships with + * its language's content baked into the HTML rather than swapping in after + * hydration. + */ +export const langResolver: ResolveFn<boolean> = (route) => { + const raw = (route.data['lang'] ?? route.parent?.data['lang']) as string | undefined; + const lang: Lang = isLang(raw) ? raw : DEFAULT_LANG; + + inject(LanguageService).applyFromRoute(lang); + return inject(TranslateService).use(lang).pipe(map(() => true)); +}; diff --git a/src/app/services/language.ts b/src/app/services/language.ts index 123c2f8..dff1941 100644 --- a/src/app/services/language.ts +++ b/src/app/services/language.ts @@ -1,48 +1,45 @@ -import { Injectable, PLATFORM_ID, afterNextRender, computed, effect, inject, signal } from '@angular/core'; +import { Injectable, PLATFORM_ID, computed, effect, inject, signal } from '@angular/core'; import { isPlatformBrowser } from '@angular/common'; +import { Router } from '@angular/router'; import { TranslateService } from '@ngx-translate/core'; export type Lang = 'de' | 'fr' | 'it' | 'rm' | 'en'; -export const LANGS: { code: Lang; label: string; flag: string }[] = [ - { code: 'de', label: 'Deutsch', flag: 'DE' }, - { code: 'fr', label: 'Français', flag: 'FR' }, - { code: 'it', label: 'Italiano', flag: 'IT' }, - { code: 'rm', label: 'Rumantsch', flag: 'RM' }, - { code: 'en', label: 'English', flag: 'EN' }, +export const LANGS: { code: Lang; label: string; flag: string; ogLocale: string }[] = [ + { code: 'de', label: 'Deutsch', flag: 'DE', ogLocale: 'de_CH' }, + { code: 'fr', label: 'Français', flag: 'FR', ogLocale: 'fr_CH' }, + { code: 'it', label: 'Italiano', flag: 'IT', ogLocale: 'it_CH' }, + { code: 'rm', label: 'Rumantsch', flag: 'RM', ogLocale: 'rm_CH' }, + { code: 'en', label: 'English', flag: 'EN', ogLocale: 'en_US' }, ]; +export const LANG_CODES = LANGS.map(l => l.code); + const STORAGE_KEY = 'schuly-lang'; -const DEFAULT: Lang = 'en'; +export const DEFAULT_LANG: Lang = 'en'; + +export function isLang(value: string | null | undefined): value is Lang { + return !!value && LANG_CODES.includes(value as Lang); +} @Injectable({ providedIn: 'root' }) export class LanguageService { private translate = inject(TranslateService); + private router = inject(Router); private isBrowser = isPlatformBrowser(inject(PLATFORM_ID)); - /** Active language signal. Components read this for reactive language-dependent rendering. */ - current = signal<Lang>(DEFAULT); + /** Active language signal. The router resolver drives this from the URL. */ + current = signal<Lang>(DEFAULT_LANG); /** Convenience: true if the global lang is anything other than English. Used by legal pages to pick a sensible fallback body. */ prefersDeForLegal = computed(() => this.current() !== 'en'); constructor() { - this.translate.addLangs(LANGS.map(l => l.code)); - this.translate.setFallbackLang(DEFAULT); - this.translate.use(DEFAULT); - - // Auto-detect on the client only, after hydration. The server pass and the - // first hydration pass both render in English so no mismatch occurs; if the - // visitor's browser asks for another supported language, we switch - // immediately afterwards. We set the signal directly (no localStorage - // write) so we don't trample an explicit prior choice on the next visit. - afterNextRender(() => { - const detected = this.detect(); - if (detected !== this.current()) this.current.set(detected); - }); + this.translate.addLangs(LANG_CODES); + this.translate.setFallbackLang(DEFAULT_LANG); // Keep TranslateService and the <html lang> attribute in sync with the - // signal. This effect does NOT persist — only explicit user actions via - // setLang() write to localStorage. + // signal. The signal is set by the route resolver, so by the time this runs + // the language always matches the URL. effect(() => { const lang = this.current(); this.translate.use(lang); @@ -50,32 +47,29 @@ export class LanguageService { }); } - /** Called when the user picks a language from the switcher. Persists the choice. */ + /** Called by the route resolver to align the active language with the URL. Does not persist. */ + applyFromRoute(lang: Lang) { + if (this.current() !== lang) this.current.set(lang); + } + + /** + * Called when the user picks a language from the switcher. Persists the + * choice and navigates to the same page under the new language prefix so the + * URL always reflects the active language. + */ setLang(lang: Lang) { - this.current.set(lang); if (this.isBrowser) { try { localStorage.setItem(STORAGE_KEY, lang); } catch {} } + const rest = this.pathWithoutLang(); + this.router.navigateByUrl(`/${lang}${rest}`); } - private detect(): Lang { - if (!this.isBrowser) return DEFAULT; - - // 1. Explicit prior user choice. - try { - const stored = localStorage.getItem(STORAGE_KEY) as Lang | null; - if (stored && LANGS.some(l => l.code === stored)) return stored; - } catch {} - - // 2. navigator.languages — covers de-CH, de-DE, de, fr-CH, fr, etc. - // Any variation maps to its base code; falls through to English if none match. - const navLangs = navigator.languages?.length ? Array.from(navigator.languages) : [navigator.language]; - for (const raw of navLangs) { - if (!raw) continue; - const code = raw.slice(0, 2).toLowerCase() as Lang; - if (LANGS.some(l => l.code === code)) return code; - } - - return DEFAULT; + /** The current path (with hash) stripped of its leading language segment. */ + private pathWithoutLang(): string { + if (!this.isBrowser) return ''; + const { pathname, search, hash } = window.location; + const stripped = pathname.replace(/^\/[a-z]{2}(?=\/|$)/, ''); + return `${stripped || ''}${search}${hash}`; } } diff --git a/src/app/services/seo.ts b/src/app/services/seo.ts new file mode 100644 index 0000000..5eb608d --- /dev/null +++ b/src/app/services/seo.ts @@ -0,0 +1,98 @@ +import { DOCUMENT, inject, Injectable } from '@angular/core'; +import { Meta, Title } from '@angular/platform-browser'; +import { ActivatedRoute, NavigationEnd, Router } from '@angular/router'; +import { TranslateService } from '@ngx-translate/core'; +import { filter } from 'rxjs'; +import { DEFAULT_LANG, Lang, LANGS, LanguageService } from './language'; + +const ORIGIN = 'https://schuly.dev'; + +/** Page key carried in route data; maps to the `meta.<key>` i18n block. */ +export type SeoPage = 'home' | 'privacy' | 'impressum' | 'terms' | 'notFound'; + +@Injectable({ providedIn: 'root' }) +export class SeoService { + private router = inject(Router); + private route = inject(ActivatedRoute); + private title = inject(Title); + private meta = inject(Meta); + private translate = inject(TranslateService); + private language = inject(LanguageService); + private doc = inject(DOCUMENT); + + /** Subscribe once (from the root component) to update head tags on every navigation. */ + init() { + this.router.events + .pipe(filter((e): e is NavigationEnd => e instanceof NavigationEnd)) + .subscribe(() => this.update()); + } + + private update() { + let r = this.route.snapshot; + while (r.firstChild) r = r.firstChild; + + const page = (r.data['seo'] as SeoPage) ?? 'home'; + const lang = this.language.current(); + const subPath = (r.data['path'] as string) ?? ''; + + const t = (key: string) => this.translate.instant(`meta.${page}.${key}`); + const title = t('title'); + const description = t('description'); + + // Set <html lang> here so it is correct in prerendered HTML too (the + // LanguageService effect only runs in the browser). + this.doc.documentElement.setAttribute('lang', lang); + + this.title.setTitle(title); + this.meta.updateTag({ name: 'description', content: description }); + + const url = `${ORIGIN}/${lang}${subPath}`; + this.meta.updateTag({ property: 'og:title', content: title }); + this.meta.updateTag({ property: 'og:description', content: description }); + this.meta.updateTag({ property: 'og:url', content: url }); + this.meta.updateTag({ property: 'og:locale', content: this.ogLocale(lang) }); + this.meta.updateTag({ name: 'twitter:title', content: title }); + this.meta.updateTag({ name: 'twitter:description', content: description }); + + this.setCanonical(url); + this.setAlternates(subPath, lang); + } + + private ogLocale(lang: Lang): string { + return LANGS.find(l => l.code === lang)?.ogLocale ?? 'en_US'; + } + + private setCanonical(url: string) { + this.upsertLink('canonical', 'canonical', { href: url }); + } + + /** Emit hreflang alternates for every language plus x-default (default language). */ + private setAlternates(subPath: string, _active: Lang) { + for (const { code } of LANGS) { + this.upsertLink(`alt-${code}`, 'alternate', { + hreflang: code, + href: `${ORIGIN}/${code}${subPath}`, + }); + } + this.upsertLink('alt-x-default', 'alternate', { + hreflang: 'x-default', + href: `${ORIGIN}/${DEFAULT_LANG}${subPath}`, + }); + } + + /** + * Create or update a <link> in <head>, keyed by a stable data attribute so we + * reuse the same element across navigations instead of piling up duplicates. + */ + private upsertLink(key: string, rel: string, attrs: Record<string, string>) { + const head = this.doc.head; + let el = head.querySelector<HTMLLinkElement>(`link[data-seo="${key}"]`); + if (!el) { + el = this.doc.createElement('link'); + el.setAttribute('data-seo', key); + el.setAttribute('rel', rel); + head.appendChild(el); + } + for (const [name, value] of Object.entries(attrs)) el.setAttribute(name, value); + } +} diff --git a/src/index.html b/src/index.html index 5f44be2..2f781d5 100644 --- a/src/index.html +++ b/src/index.html @@ -18,8 +18,7 @@ <meta name="apple-mobile-web-app-title" content="Schuly"> <meta name="format-detection" content="telephone=no"> - <link rel="canonical" href="https://schuly.dev/"> - <link rel="alternate" hreflang="x-default" href="https://schuly.dev/"> + <!-- canonical + hreflang alternates are injected per route by SeoService --> <link rel="icon" type="image/png" href="/assets/app_icon.png"> <link rel="apple-touch-icon" href="/assets/app_icon.png"> @@ -34,9 +33,7 @@ <meta property="og:site_name" content="Schuly"> <meta property="og:title" content="Schuly · Open student portal for Schulnetz & more"> <meta property="og:description" content="Open-source student portal for iOS, Android, and Web. Schulnetz works out of the box. Free and built in the open."> - <meta property="og:url" content="https://schuly.dev/"> - <meta property="og:locale" content="en_US"> - <meta property="og:locale:alternate" content="de_CH"> + <!-- og:url + og:locale are set per route by SeoService --> <meta property="og:image" content="https://schuly.dev/assets/app_icon.png"> <meta property="og:image:width" content="512"> <meta property="og:image:height" content="512">