Skip to content

Commit ec50a2b

Browse files
authored
Add per-language prerendered URLs with hreflang for multilingual SEO (#44)
1 parent b00d3d8 commit ec50a2b

22 files changed

Lines changed: 473 additions & 135 deletions

File tree

.claude/CLAUDE.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,16 @@ The `.github/workflows/build.yml` CI is independent - it only verifies the build
4444

4545
Stack is plain Angular standalone components. Look in `src/app/`. SCSS, no Tailwind. FontAwesome via `@fortawesome/angular-fontawesome`.
4646

47+
## i18n / SEO
48+
49+
Every page lives under a language prefix: `/en`, `/de`, `/fr`, `/it`, `/rm` (+ `/<lang>/privacy|impressum|terms`). There is **no unprefixed page**.
50+
51+
- 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).
52+
- `services/seo.ts` injects per-route `<title>`, 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`.
53+
- `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.
54+
- `/` 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).
55+
- The language switcher navigates to the same sub-path under the new prefix — it does not swap text in place.
56+
4757
## Release
4858

4959
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.

functions/index.js

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
// Cloudflare Pages Function for the bare "/" route only.
2+
// Negotiates the visitor's language from Accept-Language and 302-redirects to
3+
// the matching /<lang> home. Explicit /<lang> URLs never hit this function, so
4+
// they are always respected (important for SEO and shareable links).
5+
// Falls back to the default language; the static `/ -> /en` rule in _redirects
6+
// covers the case where Functions are unavailable.
7+
8+
const LANGS = ['en', 'de', 'fr', 'it', 'rm'];
9+
const DEFAULT_LANG = 'en';
10+
11+
function pickLang(acceptLanguage) {
12+
if (!acceptLanguage) return DEFAULT_LANG;
13+
for (const part of acceptLanguage.split(',')) {
14+
const code = part.split(';')[0].trim().slice(0, 2).toLowerCase();
15+
if (LANGS.includes(code)) return code;
16+
}
17+
return DEFAULT_LANG;
18+
}
19+
20+
export function onRequest(context) {
21+
const lang = pickLang(context.request.headers.get('accept-language'));
22+
const url = new URL(context.request.url);
23+
url.pathname = `/${lang}`;
24+
return Response.redirect(url.toString(), 302);
25+
}

public/_redirects

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,12 @@
1+
# Canonicalize host (www / http -> https apex)
12
https://www.schuly.dev/* https://schuly.dev/:splat 301!
23
http://www.schuly.dev/* https://schuly.dev/:splat 301!
34
http://schuly.dev/* https://schuly.dev/:splat 301!
5+
6+
# Old unprefixed paths -> default-language equivalents (preserve indexing)
7+
/impressum /en/impressum 301
8+
/privacy /en/privacy 301
9+
/terms /en/terms 301
10+
11+
# Root -> default language. Browser-preferred language is handled client-side after load.
12+
/ /en 302

