Serve the public pages in Spanish, starting with /get (relay#47)
Sign-up is open in seven countries and six of them are Spanish-speaking, so
/get was explaining the geo restriction in English to exactly the people
being told they can sign up. That page is translated; the mechanism is the
part that lasts.
A page is bilingual the moment somebody writes `es/<slug>.leaf` and not one
deploy sooner: the route, the hreflang pair and the sitemap entry are all
derived from the template existing, so nothing can advertise a page that
404s. `spec` is excluded by hand -- it is the contract implementers build
against and a translated copy would drift in silence.
Four things only showed up by actually serving it, none of which any test
would have caught:
* Leaf reads the last dot as the extension, so `render("get.es")` 500s
with "No template found for get.es". Hence `es/<slug>.leaf`.
* `#if(altHref)` is true for an empty string, so every English page
advertised an alternate, half of them pointing at nothing. Now a Bool.
* The nav and footer are in the shared layout, so the Spanish page
rendered with an entirely English chrome. Those ~40 strings now come
from the context, with a test that every one has a Spanish counterpart.
* Leaf escapes what it interpolates, so ` ` in a chrome string
arrives as `&nbsp;` and prints as text. Real characters only.
And moving the chrome into the context broke /donate, /status and /releases
until they carried it too -- ReleasesContext twice, because it encodes by
hand and drops any field missing from its CodingKeys. Same trap as the
canonical earlier today, from the same cause, so the comment above that
list now says so.
691 tests pass. Verified by serving both languages: every page keeps its
chrome, untranslated pages advertise no alternate, and the hreflang pair
reciprocates.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
50afaa5efd
commit
419fc9e797
@@ -0,0 +1,303 @@
|
||||
#extend("layout"):
|
||||
#export("content"):
|
||||
|
||||
<section class="band">
|
||||
<div class="wrap">
|
||||
<span class="eyebrow">Descargar</span>
|
||||
<h1>Descarga <span class="hl">Flamenet Messenger</span></h1>
|
||||
<p class="band-lede">
|
||||
Android ya se puede instalar. La versión para iPhone todavía se está
|
||||
preparando — esta página lo dirá en cuanto esté disponible.
|
||||
</p>
|
||||
<div class="platforms">
|
||||
<!-- Reads "Not open yet" until it is. Do not describe a submission that
|
||||
has not happened; see git history for why this wording is careful. -->
|
||||
<span class="platform platform-soon">
|
||||
<span class="platform-name">iPhone</span>
|
||||
<span class="platform-state">Próximamente</span>
|
||||
</span>
|
||||
<!-- 🔴 **Pulled 2026-08-31, restored 2026-09-01 with 0.8.** The history
|
||||
matters more than the markup, so it stays written down here.
|
||||
|
||||
0.7 crashed the moment somebody answered a call: WebRTC's native
|
||||
library aborts when it cannot find the classes its JNI_OnLoad looks
|
||||
up by name, and the release build had no `-keep` rules for
|
||||
`org.webrtc`, so R8 renamed them. Messages sent from Android were
|
||||
also not arriving. Neither is something to hand anybody.
|
||||
|
||||
What has changed: `-keep class org.webrtc.**` is in
|
||||
`app/proguard-rules.pro`, and 0.8 was exercised as a RELEASE build —
|
||||
R8 applied, the same conditions that produced the crash — placing and
|
||||
ANSWERING calls in both directions, and delivering messages, group
|
||||
messages and status both ways. The file in downloads/ is that build
|
||||
(versionCode 8, sha256 000a9618…, verified as served rather than as
|
||||
copied).
|
||||
|
||||
⚠️ **The residual, stated plainly:** that testing was an Android
|
||||
emulator against an iOS simulator, not two physical handsets. Push
|
||||
notifications, VoIP wake-ups and the microphone cannot be exercised
|
||||
anywhere else, so those three remain unproven on real hardware — see
|
||||
flamenet-messenger-ios#7. If this link has to come down again, that
|
||||
is the likeliest reason. -->
|
||||
<a class="platform" href="/downloads/flamenet-messenger.apk" download>
|
||||
<span class="platform-name">Android</span>
|
||||
<!-- Filled in from downloads/android-latest.json, the same file the app
|
||||
itself reads, so the number here cannot drift from the file being
|
||||
served. Falls back to the generic label if that fetch fails. -->
|
||||
<span class="platform-state" data-apk-version>Descargar la aplicación (APK)</span>
|
||||
</a>
|
||||
<a class="platform" href="/self-hosting">
|
||||
<span class="platform-name">Servidor</span>
|
||||
<span class="platform-state">Listo — monta el tuyo</span>
|
||||
</a>
|
||||
<a class="platform" href="/source">
|
||||
<span class="platform-name">Código fuente</span>
|
||||
<span class="platform-state">Lee el código</span>
|
||||
</a>
|
||||
</div>
|
||||
<p class="fineprint">
|
||||
<strong>Disponible en Estados Unidos, México, Guatemala, El Salvador, Honduras, Perú y Bolivia.</strong>
|
||||
Desde cualquier otro país no se puede crear una cuenta: la aplicación lo dirá
|
||||
claramente en lugar de parecer que está averiada. Esta página se puede leer desde
|
||||
cualquier lugar a propósito — enterarse después de haber instalado algo y de haber
|
||||
elegido una contraseña es una forma peor de descubrirlo.
|
||||
</p>
|
||||
<p class="fineprint">
|
||||
<strong>¿Tienes instalada una versión anterior? Reemplázala.</strong> La versión 0.11
|
||||
corrige mensajes que podían no enviarse a alguien que había reinstalado la
|
||||
aplicación — con un error que culpaba al servidor, cuando era tu propio
|
||||
teléfono el que se negaba a enviar a unas claves que ya no podía comprobar.
|
||||
También evita que una foto enviada como archivo lleve consigo el lugar donde se
|
||||
tomó. Instalar encima conserva tu cuenta y tus conversaciones.
|
||||
<a href="/releases">Qué ha cambiado</a>.
|
||||
</p>
|
||||
<p class="fineprint">
|
||||
<!-- Said before somebody waits for something they cannot install. The
|
||||
app needs iOS 26 because that is the first version Apple ships the
|
||||
post-quantum maths in, and doing it any other way would have meant
|
||||
carrying our own implementation of the algorithm. -->
|
||||
La aplicación para iPhone necesitará <strong>iOS 26</strong> o posterior. Es la primera
|
||||
versión en la que Apple incluye el cifrado poscuántico, y preferimos pedir un
|
||||
teléfono reciente antes que escribir nosotros mismos esa parte de las matemáticas.
|
||||
</p>
|
||||
<!-- ⚠️ Desktop only, by CSS. On a phone this points at the page the reader
|
||||
is already on, which is a puzzle rather than a shortcut — the download
|
||||
button is right there. Its whole job is getting somebody reading on a
|
||||
laptop onto their phone, which is where the app has to end up. -->
|
||||
<div class="qr-hop">
|
||||
<img src="/qr/flamenet-download.svg" alt="QR code linking to this page"
|
||||
width="132" height="132">
|
||||
<p>
|
||||
<strong>¿Estás leyendo esto en una computadora?</strong>
|
||||
Escanea esto con la cámara de tu teléfono para abrir esta página ahí — la
|
||||
aplicación tiene que acabar en el teléfono en el que de verdad la vas a usar.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="band band-alt">
|
||||
<div class="wrap">
|
||||
<div class="install-steps">
|
||||
<h3>Lo que te va a preguntar el teléfono, y qué tocar</h3>
|
||||
<p class="install-lede">
|
||||
Android pone tres avisos entre tú y cualquier aplicación que no venga de Play
|
||||
Store. Los tres son normales aquí. Ninguno significa que esta aplicación tenga
|
||||
algo malo — significan que llegó como archivo y no a través de una tienda, y
|
||||
Android dice lo mismo de todas las que llegan así.
|
||||
</p>
|
||||
<ol>
|
||||
<li>
|
||||
<strong>“Este archivo puede ser dañino”</strong> — lo muestra tu navegador al empezar la descarga. Elige <strong>Descargar de
|
||||
todas formas</strong>. Chrome lo muestra para los archivos de aplicación en
|
||||
general, los haya hecho quien los haya hecho; habla de cómo te llegó el
|
||||
archivo, no de lo que contiene.
|
||||
</li>
|
||||
<li>
|
||||
<strong>“Permitir instalar desde esta fuente”</strong> — entra en el ajuste y actívalo para tu navegador. Este es el que de verdad
|
||||
merece la pena entender: es el permiso que permite instalar cualquier cosa de
|
||||
fuera de Play Store, y es razonable que Android te haga decirlo a propósito.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Play Protect puede decir que no reconoce al desarrollador</strong>
|
||||
— elige <strong>Instalar de todas formas</strong>. Significa que Google no ha
|
||||
visto antes esta aplicación, lo cual es cierto: no está en Play Store.
|
||||
</li>
|
||||
</ol>
|
||||
<p class="install-verify">
|
||||
<strong>Si prefieres comprobarlo en vez de creernos,</strong> el archivo que descargues debe tener esta huella, y la aplicación instalada debe
|
||||
estar firmada por nosotros:
|
||||
</p>
|
||||
<ul class="install-hashes">
|
||||
<li>
|
||||
Archivo (SHA-256): <code data-apk-sha>publicada junto a la descarga</code><br>
|
||||
<span class="install-cmd">comprueba con <code>shasum -a 256 flamenet-messenger.apk</code></span>
|
||||
</li>
|
||||
<li>
|
||||
Firmada por <code>CN=Flamenet Messenger, O=Extant 2000 LLC</code>, certificado SHA-256
|
||||
<code>791fc826d572a70abfaf7db7fa7784dad0f86f204bb313f0d75ee676df8d3a1d</code><br>
|
||||
<span class="install-cmd">comprueba con <code>apksigner verify --print-certs flamenet-messenger.apk</code></span>
|
||||
</li>
|
||||
</ul>
|
||||
<p class="install-foot">
|
||||
No podemos hacer que estos avisos desaparezcan — solo desaparecerían publicando
|
||||
en Play Store, y eso significaría entregarle a Google la lista de quién instala
|
||||
esto. Es un intercambio que preferimos no hacer, así que preferimos explicar los
|
||||
avisos antes que evitarlos.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="band">
|
||||
<div class="wrap">
|
||||
<!-- 🔴 **Beside the direct download, not instead of it.** F-Droid is the
|
||||
better route — it updates the app for you and the scary prompts happen
|
||||
once, when you install F-Droid itself, rather than on every release —
|
||||
but it costs a person an extra app before they have ours. Somebody who
|
||||
just wants the file should still get the file. -->
|
||||
<div class="fdroid-block">
|
||||
<h3>F-Droid, cuando la aplicación vuelva</h3>
|
||||
<p class="fdroid-note">
|
||||
<strong>Esta vía también está en pausa.</strong> El repositorio sirve la misma versión que la descarga directa, así que arrastra
|
||||
el mismo fallo — pausar una y dejar la otra sería solo una forma más silenciosa
|
||||
de darle a alguien una aplicación rota. Dejamos aquí las instrucciones porque no
|
||||
cambian.
|
||||
</p>
|
||||
<p>
|
||||
<a href="https://f-droid.org/">F-Droid</a> es una tienda de aplicaciones de código abierto para Android. Añadir nuestro
|
||||
repositorio significa que las actualizaciones llegan solas, y que los avisos de
|
||||
Android sobre aplicaciones desconocidas ocurren una vez — al instalar F-Droid —
|
||||
en lugar de cada vez que publicamos una versión.
|
||||
</p>
|
||||
<p class="fdroid-steps">
|
||||
Instala F-Droid y luego abre este enlace en tu teléfono:
|
||||
</p>
|
||||
<p class="fdroid-url">
|
||||
<a class="btn btn-outline" data-fdroid-link
|
||||
href="fdroidrepos://flamenetmessenger.com/fdroid/repo?fingerprint=DD342CC7F498FDE05BE63EFDDB56ED731BAD70BB32AC1D6D8DBAB0EA7511F38B">
|
||||
Añadir el repositorio a F-Droid
|
||||
</a>
|
||||
</p>
|
||||
<p class="fdroid-manual">
|
||||
O añádelo a mano:
|
||||
<code>https://flamenetmessenger.com/fdroid/repo</code><br>
|
||||
<span class="fdroid-fp">
|
||||
Huella
|
||||
<code>DD342CC7F498FDE05BE63EFDDB56ED731BAD70BB32AC1D6D8DBAB0EA7511F38B</code>
|
||||
— F-Droid lo comprueba al añadir el repositorio, y eso es lo que impide que
|
||||
alguien capaz de interferir en tu conexión te entregue otro distinto.
|
||||
</span>
|
||||
</p>
|
||||
<p class="fdroid-note">
|
||||
Las versiones de aquí son los mismos archivos firmados que la descarga directa —
|
||||
este es nuestro propio repositorio, no una ficha en f-droid.org, así que la
|
||||
aplicación conserva nuestra firma y se instala encima de la que ya tengas. Por
|
||||
eso también está en pausa: mismo archivo, mismo fallo.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="band band-alt">
|
||||
<div class="wrap">
|
||||
<h2>Cómo saber que de verdad es nuestra</h2>
|
||||
<p>
|
||||
El <a href="/source">código es público</a> y la licencia permite que cualquiera
|
||||
lo recompile, así que “parece Flamenet Messenger” no demuestra nada
|
||||
por sí solo. Esto es lo que de verdad lo resuelve, de más fuerte a menos.
|
||||
</p>
|
||||
|
||||
<h3>1. La clave de firma — y esta Android la aplica por ti</h3>
|
||||
<p>
|
||||
Cada versión que publicamos está firmada con una clave que solo tenemos nosotros:
|
||||
</p>
|
||||
<p class="verify-line">
|
||||
<code>CN=Flamenet Messenger, O=Extant 2000 LLC</code><br>
|
||||
certificado SHA-256
|
||||
<code>791fc826d572a70abfaf7db7fa7784dad0f86f204bb313f0d75ee676df8d3a1d</code>
|
||||
</p>
|
||||
<p>
|
||||
Esto no es solo una etiqueta que puedas comprobar — Android actúa en
|
||||
consecuencia. Una aplicación firmada con otra clave <strong>no puede</strong> instalarse encima de una firmada con la nuestra; el teléfono se niega sin más. Así
|
||||
que la recompilación de otra persona puede existir, pero no puede convertirse en
|
||||
una actualización de la aplicación que ya tienes, ni ocupar su lugar sin que te
|
||||
enteres. Compruébalo tú mismo con
|
||||
<code>apksigner verify --print-certs flamenet-messenger.apk</code>.
|
||||
</p>
|
||||
|
||||
<h3>2. La versión publicada coincide con el código publicado</h3>
|
||||
<p>
|
||||
Una firma demuestra quién publicó un archivo, no qué se metió dentro. Por eso la
|
||||
versión de Android es <strong>reproducible</strong>: compila tú mismo el código público y cada entrada del APK resultante es idéntica
|
||||
byte a byte a la que publicamos. Solo cambia el bloque de la firma, porque firmar
|
||||
lleva aleatoriedad — por eso comparar el hash del archivo entero es la
|
||||
prueba equivocada, y por eso hay un script que compara lo correcto:
|
||||
</p>
|
||||
<pre class="verify-code">./gradlew clean :app:assembleRelease
|
||||
python3 scripts/verify-build.py \
|
||||
app/build/outputs/apk/release/app-release.apk \
|
||||
~/Downloads/flamenet-messenger.apk</pre>
|
||||
<p>
|
||||
<strong>CONTENTS MATCH</strong>, junto con el certificado de arriba, es la cadena completa: los bytes que hay en
|
||||
tu teléfono son los bytes que produjo tu propio compilador a partir del <a href="/source">código que puedes leer</a>, publicados con la clave que tenemos nosotros. No tienes que creernos en ninguna
|
||||
de las dos mitades.
|
||||
</p>
|
||||
|
||||
<h3>3. El repositorio de F-Droid está fijado por partida doble</h3>
|
||||
<p>
|
||||
Nuestro repositorio se niega a servir cualquier versión que no esté firmada con el
|
||||
certificado de arriba, y su índice está firmado con una clave aparte que F-Droid
|
||||
fija la primera vez que añades el repositorio:
|
||||
</p>
|
||||
<p class="verify-line">
|
||||
huella del repositorio
|
||||
<code>DD342CC7F498FDE05BE63EFDDB56ED731BAD70BB32AC1D6D8DBAB0EA7511F38B</code>
|
||||
</p>
|
||||
<p>
|
||||
Esa fijación es lo que impide que alguien capaz de interferir en tu conexión
|
||||
sustituya después el repositorio por otro — pero solo funciona si la huella
|
||||
te llegó por una vía que esa persona no controla, y por eso está impresa aquí y no
|
||||
solo la entrega el propio repositorio.
|
||||
</p>
|
||||
|
||||
<h3>Lo que nada de esto impide, dicho claramente</h3>
|
||||
<p>
|
||||
Cualquiera puede bifurcar esto y publicar su propia versión — la licencia lo
|
||||
permite y no querríamos que fuera de otro modo. Lo que no puede hacer es firmarla
|
||||
como nosotros, actualizar una instalación nuestra, ni pasar la comprobación de
|
||||
arriba contra nuestra huella.
|
||||
</p>
|
||||
<p>
|
||||
El momento débil es tu <em>primera</em> instalación, cuando no tienes nada anterior con qué comparar y dependes de haber
|
||||
llegado al sitio auténtico. Si alguien te envió aquí, merece la pena comparar las
|
||||
huellas de arriba con una copia obtenida por otra vía — otro dispositivo,
|
||||
otra red, alguien en quien ya confíes.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="band">
|
||||
<div class="wrap">
|
||||
<h2>Un par de cosas más que conviene saber</h2>
|
||||
<p class="fineprint">
|
||||
Como se instala directamente, nada la actualiza por ti. Desde la versión 0.2 la
|
||||
aplicación comprueba si se ha publicado una versión más nueva y te avisa, y hay un
|
||||
botón
|
||||
<strong>Buscar actualizaciones</strong> en Cuenta por si prefieres preguntar tú. Se lo pregunta al servidor en el que has
|
||||
iniciado sesión, no a nosotros — un servidor propio que no publique ninguna
|
||||
versión simplemente no genera ningún aviso. Instalar un APK más nuevo encima
|
||||
conserva tus mensajes y tu cuenta.
|
||||
</p>
|
||||
<p class="fineprint">
|
||||
No hay una versión que funcione en el navegador, y es una decisión deliberada, no
|
||||
algo que siga pendiente. Una página web te la entrega el servidor de nuevo cada
|
||||
vez que la abres, así que su privacidad depende de confiar en quien la sirvió ese
|
||||
día. Una aplicación que ya tienes instalada no funciona así, y por eso fuimos en
|
||||
esa dirección.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
#endexport
|
||||
#endextend
|
||||
+55
-48
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="#(lang)">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
@@ -9,6 +9,13 @@
|
||||
<meta property="og:description" content="#(description)">
|
||||
<meta property="og:type" content="website">
|
||||
<link rel="canonical" href="#(canonical)">
|
||||
#if(hasAlternate):
|
||||
<!-- Each language points at the other, and both point at themselves. A page
|
||||
that lists an alternate the other side does not reciprocate is ignored. -->
|
||||
<link rel="alternate" hreflang="#(lang)" href="#(canonical)">
|
||||
<link rel="alternate" hreflang="#(altLang)" href="#(altHref)">
|
||||
<link rel="alternate" hreflang="x-default" href="#(defaultHref)">
|
||||
#endif
|
||||
<meta property="og:url" content="#(canonical)">
|
||||
<meta property="og:image" content="#(origin)/brand/og.png?v=#(assetVersion)">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
@@ -34,13 +41,13 @@
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<a class="skip" href="#("#")main">Skip to content</a>
|
||||
<a class="skip" href="#("#")main">#(chrome.skip)</a>
|
||||
|
||||
<div class="topbar">
|
||||
<div class="wrap">
|
||||
<span class="topbar-tag">Open</span>
|
||||
<span>Free and open source —</span>
|
||||
<a href="/source">read the code</a>
|
||||
<span class="topbar-tag">#(chrome.openTag)</span>
|
||||
<span>#(chrome.freeOpen)</span>
|
||||
<a href="/source">#(chrome.readCode)</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -51,21 +58,21 @@
|
||||
width="30" height="30">
|
||||
<span>Flamenet Messenger</span>
|
||||
</a>
|
||||
<button class="nav-toggle" aria-expanded="false" aria-controls="nav-links" aria-label="Menu">
|
||||
<button class="nav-toggle" aria-expanded="false" aria-controls="nav-links" aria-label="#(chrome.menu)">
|
||||
<span></span><span></span><span></span>
|
||||
</button>
|
||||
<div class="nav-links" id="nav-links">
|
||||
<a href="/messages">Messages</a>
|
||||
<a href="/files">Files</a>
|
||||
<a href="/wallet">Wallet</a>
|
||||
<a href="/comparison">Compare</a>
|
||||
<a href="/#("#")developers">Developers</a>
|
||||
<a href="/about">About</a>
|
||||
<a class="nav-donate-menu" href="/donate">Donate</a>
|
||||
<a href="/messages">#(chrome.messages)</a>
|
||||
<a href="/files">#(chrome.files)</a>
|
||||
<a href="/wallet">#(chrome.wallet)</a>
|
||||
<a href="/comparison">#(chrome.compare)</a>
|
||||
<a href="/#("#")developers">#(chrome.developers)</a>
|
||||
<a href="/about">#(chrome.about)</a>
|
||||
<a class="nav-donate-menu" href="/donate">#(chrome.donate)</a>
|
||||
</div>
|
||||
<div class="nav-cta">
|
||||
<a class="btn btn-outline nav-donate" href="/donate">Donate</a>
|
||||
<a class="btn btn-nav" href="/get">Get it</a>
|
||||
<a class="btn btn-outline nav-donate" href="/donate">#(chrome.donate)</a>
|
||||
<a class="btn btn-nav" href="/get">#(chrome.getIt)</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -77,11 +84,11 @@
|
||||
<footer class="footer">
|
||||
<div class="wrap">
|
||||
<div class="footer-cta">
|
||||
<h2>Run your own <span class="hl">server</span>.</h2>
|
||||
<p>Two containers and a config file. Nothing to sign up for, nothing that phones home.</p>
|
||||
<h2>#(chrome.ctaLead) <span class="hl">#(chrome.ctaLeadHl)</span>.</h2>
|
||||
<p>#(chrome.ctaBody)</p>
|
||||
<div class="cta-row footer-cta-row">
|
||||
<a class="btn btn-primary" href="/self-hosting">Run your own server</a>
|
||||
<a class="btn btn-ghost" href="/spec">Read the details</a>
|
||||
<a class="btn btn-primary" href="/self-hosting">#(chrome.ctaButton)</a>
|
||||
<a class="btn btn-ghost" href="/spec">#(chrome.ctaLink)</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -92,51 +99,51 @@
|
||||
<span>Flamenet Messenger</span>
|
||||
</div>
|
||||
|
||||
<nav class="footer-col" aria-label="Product">
|
||||
<h4>Product</h4>
|
||||
<a href="/get">Get it</a>
|
||||
<a href="/messages">Messages</a>
|
||||
<a href="/files">Files</a>
|
||||
<a href="/wallet">Wallet</a>
|
||||
<a href="/comparison">How this compares</a>
|
||||
<a href="/#("#")selfhost">Self-host</a>
|
||||
<nav class="footer-col" aria-label="#(chrome.footProduct)">
|
||||
<h4>#(chrome.footProduct)</h4>
|
||||
<a href="/get">#(chrome.footGetIt)</a>
|
||||
<a href="/messages">#(chrome.messages)</a>
|
||||
<a href="/files">#(chrome.files)</a>
|
||||
<a href="/wallet">#(chrome.wallet)</a>
|
||||
<a href="/comparison">#(chrome.footCompare)</a>
|
||||
<a href="/#("#")selfhost">#(chrome.footSelfHost)</a>
|
||||
</nav>
|
||||
|
||||
<nav class="footer-col" aria-label="Developers">
|
||||
<h4>Developers</h4>
|
||||
<a href="/source">Source</a>
|
||||
<a href="/spec">Specification</a>
|
||||
<a href="/security">Security</a>
|
||||
<a href="/audit">Audit scope</a>
|
||||
<h4>#(chrome.footDevelopers)</h4>
|
||||
<a href="/source">#(chrome.footSource)</a>
|
||||
<a href="/spec">#(chrome.footSpec)</a>
|
||||
<a href="/security">#(chrome.footSecurity)</a>
|
||||
<a href="/audit">#(chrome.footAudit)</a>
|
||||
<a href="/.well-known/security.txt">security.txt</a>
|
||||
</nav>
|
||||
|
||||
<nav class="footer-col" aria-label="Project">
|
||||
<h4>Project</h4>
|
||||
<a href="/about">About</a>
|
||||
<a href="/community">Community</a>
|
||||
<a href="/safety">Getting help</a>
|
||||
<a href="/roadmap">Roadmap</a>
|
||||
<nav class="footer-col" aria-label="#(chrome.footProject)">
|
||||
<h4>#(chrome.footProject)</h4>
|
||||
<a href="/about">#(chrome.about)</a>
|
||||
<a href="/community">#(chrome.footCommunity)</a>
|
||||
<a href="/safety">#(chrome.footHelp)</a>
|
||||
<a href="/roadmap">#(chrome.footRoadmap)</a>
|
||||
<!-- Beside Roadmap on purpose: one is what is coming, the other is
|
||||
what actually landed, and the pair is more honest than either
|
||||
alone. "What's new" rather than "Releases" — the people this
|
||||
footer is for want to know what changed, not to read a changelog. -->
|
||||
<a href="/releases">What's new</a>
|
||||
<a href="/status">Status</a>
|
||||
<a href="/donate">Donate</a>
|
||||
<a href="/releases">#(chrome.footNews)</a>
|
||||
<a href="/status">#(chrome.footStatus)</a>
|
||||
<a href="/donate">#(chrome.donate)</a>
|
||||
</nav>
|
||||
|
||||
<nav class="footer-col" aria-label="Legal">
|
||||
<h4>Legal</h4>
|
||||
<a href="/terms">Terms of Use</a>
|
||||
<a href="/privacy">Privacy Policy</a>
|
||||
<a href="/metadata">What we keep</a>
|
||||
<a href="/licenses">Licenses</a>
|
||||
<nav class="footer-col" aria-label="#(chrome.footLegal)">
|
||||
<h4>#(chrome.footLegal)</h4>
|
||||
<a href="/terms">#(chrome.footTerms)</a>
|
||||
<a href="/privacy">#(chrome.footPrivacy)</a>
|
||||
<a href="/metadata">#(chrome.footKeep)</a>
|
||||
<a href="/licenses">#(chrome.footLicences)</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<p class="footer-legal">
|
||||
Flamenet Messenger is free software, published under the MIT licence. Signal is a
|
||||
#(chrome.footLicence) Signal is a
|
||||
trademark of Signal Messenger, LLC; this project is not affiliated with or endorsed
|
||||
by them. Proudly hosted at
|
||||
<a class="host-link" href="https://hitthosting.com">Hitt Hosting</a>
|
||||
|
||||
@@ -714,6 +714,44 @@ func register(routes r: Routes, on app: Application) throws {
|
||||
return try await req.view.render("releases", page)
|
||||
}
|
||||
|
||||
// 🔴 **Only pages that actually have a translation get a second route.**
|
||||
// Sign-up is open in seven countries and six of them are Spanish-speaking
|
||||
// (relay#47), so an English-only site is a real gap — but a half-translated
|
||||
// one is a worse gap. Deriving this from the template on disk means a page
|
||||
// is bilingual the moment somebody writes `es/<slug>.leaf` and not one
|
||||
// deploy sooner, and nothing can advertise an alternate that 404s.
|
||||
//
|
||||
// ⚠️ `spec` is excluded by hand even if a translation appears. It is the
|
||||
// contract implementers build against, generated from the engine repo, and
|
||||
// a translated copy would drift from the original in silence.
|
||||
//
|
||||
// ⚠️ The title and description live HERE, beside the translation, not in
|
||||
// the `pages` table. A Spanish page with an English <title> is exactly the
|
||||
// half-translated result this is meant to avoid, and it is the part nobody
|
||||
// notices while reading the page itself.
|
||||
let spanish: [String: (title: String, description: String)] = [
|
||||
"get": ("Descarga Flamenet Messenger",
|
||||
"Instala Flamenet Messenger en Android, comprueba tú mismo que la "
|
||||
+ "aplicación es la que publicamos, y mira en qué países se puede "
|
||||
+ "crear una cuenta."),
|
||||
]
|
||||
let neverTranslated: Set<String> = ["spec"]
|
||||
let translated: Set<String> = Set(
|
||||
pages.map(\.0).filter { slug in
|
||||
!neverTranslated.contains(slug)
|
||||
&& spanish[slug] != nil
|
||||
// ⚠️ `es/<slug>.leaf`, not `<slug>.es.leaf`: Leaf reads the last
|
||||
// dot as the extension, so `render("get.es")` looks for a
|
||||
// template named `get` of type `es` and 500s with
|
||||
// "No template found for get.es".
|
||||
&& FileManager.default.fileExists(
|
||||
atPath: app.directory.viewsDirectory + "es/\(slug).leaf")
|
||||
})
|
||||
if !translated.isEmpty {
|
||||
let names = translated.sorted().joined(separator: ", ")
|
||||
app.logger.info("serving \(translated.count) page(s) in Spanish: \(names)")
|
||||
}
|
||||
|
||||
// 🔴 **Generated from the same table the pages come from** (relay#48). A
|
||||
// hand-kept sitemap is wrong within a month — a page gets added and nobody
|
||||
// remembers the second list. This one cannot disagree with what is served,
|
||||
@@ -739,6 +777,13 @@ func register(routes r: Routes, on app: Application) throws {
|
||||
for slug in ["releases", "status"] {
|
||||
body += "<url><loc>\(origin)/\(slug)</loc></url>\n"
|
||||
}
|
||||
// ⚠️ Translated pages are listed too, or the only way to reach the
|
||||
// Spanish site is a link somebody already has. The hreflang tags say
|
||||
// the two are one page in two languages; the sitemap is what gets the
|
||||
// second one crawled at all.
|
||||
for slug in translated.sorted() {
|
||||
body += "<url><loc>\(origin)/es/\(slug)</loc></url>\n"
|
||||
}
|
||||
body += "</urlset>\n"
|
||||
return Response(status: .ok,
|
||||
headers: ["Content-Type": "application/xml; charset=utf-8"],
|
||||
@@ -764,6 +809,12 @@ func register(routes r: Routes, on app: Application) throws {
|
||||
for (slug, title, description) in pages {
|
||||
app.get(.constant(slug)) { req async throws -> View in
|
||||
try await req.view.render(slug, LandingContext(
|
||||
hasAlternate: translated.contains(slug),
|
||||
altLang: translated.contains(slug) ? "es" : "",
|
||||
altHref: translated.contains(slug)
|
||||
? publicOrigin(req) + "/es/" + slug : "",
|
||||
defaultHref: translated.contains(slug)
|
||||
? publicOrigin(req) + "/" + slug : "",
|
||||
canonical: publicOrigin(req) + "/" + slug,
|
||||
origin: publicOrigin(req),
|
||||
title: title,
|
||||
@@ -782,6 +833,36 @@ func register(routes r: Routes, on app: Application) throws {
|
||||
}
|
||||
}
|
||||
|
||||
// The same pages in Spanish, from `es/<slug>.leaf`.
|
||||
for slug in translated.sorted() {
|
||||
app.get("es", .constant(slug)) { req async throws -> View in
|
||||
try await req.view.render("es/" + slug, LandingContext(
|
||||
lang: "es",
|
||||
hasAlternate: true,
|
||||
chrome: .spanish,
|
||||
altLang: "en",
|
||||
altHref: publicOrigin(req) + "/" + slug,
|
||||
// ⚠️ English is x-default: it is the complete site, and the
|
||||
// language a reader lands in when nothing about them says
|
||||
// otherwise. Pointing x-default at a partial translation would
|
||||
// send everybody to the smaller site.
|
||||
defaultHref: publicOrigin(req) + "/" + slug,
|
||||
canonical: publicOrigin(req) + "/es/" + slug,
|
||||
origin: publicOrigin(req),
|
||||
title: spanish[slug]?.title ?? "Flamenet Messenger",
|
||||
description: spanish[slug]?.description ?? "",
|
||||
audience: r.config.audience,
|
||||
protoVersion: 3,
|
||||
retention: RetentionFacts(
|
||||
deliveredDays: r.config.deliveredRetentionDays,
|
||||
undeliveredDays: r.config.undeliveredRetentionDays,
|
||||
attachmentDays: r.config.attachmentRetentionDays,
|
||||
deviceExpiryDays: r.config.deviceExpiryDays,
|
||||
maxDevices: r.config.maxDevicesPerAccount,
|
||||
deleteOnAck: req.application.storage[Routes.DeleteOnAckKey.self] ?? false)))
|
||||
}
|
||||
}
|
||||
|
||||
// How a relay describes itself to a client or a peer holding nothing but
|
||||
// its hostname. Unauthenticated on purpose — a descriptor only this relay's
|
||||
// own users could read would be useless to the peers it exists to inform.
|
||||
@@ -1411,6 +1492,11 @@ enum AssetVersion {
|
||||
}
|
||||
|
||||
struct DonateContext: Encodable {
|
||||
/// Renders the shared layout, so it carries the same chrome — see
|
||||
/// `LandingContext.chrome`. Without it the nav and footer render EMPTY.
|
||||
var lang: String = "en"
|
||||
var hasAlternate: Bool = false
|
||||
var chrome: LandingContext.Chrome = LandingContext.Chrome()
|
||||
/// 🔴 **Every context the shared layout renders needs these.**
|
||||
/// `layout.leaf` is one template, so a context that omits them renders
|
||||
/// `<link rel="canonical" href="">` and a RELATIVE og:image — an empty
|
||||
@@ -1433,6 +1519,119 @@ struct DonateContext: Encodable {
|
||||
}
|
||||
|
||||
struct LandingContext: Encodable {
|
||||
/// The language this render is in — `<html lang>`, and the hreflang of the
|
||||
/// canonical. English unless a translated template is being served.
|
||||
var lang: String = "en"
|
||||
/// 🔴 **Whether this page has a translation at all**, as a Bool rather than
|
||||
/// by testing `altHref` for emptiness. Leaf's `#if` treats an empty string
|
||||
/// as true, so the alternate block rendered on every English page and
|
||||
/// pointed half of them at `/es/<slug>` URLs that do not exist.
|
||||
var hasAlternate: Bool = false
|
||||
/// The words the shared layout owns — navigation, the call to action and
|
||||
/// the whole footer.
|
||||
///
|
||||
/// ⚠️ **A translated page with an English footer is not a translated
|
||||
/// page.** `layout.leaf` is one template for every language, so these ~40
|
||||
/// strings belong to the context or they stay in English forever. The
|
||||
/// Spanish `/es/get` rendered with an entirely English nav until this
|
||||
/// existed.
|
||||
var chrome: Chrome = Chrome()
|
||||
/// The same page in the other language.
|
||||
var altLang: String = ""
|
||||
var altHref: String = ""
|
||||
var defaultHref: String = ""
|
||||
|
||||
struct Chrome: Encodable {
|
||||
// ⚠️ Real characters, never HTML entities. Leaf escapes what it
|
||||
// interpolates, so an entity arrives at the browser doubled and prints
|
||||
// as text rather than rendering.
|
||||
var skip = "Skip to content"
|
||||
var openTag = "Open"
|
||||
var freeOpen = "Free and open source —"
|
||||
var readCode = "read the code"
|
||||
var menu = "Menu"
|
||||
var messages = "Messages"
|
||||
var files = "Files"
|
||||
var wallet = "Wallet"
|
||||
var compare = "Compare"
|
||||
var developers = "Developers"
|
||||
var about = "About"
|
||||
var donate = "Donate"
|
||||
var getIt = "Get\u{00A0}it"
|
||||
var ctaLead = "Run your own"
|
||||
var ctaLeadHl = "server"
|
||||
var ctaBody = "Two containers and a config file. Nothing to sign up for, nothing that phones home."
|
||||
var ctaButton = "Run your own server"
|
||||
var ctaLink = "Read the details"
|
||||
var footProduct = "Product"
|
||||
var footGetIt = "Get it"
|
||||
var footCompare = "How this compares"
|
||||
var footSelfHost = "Self-host"
|
||||
var footDevelopers = "Developers"
|
||||
var footSource = "Source"
|
||||
var footSpec = "Specification"
|
||||
var footSecurity = "Security"
|
||||
var footAudit = "Audit scope"
|
||||
var footProject = "Project"
|
||||
var footCommunity = "Community"
|
||||
var footHelp = "Getting help"
|
||||
var footRoadmap = "Roadmap"
|
||||
var footNews = "What's new"
|
||||
var footStatus = "Status"
|
||||
var footLegal = "Legal"
|
||||
var footTerms = "Terms of Use"
|
||||
var footPrivacy = "Privacy Policy"
|
||||
var footKeep = "What we keep"
|
||||
var footLicences = "Licenses"
|
||||
var footLicence = "Flamenet Messenger is free software, published under the MIT licence."
|
||||
|
||||
/// Every string above, in Spanish. Written out rather than looked up so
|
||||
/// a missing one is caught by a test, not by a reader finding an
|
||||
/// English word in a Spanish footer a month later.
|
||||
static let spanish: Chrome = {
|
||||
var c = Chrome()
|
||||
c.skip = "Saltar al contenido"
|
||||
c.openTag = "Abierto"
|
||||
c.freeOpen = "Libre y de código abierto —"
|
||||
c.readCode = "lee el código"
|
||||
c.menu = "Menú"
|
||||
c.messages = "Mensajes"
|
||||
c.files = "Archivos"
|
||||
c.wallet = "Cartera"
|
||||
c.compare = "Comparar"
|
||||
c.developers = "Desarrolladores"
|
||||
c.about = "Acerca de"
|
||||
c.donate = "Donar"
|
||||
c.getIt = "Descargar"
|
||||
c.ctaLead = "Monta tu propio"
|
||||
c.ctaLeadHl = "servidor"
|
||||
c.ctaBody = "Dos contenedores y un archivo de configuración. Nada a lo que registrarse, nada que llame a casa."
|
||||
c.ctaButton = "Monta tu propio servidor"
|
||||
c.ctaLink = "Lee los detalles"
|
||||
c.footProduct = "Producto"
|
||||
c.footGetIt = "Descargar"
|
||||
c.footCompare = "Cómo se compara"
|
||||
c.footSelfHost = "Servidor propio"
|
||||
c.footDevelopers = "Desarrolladores"
|
||||
c.footSource = "Código"
|
||||
c.footSpec = "Especificación"
|
||||
c.footSecurity = "Seguridad"
|
||||
c.footAudit = "Alcance de la auditoría"
|
||||
c.footProject = "Proyecto"
|
||||
c.footCommunity = "Comunidad"
|
||||
c.footHelp = "Obtener ayuda"
|
||||
c.footRoadmap = "Plan de trabajo"
|
||||
c.footNews = "Novedades"
|
||||
c.footStatus = "Estado"
|
||||
c.footLegal = "Legal"
|
||||
c.footTerms = "Condiciones de uso"
|
||||
c.footPrivacy = "Política de privacidad"
|
||||
c.footKeep = "Qué guardamos"
|
||||
c.footLicences = "Licencias"
|
||||
c.footLicence = "Flamenet Messenger es software libre, publicado bajo la licencia MIT."
|
||||
return c
|
||||
}()
|
||||
}
|
||||
/// The absolute URL of this page **on the host that served it**.
|
||||
///
|
||||
/// 🔴 **Built from the request, never from a constant.** This relay is meant
|
||||
@@ -1481,6 +1680,11 @@ func publicOrigin(_ request: Request) -> String {
|
||||
/// `847293.4472899437` — and a status page whose figures look like debug output
|
||||
/// is one nobody reads carefully enough to notice when it turns red.
|
||||
struct StatusContext: Encodable {
|
||||
/// Renders the shared layout, so it carries the same chrome — see
|
||||
/// `LandingContext.chrome`. Without it the nav and footer render EMPTY.
|
||||
var lang: String = "en"
|
||||
var hasAlternate: Bool = false
|
||||
var chrome: LandingContext.Chrome = LandingContext.Chrome()
|
||||
/// 🔴 **Every context the shared layout renders needs these.**
|
||||
/// `layout.leaf` is one template, so a context that omits them renders
|
||||
/// `<link rel="canonical" href="">` and a RELATIVE og:image — an empty
|
||||
|
||||
@@ -188,6 +188,15 @@ enum ReleaseNotes {
|
||||
/// ⚠️ Leaf has no arithmetic, so `previousPage` and `nextPage` are computed
|
||||
/// here. A template that tried `#(page - 1)` would render the expression.
|
||||
struct ReleasesContext: Encodable {
|
||||
/// Renders the shared layout, so it carries the same chrome — see
|
||||
/// `LandingContext.chrome`. Without it the nav and footer render EMPTY.
|
||||
///
|
||||
/// 🔴 And it MUST be listed in `CodingKeys` below. This struct encodes by
|
||||
/// hand, and a field missing from that list is dropped in silence — which
|
||||
/// is exactly how `canonical` shipped empty earlier today.
|
||||
var lang: String = "en"
|
||||
var hasAlternate: Bool = false
|
||||
var chrome: LandingContext.Chrome = LandingContext.Chrome()
|
||||
/// 🔴 **Every context the shared layout renders needs these.**
|
||||
/// `layout.leaf` is one template, so a context that omits them renders
|
||||
/// `<link rel="canonical" href="">` and a RELATIVE og:image — an empty
|
||||
@@ -210,13 +219,12 @@ struct ReleasesContext: Encodable {
|
||||
var nextPage: Int { min(pages, page + 1) }
|
||||
|
||||
/// ⚠️ **A hand-written encoder drops anything not listed here, silently.**
|
||||
/// `canonical` and `origin` were added to this struct and set by the route,
|
||||
/// and `/releases` still rendered `href=""` — because Leaf renders the
|
||||
/// ENCODED object, and these two were not in the list. Same trap as
|
||||
/// `Change.label` above, which is documented a few lines up and still cost
|
||||
/// a second afternoon.
|
||||
/// Leaf renders the ENCODED object, so a field added to the struct above
|
||||
/// and set by the route still arrives as nothing. That is how `/releases`
|
||||
/// shipped `<link rel="canonical" href="">`, and then how it rendered with
|
||||
/// an empty nav — twice, the same afternoon, from the same cause.
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case canonical, origin
|
||||
case canonical, origin, lang, hasAlternate, chrome
|
||||
case title, description, audience, assetVersion, releases, page, pages
|
||||
case hasPrevious, hasNext, previousPage, nextPage
|
||||
}
|
||||
@@ -225,6 +233,9 @@ struct ReleasesContext: Encodable {
|
||||
var c = encoder.container(keyedBy: CodingKeys.self)
|
||||
try c.encode(canonical, forKey: .canonical)
|
||||
try c.encode(origin, forKey: .origin)
|
||||
try c.encode(lang, forKey: .lang)
|
||||
try c.encode(hasAlternate, forKey: .hasAlternate)
|
||||
try c.encode(chrome, forKey: .chrome)
|
||||
try c.encode(title, forKey: .title)
|
||||
try c.encode(description, forKey: .description)
|
||||
try c.encode(audience, forKey: .audience)
|
||||
|
||||
@@ -347,6 +347,66 @@ final class PublicPagesTests: XCTestCase {
|
||||
XCTAssertNotNil(object?["@type"], "the block declares no type")
|
||||
}
|
||||
|
||||
func testSpanishPagesOnlyLinkToSpanishPagesThatExist() throws {
|
||||
let spanishDir = views.appendingPathComponent("es")
|
||||
guard let files = try? FileManager.default.contentsOfDirectory(atPath: spanishDir.path)
|
||||
.filter({ $0.hasSuffix(".leaf") }), !files.isEmpty else { return }
|
||||
|
||||
let translated = Set(files.map { $0.replacingOccurrences(of: ".leaf", with: "") })
|
||||
for file in files {
|
||||
let page = try String(contentsOf: spanishDir.appendingPathComponent(file),
|
||||
encoding: .utf8)
|
||||
var cursor = page.startIndex
|
||||
while let hit = page.range(of: "href=\"/es/", range: cursor ..< page.endIndex) {
|
||||
cursor = hit.upperBound
|
||||
let slug = String(page[hit.upperBound...]
|
||||
.prefix(while: { $0 != "\"" && $0 != "#" && $0 != "/" }))
|
||||
XCTAssertTrue(
|
||||
translated.contains(slug),
|
||||
"es/\(file) links to /es/\(slug), but there is no es/\(slug).leaf — "
|
||||
+ "that link 404s. Point it at /\(slug) until it is translated.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func testEveryChromeStringHasASpanishCounterpart() throws {
|
||||
let source = try String(
|
||||
contentsOf: root.appendingPathComponent("Sources/FlamenetRelay/Entrypoint.swift"),
|
||||
encoding: .utf8)
|
||||
guard let chrome = source.range(of: "struct Chrome: Encodable {"),
|
||||
let spanish = source.range(of: "static let spanish: Chrome = {")
|
||||
else { return XCTFail("the Chrome struct is no longer where this test looks") }
|
||||
|
||||
let declarations = source[chrome.upperBound ..< spanish.lowerBound]
|
||||
let translations = source[spanish.upperBound...].prefix(4000)
|
||||
|
||||
var fields: [String] = []
|
||||
var cursor = declarations.startIndex
|
||||
while let hit = declarations.range(of: "var ", range: cursor ..< declarations.endIndex) {
|
||||
cursor = hit.upperBound
|
||||
let name = String(declarations[hit.upperBound...]
|
||||
.prefix(while: { $0.isLetter || $0.isNumber }))
|
||||
if !name.isEmpty { fields.append(name) }
|
||||
}
|
||||
XCTAssertFalse(fields.isEmpty, "no chrome fields were found to check")
|
||||
|
||||
for field in fields {
|
||||
XCTAssertTrue(translations.contains("c.\(field) ="),
|
||||
"Chrome.\(field) has no Spanish counterpart, so it renders in "
|
||||
+ "English on every /es page")
|
||||
}
|
||||
// ⚠️ Assignment lines only. The comment above the field explains the
|
||||
// trap and names the entity, and an earlier version of this test
|
||||
// flagged that comment — a check that fails on its own documentation.
|
||||
let assignments = (declarations + translations)
|
||||
.split(separator: "\n")
|
||||
.filter { $0.contains(" = \"") }
|
||||
for line in assignments where line.contains("&") && line.contains(";") {
|
||||
XCTFail("an HTML entity in a chrome string is escaped by Leaf and prints "
|
||||
+ "literally — use the real character: \(line.trimmingCharacters(in: .whitespaces))")
|
||||
}
|
||||
}
|
||||
|
||||
func testTheSafetyPageCarriesAReachableContact() throws {
|
||||
let page = try String(contentsOf: views.appendingPathComponent("safety.leaf"),
|
||||
encoding: .utf8)
|
||||
|
||||
Reference in New Issue
Block a user