"""Search engines and AI assistants: crawlable content pages, sitemap, robots.txt, llms.txt, icons and head tags. The app itself is drawn by JavaScript, so crawlers that do not run scripts see an empty page. This module adds plain HTML pages built from the same data as the app (phrases.json, i18n.py, romansh.PLACES): // start page: what Romansh is, the idioms, all phrases, questions and answers // one idiom: region, villages, tip, phrases with audio // one phrase in all idioms ("How do you say “Good morning!” in Romansh?") for in en, de, fr, it (the app itself also speaks Romansh). Every page names its sources and licences: Pledari Grond data is CC BY 4.0 (attribution, licence link, changes indicated, no endorsement implied); model proposals (†) and the computer voice are non-commercial (CC BY-NC), like Clerina. """ import html import json from pathlib import Path import i18n from romansh import IDIOMS, PLACES, phrase_rows from map_view import COLORS, STANDARD_COLOR import brand SITE = "https://clerina.ch" UPDATED = "2026-10-24" # date the content and sources were last checked LANGS = ["en", "de", "fr", "it"] LOCALES = {"en": "en_GB", "de": "de_CH", "fr": "fr_CH", "it": "it_CH"} ORDER = ["rm-sursilv", "rm-sutsilv", "rm-surmiran", "rm-puter", "rm-vallader", "rm-rumgr"] IDIOM_SLUG = {"rm-sursilv": "sursilvan", "rm-sutsilv": "sutsilvan", "rm-surmiran": "surmiran", "rm-puter": "puter", "rm-vallader": "vallader", "rm-rumgr": "rumantsch-grischun"} AUTHOR = {"@type": "Person", "name": "Sandro Barbieri", "url": "https://www.linkedin.com/in/sandro-barbieri/", "sameAs": ["https://www.linkedin.com/in/sandro-barbieri/"], "affiliation": {"@type": "CollegeOrUniversity", "name": "University of Zurich"}} ROOT = Path(__file__).parent ICON_DIR = ROOT / "static" / "icons" AUDIO_DIR = ROOT / "audio" try: AUDIO = json.loads((AUDIO_DIR / "manifest.json").read_text(encoding="utf-8")) except Exception: AUDIO = {} esc = html.escape SLUGS = { "en": {"hello": "hello", "morning": "good-morning", "evening": "good-evening", "night": "good-night", "bye": "goodbye", "how": "how-are-you", "thanks": "thank-you", "please": "please", "excuse": "excuse-me", "appetite": "enjoy-your-meal", "cheers": "cheers", "count": "numbers-1-10"}, "de": {"hello": "hallo", "morning": "guten-morgen", "evening": "guten-abend", "night": "gute-nacht", "bye": "auf-wiedersehen", "how": "wie-geht-es-dir", "thanks": "danke", "please": "bitte", "excuse": "entschuldigung", "appetite": "guten-appetit", "cheers": "prost", "count": "zahlen-1-10"}, "fr": {"hello": "salut", "morning": "bonjour", "evening": "bonsoir", "night": "bonne-nuit", "bye": "au-revoir", "how": "comment-ca-va", "thanks": "merci", "please": "s-il-vous-plait", "excuse": "excusez-moi", "appetite": "bon-appetit", "cheers": "sante", "count": "chiffres-1-10"}, "it": {"hello": "ciao", "morning": "buongiorno", "evening": "buonasera", "night": "buonanotte", "bye": "arrivederci", "how": "come-stai", "thanks": "grazie", "please": "per-favore", "excuse": "scusi", "appetite": "buon-appetito", "cheers": "salute", "count": "numeri-1-10"}, } # Page texts. HTML-valued entries (suffix _html) are trusted. T = { "lang_name": {"en": "English", "de": "Deutsch", "fr": "Français", "it": "Italiano"}, "home_title": { "en": "Romansh phrases for every village in Graubünden | Clerina", "de": "Romanische Alltagssätze für jedes Dorf in Graubünden | Clerina", "fr": "Phrases romanches du quotidien pour chaque village des Grisons | Clerina", "it": "Frasi romance quotidiane per ogni villaggio dei Grigioni | Clerina", }, "home_desc": { "en": "Say hello in the local Romansh: everyday phrases and numbers in Sursilvan, Sutsilvan, Surmiran, Puter, " "Vallader and Rumantsch Grischun, with pronunciation and an idiom map.", "de": "Grüss auf Romanisch wie die Einheimischen: Alltagssätze und Zahlen in Sursilvan, Sutsilvan, Surmiran, " "Puter, Vallader und Rumantsch Grischun, mit Aussprache und Idiom-Karte.", "fr": "Saluez en romanche comme les habitants : phrases du quotidien et chiffres en sursilvan, sutsilvan, " "surmiran, puter, vallader et rumantsch grischun, avec prononciation et carte des idiomes.", "it": "Saluta in romancio come la gente del posto: frasi quotidiane e numeri in sursilvan, sutsilvan, " "surmiran, puter, vallader e rumantsch grischun, con pronuncia e mappa degli idiomi.", }, "home_h1": { "en": "Everyday Romansh for the village you are in", "de": "Romanisch für das Dorf, in dem du gerade bist", "fr": "Le romanche du village où vous êtes", "it": "Il romancio del villaggio in cui sei", }, "home_intro": { "en": "Romansh (rumantsch) is one of Switzerland’s four national languages. In Graubünden it is spoken in five " "regional idioms – Sursilvan, Sutsilvan, Surmiran, Puter and Vallader; Rumantsch Grischun is the " "common written standard. Locals appreciate a greeting in their own idiom. Clerina finds the idiom of " "the village you are in and shows {n} everyday phrases and the numbers 1–10, with pronunciation.", "de": "Romanisch (rumantsch) ist eine der vier Landessprachen der Schweiz. In Graubünden wird es in fünf " "regionalen Idiomen gesprochen – Sursilvan, Sutsilvan, Surmiran, Puter und Vallader; Rumantsch " "Grischun ist die gemeinsame Schriftsprache. Einheimische freuen sich über einen Gruss in ihrem eigenen " "Idiom. Clerina findet das Idiom des Dorfes, in dem du bist, und zeigt {n} Alltagssätze und die Zahlen " "1–10, mit Aussprache.", "fr": "Le romanche (rumantsch) est l’une des quatre langues nationales de la Suisse. Aux Grisons, il se parle " "en cinq idiomes régionaux – sursilvan, sutsilvan, surmiran, puter et vallader ; le rumantsch grischun " "est la langue écrite commune. Les habitants apprécient d’être salués dans leur propre idiome. Clerina " "trouve l’idiome du village où vous êtes et affiche {n} phrases du quotidien et les chiffres de 1 à 10, " "avec la prononciation.", "it": "Il romancio (rumantsch) è una delle quattro lingue nazionali della Svizzera. Nei Grigioni si parla in " "cinque idiomi regionali – sursilvan, sutsilvan, surmiran, puter e vallader; il rumantsch grischun è la " "lingua scritta comune. La gente del posto apprezza un saluto nel proprio idioma. Clerina trova " "l’idioma del villaggio in cui sei e mostra {n} frasi quotidiane e i numeri da 1 a 10, con la pronuncia.", }, "cta": {"en": "Open Clerina – find your village", "de": "Clerina öffnen – dein Dorf finden", "fr": "Ouvrir Clerina – trouver votre village", "it": "Apri Clerina – trova il tuo villaggio"}, "app": {"en": "Open the app", "de": "Zur App", "fr": "Ouvrir l’app", "it": "Apri l’app"}, "h_idioms": {"en": "The five Romansh idioms and the written standard", "de": "Die fünf romanischen Idiome und die Schriftsprache", "fr": "Les cinq idiomes romanches et la langue écrite", "it": "I cinque idiomi romanci e la lingua scritta"}, "h_table": {"en": "Everyday phrases in all idioms", "de": "Alltagssätze in allen Idiomen", "fr": "Phrases du quotidien dans tous les idiomes", "it": "Frasi quotidiane in tutti gli idiomi"}, "h_phrases": {"en": "Phrases", "de": "Sätze", "fr": "Phrases", "it": "Frasi"}, "col_meaning": {"en": "Meaning", "de": "Bedeutung", "fr": "Sens", "it": "Significato"}, "col_idiom": {"en": "Idiom", "de": "Idiom", "fr": "Idiome", "it": "Idioma"}, "col_phrase": {"en": "Romansh", "de": "Romanisch", "fr": "Romanche", "it": "Romancio"}, "listen": {"en": "Listen", "de": "Anhören", "fr": "Écouter", "it": "Ascolta"}, "h_faq": {"en": "Questions and answers", "de": "Fragen und Antworten", "fr": "Questions et réponses", "it": "Domande e risposte"}, "q": {"en": "How do you say “{p}” in Romansh?", "de": "Was heisst «{p}» auf Romanisch?", "fr": "Comment dit-on « {p} » en romanche ?", "it": "Come si dice «{p}» in romancio?"}, "q_count": {"en": "How do you count from 1 to 10 in Romansh?", "de": "Wie zählt man auf Romanisch von 1 bis 10?", "fr": "Comment compter de 1 à 10 en romanche ?", "it": "Come si conta da 1 a 10 in romancio?"}, "a_lead": {"en": "In Rumantsch Grischun, the written standard: “{rg}”. In the idioms: {list}.", "de": "In Rumantsch Grischun, der Schriftsprache: «{rg}». In den Idiomen: {list}.", "fr": "En rumantsch grischun, la langue écrite : « {rg} ». Dans les idiomes : {list}.", "it": "In rumantsch grischun, la lingua scritta: «{rg}». Negli idiomi: {list}."}, "a_same": {"en": "“{rg}” – the same in all five idioms and in Rumantsch Grischun.", "de": "«{rg}» – gleich in allen fünf Idiomen und in Rumantsch Grischun.", "fr": "« {rg} » – identique dans les cinq idiomes et en rumantsch grischun.", "it": "«{rg}» – uguale in tutti e cinque gli idiomi e in rumantsch grischun."}, "q_idioms": {"en": "How many Romansh idioms are there?", "de": "Wie viele romanische Idiome gibt es?", "fr": "Combien y a-t-il d’idiomes romanches ?", "it": "Quanti idiomi romanci ci sono?"}, "a_idioms": { "en": "Five: Sursilvan, Sutsilvan, Surmiran, Puter and Vallader. Rumantsch Grischun is not a sixth idiom but " "the common written standard used by the canton, media and schools.", "de": "Fünf: Sursilvan, Sutsilvan, Surmiran, Puter und Vallader. Rumantsch Grischun ist kein sechstes Idiom, " "sondern die gemeinsame Schriftsprache von Kanton, Medien und Schulen.", "fr": "Cinq : sursilvan, sutsilvan, surmiran, puter et vallader. Le rumantsch grischun n’est pas un sixième " "idiome, mais la langue écrite commune du canton, des médias et des écoles.", "it": "Cinque: sursilvan, sutsilvan, surmiran, puter e vallader. Il rumantsch grischun non è un sesto idioma, " "ma la lingua scritta comune usata da cantone, media e scuole.", }, "q_where": {"en": "Which Romansh idiom is spoken where?", "de": "Welches romanische Idiom spricht man wo?", "fr": "Quel idiome romanche parle-t-on où ?", "it": "Quale idioma romancio si parla dove?"}, "idiom_title": {"en": "{idiom}: everyday Romansh phrases with pronunciation | Clerina", "de": "{idiom}: romanische Alltagssätze mit Aussprache | Clerina", "fr": "{idiom} : phrases romanches du quotidien avec prononciation | Clerina", "it": "{idiom}: frasi romance quotidiane con pronuncia | Clerina"}, "idiom_h1": {"en": "Everyday phrases in {idiom}", "de": "Alltagssätze auf {idiom}", "fr": "Phrases du quotidien en {idiom}", "it": "Frasi quotidiane in {idiom}"}, "idiom_where": {"en": "{idiom} is the Romansh idiom of {region}.", "de": "{idiom} ist das romanische Idiom von {region}.", "fr": "Le {idiom} est l’idiome romanche de la région suivante : {region}.", "it": "Il {idiom} è l’idioma romancio di questa regione: {region}."}, "rg_where": {"en": "Rumantsch Grischun is the common written standard of Romansh, used by the canton, media and " "schools. Clerina shows it where no local idiom applies.", "de": "Rumantsch Grischun ist die gemeinsame Schriftsprache des Romanischen, verwendet von Kanton, " "Medien und Schulen. Clerina zeigt sie dort, wo kein lokales Idiom gilt.", "fr": "Le rumantsch grischun est la langue écrite commune du romanche, utilisée par le canton, les " "médias et les écoles. Clerina l’affiche là où aucun idiome local ne s’applique.", "it": "Il rumantsch grischun è la lingua scritta comune del romancio, usata da cantone, media e " "scuole. Clerina la mostra dove non vale nessun idioma locale."}, "idiom_desc": {"en": "{n} everyday phrases and the numbers 1–10 in {idiom}, with pronunciation.", "de": "{n} Alltagssätze und die Zahlen 1–10 auf {idiom}, mit Aussprache.", "fr": "{n} phrases du quotidien et les chiffres de 1 à 10 en {idiom}, avec prononciation.", "it": "{n} frasi quotidiane e i numeri da 1 a 10 in {idiom}, con pronuncia."}, "villages": {"en": "Villages in Clerina", "de": "Dörfer in Clerina", "fr": "Villages dans Clerina", "it": "Villaggi in Clerina"}, "tip": {"en": "Tip", "de": "Tipp", "fr": "Conseil", "it": "Consiglio"}, "other_idioms": {"en": "Other idioms", "de": "Andere Idiome", "fr": "Autres idiomes", "it": "Altri idiomi"}, "phrase_title": {"en": "“{p}” in Romansh – all idioms | Clerina", "de": "«{p}» auf Romanisch – alle Idiome | Clerina", "fr": "« {p} » en romanche – tous les idiomes | Clerina", "it": "«{p}» in romancio – tutti gli idiomi | Clerina"}, "count_title": {"en": "Numbers 1–10 in Romansh – all idioms | Clerina", "de": "Zahlen 1–10 auf Romanisch – alle Idiome | Clerina", "fr": "Les chiffres de 1 à 10 en romanche – tous les idiomes | Clerina", "it": "Numeri da 1 a 10 in romancio – tutti gli idiomi | Clerina"}, "more_phrases": {"en": "More phrases", "de": "Weitere Sätze", "fr": "Autres phrases", "it": "Altre frasi"}, "home": {"en": "Romansh phrases", "de": "Romanische Sätze", "fr": "Phrases romanches", "it": "Frasi romance"}, "h_sources": {"en": "Sources and licence", "de": "Quellen und Lizenz", "fr": "Sources et licence", "it": "Fonti e licenza"}, "sources_html": { "en": "Phrases and numbers for Rumantsch Grischun, Sursilvan, Sutsilvan and Surmiran: {pg} dictionary, " "© Lia Rumantscha, licensed under {ccby}. Clerina selected the entries and ranked them by how often " "they are used in Romansh texts ({corpora}); punctuation may be adapted. Entries marked † – mostly " "Puter and Vallader, whose dictionary cannot be searched here – were proposed by the {model} " "(CC BY-NC 4.0) and still need a check by native speakers. Audio: computer voice ({voice}, CC BY-NC), " "checked by a Romansh speech recogniser – a rough guide only. Clerina is an independent, non-commercial " "project; Lia Rumantscha and ZurichNLP do not endorse it. Last checked: {date}.", "de": "Sätze und Zahlen für Rumantsch Grischun, Sursilvan, Sutsilvan und Surmiran: Wörterbuch {pg}, " "© Lia Rumantscha, lizenziert unter {ccby}. Clerina hat die Einträge ausgewählt und nach ihrer " "Gebrauchshäufigkeit in romanischen Texten gewichtet ({corpora}); die Satzzeichen können angepasst sein. " "Mit † markierte Einträge – vor allem Puter und Vallader, deren Wörterbuch hier nicht durchsucht werden " "kann – stammen vom {model} (CC BY-NC 4.0) und müssen noch von Muttersprachler:innen geprüft werden. " "Audio: Computerstimme ({voice}, CC BY-NC), von einer romanischen Spracherkennung geprüft – nur eine " "grobe Orientierung. Clerina ist ein unabhängiges, nicht kommerzielles Projekt; die Lia Rumantscha und " "ZurichNLP stehen nicht dahinter. Zuletzt geprüft: {date}.", "fr": "Phrases et chiffres pour le rumantsch grischun, le sursilvan, le sutsilvan et le surmiran : " "dictionnaire {pg}, © Lia Rumantscha, sous licence {ccby}. Clerina a choisi les entrées et les a " "classées selon leur fréquence d’usage dans des textes romanches ({corpora}) ; la ponctuation peut être " "adaptée. Les entrées marquées † – surtout en puter et en vallader, dont le dictionnaire ne peut pas " "être consulté ici – ont été proposées par le {model} (CC BY-NC 4.0) et doivent encore être vérifiées " "par des locuteurs natifs. Audio : voix de synthèse ({voice}, CC BY-NC), vérifiée par une " "reconnaissance vocale romanche – un simple repère. Clerina est un projet indépendant et non " "commercial ; la Lia Rumantscha et ZurichNLP ne le cautionnent pas. Dernière vérification : {date}.", "it": "Frasi e numeri per rumantsch grischun, sursilvan, sutsilvan e surmiran: dizionario {pg}, " "© Lia Rumantscha, con licenza {ccby}. Clerina ha scelto le voci e le ha ordinate secondo la frequenza " "d’uso nei testi romanci ({corpora}); la punteggiatura può essere adattata. Le voci segnate con † – " "soprattutto puter e vallader, il cui dizionario qui non è consultabile – sono state proposte dal " "{model} (CC BY-NC 4.0) e devono ancora essere verificate da madrelingua. Audio: voce sintetica " "({voice}, CC BY-NC), verificata da un riconoscimento vocale romancio – solo un’indicazione. Clerina è " "un progetto indipendente e non commerciale; la Lia Rumantscha e ZurichNLP non lo sostengono " "ufficialmente. Ultima verifica: {date}.", }, "model_name": {"en": "ZurichNLP Romansh translation model", "de": "romanischen Übersetzungsmodell von ZurichNLP", "fr": "modèle de traduction romanche de ZurichNLP", "it": "modello di traduzione romancio di ZurichNLP"}, "footer_html": { "en": "Clerina – a prototype by Sandro Barbieri, University of Zurich, CAS Generative AI. {about}", "de": "Clerina – ein Prototyp von Sandro Barbieri, Universität Zürich, CAS Generative AI. {about}", "fr": "Clerina – un prototype de Sandro Barbieri, Université de Zurich, CAS Generative AI. {about}", "it": "Clerina – un prototipo di Sandro Barbieri, Università di Zurigo, CAS Generative AI. {about}", }, "about": {"en": "About, credits and imprint", "de": "Über Clerina, Quellen und Impressum", "fr": "À propos, crédits et mentions légales", "it": "Informazioni, crediti e note legali"}, } def tx(key, lang, **kw): s = T[key][lang] return s.format(**kw) if kw else s # ---------------------------------------------------------------- data helpers def keys(): return [k for _, _, k in phrase_rows("rm-rumgr")] def rows(code): """{key: (text, verified)} for one idiom.""" return {k: (text, ok) for text, ok, k in phrase_rows(code)} # French uses "Bonjour !" for both the greeting and "good morning"; the greeting page says "Salut !" instead. MEANING_OVERRIDE = {"fr": {"hello": "Salut !"}} def meaning(lang, key): """Meaning in the page language, without the explanation in brackets (e.g. "Hello!").""" if key in MEANING_OVERRIDE.get(lang, {}): return MEANING_OVERRIDE[lang][key] m = i18n.MEANINGS[lang][keys().index(key)] return m.split(" (")[0].strip() def n_phrases(): return sum(1 for k in keys() if k != "count") def color(code): return COLORS.get(code, STANDARD_COLOR) def villages(code): return sorted({p[0][0] for p in PLACES if p[3] == code}) def audio_src(code, key): clip = AUDIO.get(code, {}).get(key) if clip and (AUDIO_DIR / clip["file"]).exists(): return f"/audio/{clip['file']}" return None # ---------------------------------------------------------------- URLs def url_home(lang): return f"/{lang}/" def url_idiom(lang, code): return f"/{lang}/{IDIOM_SLUG[code]}" def url_phrase(lang, key): return f"/{lang}/{SLUGS[lang][key]}" def all_pages(): """[(path, kind, lang, item)] for every content page.""" out = [] for lang in LANGS: out.append((url_home(lang), "home", lang, None)) out += [(url_idiom(lang, c), "idiom", lang, c) for c in ORDER] out += [(url_phrase(lang, k), "phrase", lang, k) for k in keys()] return out def alternates(kind, item): """hreflang alternates for one page (same page in every language) plus x-default (English).""" def path(lang): return url_home(lang) if kind == "home" else url_idiom(lang, item) if kind == "idiom" else url_phrase(lang, item) links = [(lang, SITE + path(lang)) for lang in LANGS] return links + [("x-default", SITE + path("en"))] # ---------------------------------------------------------------- page parts CSS = f""" :root {{ --text: {brand.LIGHT['text']}; --muted: {brand.LIGHT['muted']}; --primary: {brand.LIGHT['primary']}; --border: {brand.LIGHT['border']}; --page: {brand.LIGHT['page']}; --surface: {brand.LIGHT['surface']}; }} * {{ box-sizing: border-box; }} body {{ margin: 0; font: 17px/1.6 Inter, "Helvetica Neue", Helvetica, Arial, sans-serif; color: var(--text); background: var(--page); }} a {{ color: var(--primary); }} .bar {{ background: #fff; border-bottom: 1px solid var(--border); }} .bar-in {{ display: flex; align-items: center; gap: 16px; flex-wrap: wrap; padding: 10px 0; }} .brand {{ display: inline-flex; align-items: center; gap: 8px; text-decoration: none; text-transform: uppercase; letter-spacing: .14em; font-weight: 900; font-size: .85rem; color: var(--primary); }} .brand i {{ width: 10px; height: 10px; background: {brand.GOLD}; }} .langs {{ margin-left: auto; display: flex; gap: 2px; }} .langs a {{ text-decoration: none; font-weight: 800; font-size: .8rem; padding: 4px 9px; color: var(--text); border: 1px solid var(--border); }} .langs a[aria-current] {{ background: {brand.SKY_DEEP}; color: #fff; border-color: {brand.SKY_DEEP}; }} .wrap {{ width: min(1100px, 100% - 32px); margin: 0 auto; }} .hero {{ background: linear-gradient(180deg, {brand.SKY_DEEP}, {brand.SKY} 70%, {brand.SKY_LIGHT}); color: #fff; padding: 40px 0 34px; }} .hero h1 {{ margin: 0 0 12px; font-size: clamp(1.8rem, 4vw, 2.7rem); line-height: 1.1; letter-spacing: -.02em; }} .hero p {{ max-width: 760px; margin: 0 0 18px; font-size: 1.08rem; color: #fff; }} .hero a {{ color: #fff; }} .crumbs {{ font-size: .85rem; margin-bottom: 10px; }} .crumbs a {{ color: {brand.AMBER_TEXT}; }} .cta {{ display: inline-block; background: #B50024; color: #fff !important; font-weight: 800; text-decoration: none; padding: 12px 20px; }} .cta:hover {{ background: #8F001C; }} main {{ padding: 8px 0 32px; }} h2 {{ margin: 34px 0 12px; font-size: 1.45rem; letter-spacing: -.01em; }} .answer {{ font-size: 1.12rem; background: #fff; border-left: 5px solid {brand.GOLD}; padding: 14px 18px; }} .scroll {{ overflow-x: auto; background: #fff; border: 1px solid var(--border); }} table {{ border-collapse: collapse; width: 100%; }} th, td {{ text-align: left; padding: 9px 12px; border-bottom: 1px solid var(--border); vertical-align: top; }} thead th {{ font-size: .78rem; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); }} tbody th {{ font-weight: 700; }} td small {{ display: block; color: var(--muted); }} .sw {{ display: inline-block; width: 11px; height: 11px; margin-right: 7px; vertical-align: -1px; }} .idioms {{ display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 12px; padding: 0; list-style: none; }} .idioms li {{ background: #fff; border: 1px solid var(--border); border-top: 5px solid var(--c); padding: 12px 14px; }} .idioms b {{ display: block; }} .play {{ border: 1px solid var(--primary); background: #fff; color: var(--primary); width: 30px; height: 30px; cursor: pointer; font-size: .8rem; }} .play:hover, .play.on {{ background: var(--primary); color: #fff; }} .note {{ font-size: .9rem; color: var(--muted); }} .tip {{ background: #fff; border: 1px solid var(--border); padding: 12px 16px; }} .faq dt {{ font-weight: 800; margin-top: 14px; }} .faq dd {{ margin: 4px 0 0; }} .chips {{ display: flex; flex-wrap: wrap; gap: 8px; padding: 0; list-style: none; }} .chips a {{ display: inline-block; background: #fff; border: 1px solid var(--border); padding: 5px 10px; text-decoration: none; }} .sources {{ font-size: .9rem; color: var(--muted); background: #fff; border: 1px solid var(--border); padding: 14px 18px; }} footer {{ border-top: 1px solid var(--border); background: #fff; padding: 16px 0; font-size: .9rem; color: var(--muted); }} """ PLAY_JS = """ document.addEventListener('click', e => { const b = e.target.closest('.play'); if (!b) return; const a = window.__a || (window.__a = new Audio()); document.querySelectorAll('.play.on').forEach(x => x.classList.remove('on')); if (a.dataset.src === b.dataset.src && !a.paused) { a.pause(); return; } a.src = a.dataset.src = b.dataset.src; a.play(); b.classList.add('on'); a.onended = () => b.classList.remove('on'); }); """ def play(lang, code, key, text): src = audio_src(code, key) if not src: return "" label = f"{tx('listen', lang)}: {text}" return f'' def romansh_cell(code, key): text, ok = rows(code)[key] return esc(text) + ("" if ok else " †") def sources_html(lang): link = lambda href, text: f'{esc(text)}' # noqa: E731 return tx("sources_html", lang, pg=link("https://www.pledarigrond.ch", "Pledari Grond"), ccby=link("https://creativecommons.org/licenses/by/4.0/", "CC BY 4.0"), corpora=link("https://huggingface.co/ZurichNLP", "ZurichNLP"), model=link("https://huggingface.co/ZurichNLP/romansh-nllb-1.3b-ct2", tx("model_name", lang)), voice=link("https://huggingface.co/k2-fsa/OmniVoice", "OmniVoice"), date=UPDATED) def jsonld(*objs): return "".join(f'' for o in objs) def app_ld(lang=None): return {"@context": "https://schema.org", "@type": "WebApplication", "name": "Clerina", "url": SITE + "/", "description": T["home_desc"][lang or "en"], "applicationCategory": "EducationalApplication", "operatingSystem": "Any (web browser)", "isAccessibleForFree": True, "offers": {"@type": "Offer", "price": "0", "priceCurrency": "CHF"}, "inLanguage": ["en", "de", "fr", "it", "rm"], "author": AUTHOR, "creator": AUTHOR, "image": SITE + "/preview.png"} def site_ld(): return {"@context": "https://schema.org", "@type": "WebSite", "name": "Clerina", "url": SITE + "/", "inLanguage": ["en", "de", "fr", "it", "rm"], "publisher": AUTHOR} def faq_ld(pairs): return {"@context": "https://schema.org", "@type": "FAQPage", "mainEntity": [{"@type": "Question", "name": q, "acceptedAnswer": {"@type": "Answer", "text": a}} for q, a in pairs]} def crumbs_ld(items): return {"@context": "https://schema.org", "@type": "BreadcrumbList", "itemListElement": [{"@type": "ListItem", "position": i + 1, "name": name, "item": SITE + path} for i, (name, path) in enumerate(items)]} def head_icons(): return ('' '' '' '') def page(lang, path, kind, item, title, desc, crumbs, hero_html, body_html, ld): alt = "".join(f'' for h, u in alternates(kind, item)) current = ' aria-current="page"' langs = "".join( f'{h.upper()}' for h, u in alternates(kind, item) if h != "x-default") crumb_html = "" if crumbs: crumb_html = '" about = f'{esc(tx("about", lang))}' return f""" {esc(title)} {alt} {head_icons()} {jsonld(*ld)}
{crumb_html}{hero_html}
{body_html}

