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
10 changes: 10 additions & 0 deletions .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` (+ `/<lang>/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 `<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`.
- `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.
Expand Down
25 changes: 25 additions & 0 deletions functions/index.js
Original file line number Diff line number Diff line change
@@ -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);
}
9 changes: 9 additions & 0 deletions public/_redirects
Original file line number Diff line number Diff line change
@@ -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
22 changes: 22 additions & 0 deletions public/i18n/de.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
22 changes: 22 additions & 0 deletions public/i18n/en.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
22 changes: 22 additions & 0 deletions public/i18n/fr.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
22 changes: 22 additions & 0 deletions public/i18n/it.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
22 changes: 22 additions & 0 deletions public/i18n/rm.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
48 changes: 0 additions & 48 deletions public/sitemap.xml

This file was deleted.

105 changes: 94 additions & 11 deletions scripts/postbuild.ts
Original file line number Diff line number Diff line change
@@ -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();
3 changes: 2 additions & 1 deletion src/app/app.config.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -16,6 +16,7 @@ export const appConfig: ApplicationConfig = {
scrollPositionRestoration: 'top',
anchorScrolling: 'enabled',
}),
withRouterConfig({ paramsInheritanceStrategy: 'always' }),
),
provideClientHydration(withEventReplay()),
provideHttpClient(withFetch()),
Expand Down
10 changes: 7 additions & 3 deletions src/app/app.routes.server.ts
Original file line number Diff line number Diff line change
@@ -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 },
];
Loading