public/i18n/de.json

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,26 @@
11
{
2+
"meta": {
3+
"home": {
4+
"title": "Schuly · Offenes Schülerportal für Schulnetz & mehr",
5+
"description": "Open-Source-Schülerportal für iOS, Android und Web. Schulnetz funktioniert sofort; weitere Schulsysteme über Backend-Plugins. Kostenlos."
6+
},
7+
"privacy": {
8+
"title": "Datenschutz · Schuly",
9+
"description": "Wie Schuly deine personenbezogenen Daten nach revDSG und DSGVO verarbeitet."
10+
},
11+
"impressum": {
12+
"title": "Impressum · Schuly",
13+
"description": "Rechtliche Angaben und Kontakt zu Schuly."
14+
},
15+
"terms": {
16+
"title": "Nutzungsbedingungen · Schuly",
17+
"description": "Die Bedingungen für die Nutzung von Schuly."
18+
},
19+
"notFound": {
20+
"title": "Seite nicht gefunden · Schuly",
21+
"description": "Diese Seite existiert auf schuly.dev nicht."
22+
}
23+
},
224
"nav": {
325
"features": "Features",
426
"screenshots": "Screenshots",

public/i18n/en.json

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,26 @@
11
{
2+
"meta": {
3+
"home": {
4+
"title": "Schuly · Open student portal for Schulnetz & more",
5+
"description": "Open-source student portal for iOS, Android, and Web. Schulnetz works out of the box; more school systems via backend plugins. Free."
6+
},
7+
"privacy": {
8+
"title": "Privacy Policy · Schuly",
9+
"description": "How Schuly handles your personal data under the Swiss revDSG and the EU GDPR."
10+
},
11+
"impressum": {
12+
"title": "Imprint · Schuly",
13+
"description": "Legal information and contact details for Schuly."
14+
},
15+
"terms": {
16+
"title": "Terms of Use · Schuly",
17+
"description": "The terms that govern your use of Schuly."
18+
},
19+
"notFound": {
20+
"title": "Page not found · Schuly",
21+
"description": "This page does not exist on schuly.dev."
22+
}
23+
},
224
"nav": {
325
"features": "Features",
426
"screenshots": "Screenshots",

public/i18n/fr.json

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,26 @@
11
{
2+
"meta": {
3+
"home": {
4+
"title": "Schuly · Portail élève ouvert pour Schulnetz et plus",
5+
"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."
6+
},
7+
"privacy": {
8+
"title": "Confidentialité · Schuly",
9+
"description": "Comment Schuly traite tes données personnelles selon la revLPD et le RGPD."
10+
},
11+
"impressum": {
12+
"title": "Mentions légales · Schuly",
13+
"description": "Informations légales et contact pour Schuly."
14+
},
15+
"terms": {
16+
"title": "Conditions d'utilisation · Schuly",
17+
"description": "Les conditions régissant ton utilisation de Schuly."
18+
},
19+
"notFound": {
20+
"title": "Page introuvable · Schuly",
21+
"description": "Cette page n'existe pas sur schuly.dev."
22+
}
23+
},
224
"nav": {
325
"features": "Fonctionnalités",
426
"screenshots": "Captures",

public/i18n/it.json

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,26 @@
11
{
2+
"meta": {
3+
"home": {
4+
"title": "Schuly · Portale studenti aperto per Schulnetz e altro",
5+
"description": "Portale studenti open source per iOS, Android e web. Schulnetz funziona subito; altri sistemi tramite plugin backend. Gratuito."
6+
},
7+
"privacy": {
8+
"title": "Privacy · Schuly",
9+
"description": "Come Schuly tratta i tuoi dati personali secondo la revLPD e il GDPR."
10+
},
11+
"impressum": {
12+
"title": "Note legali · Schuly",
13+
"description": "Informazioni legali e contatto per Schuly."
14+
},
15+
"terms": {
16+
"title": "Condizioni d'uso · Schuly",
17+
"description": "Le condizioni che regolano il tuo uso di Schuly."
18+
},
19+
"notFound": {
20+
"title": "Pagina non trovata · Schuly",
21+
"description": "Questa pagina non esiste su schuly.dev."
22+
}
23+
},
224
"nav": {
325
"features": "Funzionalità",
426
"screenshots": "Screenshot",

public/i18n/rm.json

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,26 @@
11
{
2+
"meta": {
3+
"home": {
4+
"title": "Schuly · Portal da scolars avert per Schulnetz e dapli",
5+
"description": "Portal da scolars open source per iOS, Android ed il web. Schulnetz funcziuna directamain; ulteriurs sistems via plugins backend. Gratuit."
6+
},
7+
"privacy": {
8+
"title": "Protecziun da datas · Schuly",
9+
"description": "Co che Schuly tracta tias datas persunalas tenor revDSG e DSGVO."
10+
},
11+
"impressum": {
12+
"title": "Impressum · Schuly",
13+
"description": "Infurmaziuns giuridicas e contact per Schuly."
14+
},
15+
"terms": {
16+
"title": "Cundiziuns d'utilisaziun · Schuly",
17+
"description": "Las cundiziuns per l'utilisaziun da Schuly."
18+
},
19+
"notFound": {
20+
"title": "Pagina betg chattada · Schuly",
21+
"description": "Questa pagina n'exista betg sin schuly.dev."
22+
}
23+
},
224
"nav": {
325
"features": "Funcziuns",
426
"screenshots": "Maletgs",

public/sitemap.xml

Lines changed: 0 additions & 48 deletions
This file was deleted.

scripts/postbuild.ts

Lines changed: 94 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,101 @@
1-
import { existsSync, mkdirSync, copyFileSync } from 'node:fs';
1+
import { existsSync, mkdirSync, copyFileSync, rmSync, writeFileSync } from 'node:fs';
22
import { join } from 'node:path';
33

4-
// Renders the /404 route output to dist/.../browser/404.html so Cloudflare
5-
// Pages serves it for any unknown URL with a real HTTP 404 status.
6-
74
const browser = 'dist/SchulyWebsite/browser';
8-
const src = join(browser, '404', 'index.html');
9-
const dest = join(browser, '404.html');
105

11-
if (!existsSync(src)) {
12-
console.warn(`[postbuild] skipped: ${src} not found.`);
6+
const ORIGIN = 'https://schuly.dev';
7+
const LANGS = ['en', 'de', 'fr', 'it', 'rm'] as const;
8+
const DEFAULT_LANG = 'en';
9+
10+
const PAGES: { path: string; changefreq: string; priority: string; images?: string[] }[] = [
11+
{
12+
path: '',
13+
changefreq: 'weekly',
14+
priority: '1.0',
15+
images: [
16+
'assets/app_icon.png',
17+
'assets/schuly-start-page.png',
18+
'assets/schuly-agenda-page.png',
19+
'assets/schuly-grades-page.png',
20+
'assets/schuly-absences-page.png',
21+
],
22+
},
23+
{ path: '/impressum', changefreq: 'yearly', priority: '0.3' },
24+
{ path: '/privacy', changefreq: 'monthly', priority: '0.5' },
25+
{ path: '/terms', changefreq: 'monthly', priority: '0.4' },
26+
];
27+
28+
// 1. Render the /404 route output to 404.html so Cloudflare Pages serves it for
29+
// any unknown URL with a real HTTP 404 status.
30+
function writeNotFound() {
31+
const src = join(browser, '404', 'index.html');
32+
const dest = join(browser, '404.html');
33+
if (!existsSync(src)) {
34+
console.warn(`[postbuild] 404 skipped: ${src} not found.`);
35+
return;
36+
}
37+
mkdirSync(browser, { recursive: true });
38+
copyFileSync(src, dest);
39+
console.log(`[postbuild] wrote ${dest}`);
40+
}
41+
42+
// 2. The root '' route only redirects to /<default-lang>. Drop any prerendered
43+
// root index.html so Cloudflare's `/ -> /en` edge redirect in _redirects
44+
// applies instead of serving a static shell.
45+
function dropRootIndex() {
46+
const root = join(browser, 'index.html');
47+
if (existsSync(root)) {
48+
rmSync(root);
49+
console.log(`[postbuild] removed ${root} (root handled by _redirects)`);
50+
}
51+
}
52+
53+
// 3. Generate a multilingual sitemap with hreflang alternates for every page.
54+
function writeSitemap() {
55+
const lastmod = new Date().toISOString().slice(0, 10);
56+
57+
const urls = LANGS.flatMap(lang =>
58+
PAGES.map(page => {
59+
const loc = `${ORIGIN}/${lang}${page.path}`;
60+
const alternates = [
61+
...LANGS.map(l => ` <xhtml:link rel="alternate" hreflang="${l}" href="${ORIGIN}/${l}${page.path}"/>`),
62+
` <xhtml:link rel="alternate" hreflang="x-default" href="${ORIGIN}/${DEFAULT_LANG}${page.path}"/>`,
63+
].join('\n');
64+
const images = (page.images ?? [])
65+
.map(img => ` <image:image>\n <image:loc>${ORIGIN}/${img}</image:loc>\n </image:image>`)
66+
.join('\n');
67+
return [
68+
' <url>',
69+
` <loc>${loc}</loc>`,
70+
alternates,
71+
` <lastmod>${lastmod}</lastmod>`,
72+
` <changefreq>${page.changefreq}</changefreq>`,
73+
` <priority>${page.priority}</priority>`,
74+
images,
75+
' </url>',
76+
]
77+
.filter(Boolean)
78+
.join('\n');
79+
}),
80+
).join('\n');
81+
82+
const xml = `<?xml version="1.0" encoding="UTF-8"?>
83+
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
84+
xmlns:xhtml="http://www.w3.org/1999/xhtml"
85+
xmlns:image="http://www.google.com/schemas/sitemap-image/1.1">
86+
${urls}
87+
</urlset>
88+
`;
89+
90+
writeFileSync(join(browser, 'sitemap.xml'), xml);
91+
console.log(`[postbuild] wrote ${join(browser, 'sitemap.xml')} (${LANGS.length * PAGES.length} urls)`);
92+
}
93+
94+
if (!existsSync(browser)) {
95+
console.warn(`[postbuild] skipped: ${browser} not found.`);
1396
process.exit(0);
1497
}
1598

16-
mkdirSync(browser, { recursive: true });
17-
copyFileSync(src, dest);
18-
console.log(`[postbuild] wrote ${dest}`);
99+
writeNotFound();
100+
dropRootIndex();
101+
writeSitemap();

0 commit comments

Comments
 (0)