{esc(tx("h_sources", lang))}

{sources_html(lang)}

{tx("footer_html", lang, about=about)}
""" # ---------------------------------------------------------------- answers def answer(lang, key): """Plain-text answer for "How do you say … in Romansh?" (also used for the FAQ structured data).""" rg = rows("rm-rumgr")[key][0] others = [(IDIOMS[c], rows(c)[key][0]) for c in ORDER if c != "rm-rumgr"] if all(t == rg for _, t in others): return tx("a_same", lang, rg=rg) q = {"en": "“{}”", "de": "«{}»", "fr": "« {} »", "it": "«{}»"}[lang] return tx("a_lead", lang, rg=rg, list=", ".join(f"{n} {q.format(t)}" for n, t in others)) def question(lang, key): return tx("q_count", lang) if key == "count" else tx("q", lang, p=meaning(lang, key)) def faq_pairs(lang): pairs = [(tx("q_idioms", lang), tx("a_idioms", lang)), (tx("q_where", lang), " ".join(f"{IDIOMS[c]}: {i18n.REGIONS[lang][c]}." for c in ORDER))] pairs += [(question(lang, k), answer(lang, k)) for k in ("hello", "morning", "thanks", "bye", "cheers")] return pairs # ---------------------------------------------------------------- pages def home_page(lang): path = url_home(lang) idiom_cards = "".join( f'
  • {esc(IDIOMS[c])}' f'{esc(i18n.REGIONS[lang][c])}
  • ' for c in ORDER) head = "".join(f'' f'{esc(IDIOMS[c])}' for c in ORDER) body_rows = "".join( f'{esc(meaning(lang, k))}' + "".join(f"{romansh_cell(c, k)}" for c in ORDER) + "" for k in keys()) faq = "".join(f"
    {esc(q)}
    {esc(a)}
    " for q, a in faq_pairs(lang)) body = f"""

    {esc(tx("h_idioms", lang))}

      {idiom_cards}

    {esc(tx("h_table", lang))}

    {head}{body_rows}
    {esc(tx("col_meaning", lang))}

    † {esc(i18n.S["legend_model"][lang])}

    {esc(tx("h_faq", lang))}

    {faq}
    """ hero = (f'

    {esc(tx("home_h1", lang))}

    {esc(tx("home_intro", lang, n=n_phrases()))}

    ' f'{esc(tx("cta", lang))}') ld = [site_ld(), app_ld(lang), faq_ld(faq_pairs(lang))] return page(lang, path, "home", None, tx("home_title", lang), tx("home_desc", lang), None, hero, body, ld) def idiom_page(lang, code): path = url_idiom(lang, code) name = IDIOMS[code] where = tx("rg_where", lang) if code == "rm-rumgr" else tx("idiom_where", lang, idiom=name, region=i18n.REGIONS[lang][code]) data = rows(code) table = "".join( f'{play(lang, code, k, data[k][0])}{romansh_cell(code, k)}' f'{esc(meaning(lang, k))}' for k in keys()) vill = villages(code) vill_html = (f'

    {esc(tx("villages", lang))}

    {esc(", ".join(vill))}

    ' if vill else "") others = "".join(f'
  • ' f'{esc(IDIOMS[c])}
  • ' for c in ORDER if c != code) body = f"""

    {esc(tx("h_phrases", lang))}

    {table}
    {esc(name)} {esc(tx("col_meaning", lang))}

    ▶ {esc(i18n.S["audio_tts"][lang])}

    † {esc(i18n.S["legend_model"][lang])}

    {esc(tx("tip", lang))}

    {esc(i18n.TIPS[lang][code])}

    {vill_html}

    {esc(tx("other_idioms", lang))}

      {others}
    """ crumbs = [(tx("home", lang), url_home(lang)), (name, path)] hero = (f'

    {esc(tx("idiom_h1", lang, idiom=name))}

    {esc(where)}

    ' f'{esc(tx("cta", lang))}') desc = f'{where} {tx("idiom_desc", lang, n=n_phrases(), idiom=name)}' ld = [crumbs_ld(crumbs), app_ld(lang)] return page(lang, path, "idiom", code, tx("idiom_title", lang, idiom=name), desc, crumbs, hero, body, ld) def phrase_page(lang, key): path = url_phrase(lang, key) q = question(lang, key) a = answer(lang, key) table = "".join( f'{play(lang, c, key, rows(c)[key][0])}' f'' f'{esc(IDIOMS[c])}{romansh_cell(c, key)}' for c in ORDER) more = "".join(f'
  • {esc(meaning(lang, k))}
  • ' for k in keys() if k != key) body = f"""

    {esc(a)}

    {table}
    {esc(tx("col_idiom", lang))} {esc(tx("col_phrase", lang))}

    ▶ {esc(i18n.S["audio_tts"][lang])}

    † {esc(i18n.S["legend_model"][lang])}

    {esc(tx("more_phrases", lang))}

      {more}
    """ title = tx("count_title", lang) if key == "count" else tx("phrase_title", lang, p=meaning(lang, key)) crumbs = [(tx("home", lang), url_home(lang)), (meaning(lang, key), path)] hero = f'

    {esc(q)}

    {esc(tx("cta", lang))}' ld = [crumbs_ld(crumbs), faq_ld([(q, a)])] return page(lang, path, "phrase", key, title, a[:300], crumbs, hero, body, ld) def render(path): """HTML for a content page path, or None.""" for p, kind, lang, item in all_pages(): if p == path or (kind == "home" and path == p.rstrip("/")): return home_page(lang) if kind == "home" else idiom_page(lang, item) if kind == "idiom" else phrase_page(lang, item) return None # ---------------------------------------------------------------- crawler files AI_BOTS = ["GPTBot", "OAI-SearchBot", "ChatGPT-User", "ClaudeBot", "Claude-SearchBot", "Claude-User", "PerplexityBot", "Perplexity-User", "Google-Extended", "Applebot-Extended", "CCBot", "Amazonbot", "meta-externalagent", "MistralAI-User", "DuckAssistBot", "Bytespider"] def robots_txt(): lines = ["# Clerina welcomes search engines and AI assistants.", "User-agent: *", "Allow: /", ""] for bot in AI_BOTS: lines += [f"User-agent: {bot}", "Allow: /", ""] return "\n".join(lines + [f"Sitemap: {SITE}/sitemap.xml", ""]) def sitemap_xml(): urls = [f"{SITE}/{UPDATED}", f"{SITE}/about{UPDATED}"] for path, kind, lang, item in all_pages(): alt = "".join(f'' for h, u in alternates(kind, item)) urls.append(f"{SITE}{path}{UPDATED}{alt}") return ('\n' '\n' + "\n".join(urls) + "\n\n") def phrases_md(): head = "| Meaning | " + " | ".join(IDIOMS[c] for c in ORDER) + " |" lines = [head, "|" + "---|" * (len(ORDER) + 1)] for k in keys(): lines.append(f"| {meaning('en', k)} | " + " | ".join(romansh_cell(c, k) for c in ORDER) + " |") regions = "\n".join(f"- **{IDIOMS[c]}**: {i18n.REGIONS['en'][c]} – {SITE}{url_idiom('en', c)}" for c in ORDER) return f"""# Everyday Romansh phrases in all idioms (Clerina) Romansh has five regional idioms – Sursilvan, Sutsilvan, Surmiran, Puter, Vallader – and Rumantsch Grischun, the common written standard (not a sixth idiom). {regions} {chr(10).join(lines)} † = not found in the Pledari Grond dictionary; proposed by the ZurichNLP Romansh translation model and ranked by corpus frequency; still needs a check by native speakers. ## Sources and licence Phrases and numbers for Rumantsch Grischun, Sursilvan, Sutsilvan and Surmiran: Pledari Grond dictionary (https://www.pledarigrond.ch), © Lia Rumantscha, licensed under CC BY 4.0 (https://creativecommons.org/licenses/by/4.0/). Selected by Clerina and ranked by frequency of use in ZurichNLP's Romansh corpora; punctuation may be adapted. Entries marked † come from the ZurichNLP Romansh NLLB model (CC BY-NC 4.0). Clerina is independent and non-commercial; Lia Rumantscha and ZurichNLP do not endorse it. Last checked: {UPDATED}. Source page: {SITE}/en/ """ def llms_txt(): pages = "\n".join(f"- [{meaning('en', k)} in Romansh]({SITE}{url_phrase('en', k)})" for k in keys()) idioms = "\n".join(f"- [{IDIOMS[c]}]({SITE}{url_idiom('en', c)}): {i18n.REGIONS['en'][c]}" for c in ORDER) return f"""# Clerina > Clerina shows visitors to Graubünden (Switzerland) the everyday Romansh phrases of the village they are in – in the > local idiom, with pronunciation and a map. Prototype by Sandro Barbieri, University of Zurich, CAS Generative AI. Key facts: - Romansh (rumantsch) is one of Switzerland's four national languages, spoken mainly in Graubünden. - It has five regional idioms: Sursilvan, Sutsilvan, Surmiran, Puter and Vallader. - Rumantsch Grischun is the common written standard used by the canton, media and schools – not a sixth idiom. - The classic greeting in all idioms is "Allegra!"; "Good morning" is "Bun di!" in Rumantsch Grischun. - Phrases are checked against the Pledari Grond dictionary of Lia Rumantscha (CC BY 4.0); unverified entries are marked †. ## Pages - [App: find your village and its idiom]({SITE}/) - [Romansh phrases in all idioms (English)]({SITE}/en/) – also in [German]({SITE}/de/), [French]({SITE}/fr/), [Italian]({SITE}/it/) - [About, sources and credits]({SITE}/about) ## Idioms {idioms} ## Phrases {pages} ## Data - [All phrases as a Markdown table, with sources]({SITE}/romansh-phrases.md) """ def manifest_json(): return {"name": "Clerina – everyday Romansh", "short_name": "Clerina", "start_url": "/", "display": "standalone", "background_color": brand.LIGHT["page"], "theme_color": brand.SKY_DEEP, "lang": "en", "description": T["home_desc"]["en"], "icons": [{"src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png"}, {"src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png"}, {"src": "/favicon.svg", "sizes": "any", "type": "image/svg+xml"}]} # ---------------------------------------------------------------- head tags and fallback text for the app pages APP_PAGES = { "/": {"title": "Clerina – Romansh phrases for the village you are in", "desc": T["home_desc"]["en"]}, "/about": {"title": "About Clerina – sources, credits and how it was built", "desc": "How Clerina was built: Romansh sources (Pledari Grond, Lia Rumantscha, ZurichNLP), " "technologies, credits and imprint."}, } def app_head(path): """Extra tags for the Gradio pages ("/" and "/about"); empty for other paths.""" info = APP_PAGES.get(path.rstrip("/") or "/") if not info: return "" canonical = SITE + (path.rstrip("/") or "/") ld = [site_ld(), app_ld()] if canonical == SITE + "/" else [crumbs_ld([("Clerina", "/"), ("About", "/about")])] return (f'{esc(info["title"])}' f'{head_icons()}{jsonld(*ld)}') def app_noscript(path, about_html=None): """Text for crawlers that do not run JavaScript (shown only when scripts are off).""" p = path.rstrip("/") or "/" if p == "/about" and about_html: return f"" if p != "/": return "" links = " · ".join(f'{esc(T["home"][lang])} ({T["lang_name"][lang]})' for lang in LANGS) idioms = " · ".join(f'{esc(IDIOMS[c])}' for c in ORDER) return (f'")