feat(landing): changelog public /changelog + flux RSS

Page Astro prerendered qui liste les versions livrées en reverse-chrono.
Contenu géré en MD files versionnés dans le repo via Astro content
collections (`src/content.config.ts`), pas de DB — chaque release =
1 fichier `src/content/changelog/<x.y.z>.md` ajouté en PR à côté du bump
de version applicatif.

11 entrées initiales (v1.0.0 → v1.10.0) couvrant le premier mois public :
lancement (OCR Mistral + plans par défaut + mode démo + Stripe), saisie
manuelle, SSO Google puis Microsoft, plans custom, templates email,
réécriture IA, insights, blog, marque blanche, remerciement automatique.

UI :
- Hero centré aligné DA landing (eyebrow rubis + h1 display + sub muted)
- 2 colonnes desktop : feed cartes (gauche) + sticky rail jump-nav (droite)
- Sur mobile/tablette : pas de rail, juste le feed
- Sticky rail : IntersectionObserver inline qui met en surbrillance la
  version courante quand l'user scrolle
- Anchors `#1.4.0` partageables, cliquables depuis le chip de chaque carte
- Type pills colorés : feature (rubis solid), improvement (cream-2),
  fix (line outline)
- Bullets losanges ◆ rubis cohérents avec le gem brand

SEO :
- `prerender = true` → HTML figé au build, LCP minimum
- JSON-LD WebPage avec mainEntity[TechArticle] par version → rich
  snippets Google
- Flux RSS 2.0 à `/changelog/rss.xml` (prerendered aussi)
- Auto-discovery RSS ajoutée au Layout (à côté de celle du blog)
- Lien Changelog ajouté au SiteFooter à côté de Blog

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
ordinarthur 2026-05-11 00:05:45 +02:00
parent 642747d762
commit fc0d13e955
16 changed files with 625 additions and 1 deletions

View File

@ -23,6 +23,9 @@ export function SiteFooter() {
<a href="/blog" className="text-ink-2 hover:text-rubis transition-colors">
Blog
</a>
<a href="/changelog" className="text-ink-2 hover:text-rubis transition-colors">
Changelog
</a>
<a href="/mentions-legales" className="text-ink-2 hover:text-rubis transition-colors">
Mentions légales
</a>

View File

@ -0,0 +1,31 @@
import { defineCollection, z } from "astro:content";
import { glob } from "astro/loaders";
/**
* Collection `changelog` une entrée Markdown par version livrée.
*
* Versionné dans le repo : pas de DB, pas d'admin. Pour ajouter une entrée
* lors d'une release, on crée `apps/landing/src/content/changelog/<x.y.z>.md`
* avec le frontmatter ci-dessous + un body markdown court qui raconte le
* pourquoi (le quoi est déjà dans `highlights`).
*
* Le toast SPA (apps/web) lit la version courante via `apps/web/src/version.ts`
* pensez à le bumper en même temps que vous ajoutez le .md.
*/
const changelog = defineCollection({
loader: glob({ pattern: "**/*.md", base: "./src/content/changelog" }),
schema: z.object({
/** Version sémantique, ex. "1.4.0". Sert d'ancre URL : `/changelog#1.4.0`. */
version: z.string().regex(/^\d+\.\d+\.\d+$/, "Format attendu : x.y.z"),
/** Date de release. ISO ou string YYYY-MM-DD. */
date: z.coerce.date(),
/** Titre court visible dans la carte (≤ 60 caractères idéal). */
title: z.string().min(3).max(80),
/** Type pour la pastille colorée — feature (rubis), improvement (muted), fix (line). */
type: z.enum(["feature", "improvement", "fix"]),
/** 1 à 8 bullets de surface — affichés dans la carte avant le body markdown. */
highlights: z.array(z.string()).min(1).max(8),
}),
});
export const collections = { changelog };

View File

@ -0,0 +1,28 @@
---
version: "1.0.0"
date: 2026-04-10
title: "Lancement de Rubis"
type: feature
highlights:
- "Glissez-déposez vos factures, l'OCR Mistral AI fait le reste"
- "4 plans de relance prêts (Standard B2B, Rapide, Patient, Ferme)"
- "Confirmations par email avant chaque relance"
- "Compteur rubis : 1 rubis = 10 minutes libérées"
- "Dashboard avec KPIs : à relancer, encaissé, DSO"
- "Mode démo sans engagement, sans carte bancaire"
- "Stripe + 30 jours gratuits"
- "Tout fonctionne sur mobile"
---
Aujourd'hui Rubis est ouvert au public.
L'idée est simple : vos factures impayées se relancent toutes seules pendant que vous travaillez. Vous déposez la facture, vous choisissez un plan, et c'est tout.
Quelques choix qu'on assume dès le départ :
- **Le ton monte avec le retard, jamais avant.** Pas d'agressivité par défaut.
- **L'OCR tourne sur Mistral AI**, donc tout est hébergé en France. Vos données ne quittent pas l'Europe.
- **La mise en demeure passe toujours par votre validation manuelle.** On ne joue pas avec la relation client.
- **L'unité dans l'app, c'est le rubis**, pas le DSO. 1 rubis = 10 minutes que vous n'avez pas passées à relancer.
30 jours gratuits, sans carte bancaire, pour démarrer.

View File

@ -0,0 +1,14 @@
---
version: "1.1.0"
date: 2026-04-13
title: "Saisie manuelle des factures"
type: feature
highlights:
- "Créez une facture au clavier en quelques secondes"
- "Pratique pour les avoirs, les factures perdues, ou les PDF que l'OCR n'arrive pas à lire"
- "Tous les champs en saisie libre, vous gardez le contrôle"
---
L'OCR couvre l'écrasante majorité des cas, mais pas tous. Un PDF mal scanné, une facture papier qu'on n'a plus, un avoir qu'on tape rapidement — il fallait un fallback propre.
Vous renseignez le client, le numéro, le montant, l'échéance. C'est exactement la même facture pour Rubis qu'une facture importée — mêmes plans de relance, mêmes confirmations, même timeline.

View File

@ -0,0 +1,14 @@
---
version: "1.10.0"
date: 2026-05-08
title: "Le client est remercié, automatiquement"
type: feature
highlights:
- "Quand vous validez « Payée », un mot court part au client"
- "Optionnel, configurable depuis vos settings"
- "Un client remercié est un client qui revient"
---
Vous validez « Payée » dans Rubis, et Rubis envoie un mot bref au client : « Merci, paiement bien reçu. Belle journée. » C'est court, c'est sincère, c'est optionnel.
Quelques utilisateurs en bêta nous ont dit que ce simple geste valait un petit cadeau de fin d'année. On l'a poussé en prod par défaut activé, désactivable en deux clics.

View File

@ -0,0 +1,14 @@
---
version: "1.2.0"
date: 2026-04-16
title: "Connexion en un clic avec Google"
type: feature
highlights:
- "SSO via votre compte Google Workspace"
- "Plus de mot de passe à retenir"
- "Création de compte instantanée pour les nouveaux"
---
La majorité de nos utilisateurs ont déjà un compte Google Workspace pour leur boîte. Au lieu de leur faire créer un énième mot de passe, on branche directement le SSO.
Un bouton « Continuer avec Google » sur la page de connexion et le tour est joué. Vos credentials Google ne transitent jamais par nos serveurs — c'est de l'OAuth standard.

View File

@ -0,0 +1,14 @@
---
version: "1.3.0"
date: 2026-04-19
title: "Connexion en un clic avec Microsoft"
type: feature
highlights:
- "SSO via votre compte Microsoft 365"
- "Pour les boîtes côté Outlook plutôt que Gmail"
- "Création de compte instantanée"
---
Beaucoup de TPE-PME tournent sur Microsoft 365. On a ajouté le même flow que pour Google : un bouton « Continuer avec Microsoft » sur la page de connexion, et c'est parti.
Même principe qu'avec Google — vos credentials Microsoft restent chez Microsoft, on récupère juste votre email pour créer le compte côté Rubis.

View File

@ -0,0 +1,17 @@
---
version: "1.4.0"
date: 2026-04-22
title: "Plans de relance sur mesure"
type: feature
highlights:
- "Les 4 plans par défaut ne suffisent pas toujours — créez les vôtres"
- "Cadence à la carte : J+3, J+7, J+15, J+30, comme vous voulez"
- "Choix du ton à chaque étape : cordial, ferme, sérieux"
- "Aucune limite sur le nombre d'étapes"
---
Standard B2B, Rapide, Patient, Ferme — les 4 plans fournis couvrent l'essentiel. Mais chaque secteur a ses habitudes. Un cabinet de conseil ne relance pas comme un fournisseur d'imprimerie.
L'éditeur de plan vous laisse construire le vôtre : chaque étape a sa date relative à l'échéance, son ton, son intention. Vous nommez le plan, vous le sauvegardez, vous l'appliquez à vos factures.
Les plans personnalisés cohabitent avec les plans par défaut. Vous pouvez les dupliquer, les modifier, les archiver.

View File

@ -0,0 +1,14 @@
---
version: "1.5.0"
date: 2026-04-25
title: "Templates d'email avec variables"
type: feature
highlights:
- "Tous les textes d'email sont éditables, dans tous les plans"
- "Variables disponibles : `{{client.nom}}`, `{{facture.montant}}`, `{{facture.numero}}`, `{{retard.jours}}`"
- "Aperçu en temps réel avec les vraies valeurs de votre prochaine relance"
---
Les modèles d'email fournis par défaut sont écrits par des gens qui connaissent le sujet. Mais votre voix, c'est la vôtre.
Chaque étape de chaque plan a son template éditable. Les variables sont remplacées au moment de l'envoi par les vraies valeurs de la facture en cours. Pas de surprise : un aperçu vous montre le résultat avec votre prochaine facture en attente.

View File

@ -0,0 +1,16 @@
---
version: "1.6.0"
date: 2026-04-28
title: "Réécrivez vos relances avec l'IA"
type: feature
highlights:
- "Un bouton sur chaque template : « Reformule plus ferme », « plus chaleureux », « plus court »"
- "L'IA propose, vous validez, vous gardez la main"
- "Basée sur Mistral AI, hébergée en France"
---
Réécrire un email plus ferme sans devenir agressif, ou plus chaleureux sans être obséquieux — c'est un exercice subtil. On a branché un assistant IA sur l'éditeur de templates : un bouton, une consigne, une nouvelle version.
Vous gardez toujours le dernier mot. La proposition de l'IA s'affiche à côté de votre texte original — vous adoptez, vous mixez, vous ignorez.
Comme l'OCR, l'IA tourne sur Mistral, donc en France. Aucune relance n'est envoyée à un provider américain.

View File

@ -0,0 +1,15 @@
---
version: "1.7.0"
date: 2026-05-01
title: "Insights et graphiques"
type: feature
highlights:
- "Évolution de votre DSO sur 6 mois"
- "Rubis cumulés par mois, et au total depuis le début"
- "Taux d'encaissement par client et par plan de relance"
- "Détection des plans qui marchent moins bien sur vos clients"
---
Avant, le dashboard montrait l'instant T. On a ajouté l'histoire : votre DSO d'il y a 3 mois, vos rubis du mois dernier, votre taux d'encaissement par segment.
Vous repérez en un coup d'œil les plans qui sous-performent, les clients qui demandent un suivi serré, et les mois où vous avez vraiment gagné du temps.

View File

@ -0,0 +1,14 @@
---
version: "1.8.0"
date: 2026-05-04
title: "Le blog des relances qui marchent"
type: feature
highlights:
- "`rubis.pro/blog` — bonnes pratiques, modèles d'email, retours du terrain"
- "Un nouvel article par semaine, sans bullshit"
- "Tout est gratuit, RSS dispo pour les power users"
---
On entend les mêmes questions revenir : comment relancer un client qui ignore les emails ? Quel ton au J+30 ? Et la mise en demeure, légalement, c'est encadré comment ?
On a ouvert un blog pour y répondre. Pas de listicles, pas d'AIDA, juste des articles utiles écrits par des gens qui ont gratté le sujet.

View File

@ -0,0 +1,14 @@
---
version: "1.9.0"
date: 2026-05-06
title: "Marque blanche (plan Business)"
type: feature
highlights:
- "Vos relances partent avec votre logo, vos couleurs, votre signature"
- "Aucune mention « envoyé via Rubis » dans le footer des emails"
- "Disponible sur le plan Business"
---
Vos clients n'ont pas à savoir que vos relances sont automatisées. Sur le plan Business, tout est marqué à votre image : logo dans l'en-tête, couleur principale sur les liens, signature personnalisable, et bien sûr, votre nom comme expéditeur.
Le footer « envoyé via Rubis » disparaît. C'est votre marque, du début à la fin.

View File

@ -133,8 +133,10 @@ const jsonLdArray = jsonLd ? (Array.isArray(jsonLd) ? jsonLd : [jsonLd]) : [];
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="manifest" href="/site.webmanifest" />
{/* RSS auto-discovery */}
{/* RSS auto-discovery — exposés sur toutes les pages pour que les
lecteurs RSS détectent les deux flux quel que soit le point d'entrée. */}
<link rel="alternate" type="application/rss+xml" title="Blog Rubis" href={`${SITE_URL}/blog/rss.xml`} />
<link rel="alternate" type="application/rss+xml" title="Changelog Rubis" href={`${SITE_URL}/changelog/rss.xml`} />
{/* JSON-LD structured data */}
{

View File

@ -0,0 +1,337 @@
---
/**
* /changelog — Liste reverse-chrono des versions livrées.
*
* Stratégie de rendu : `prerender = true` — le contenu vient de fichiers .md
* versionnés dans le repo (cf. `src/content.config.ts`), donc tout est figé au
* build. À chaque release, on bumpe `apps/web/src/version.ts` + on ajoute un
* nouveau .md dans `src/content/changelog/`, puis le redéploiement régénère
* le HTML.
*
* Design :
* - Hero centré, eyebrow rubis + h1 display
* - 2 colonnes desktop : feed cartes (gauche) + sticky rail (droite)
* - 1 colonne mobile/tablette : pas de rail, juste le feed
* - Anchors `#1.4.0` par version → partageables, scrollables
* - JSON-LD : un `WebPage` avec `mainEntity` listant chaque release comme
* `TechArticle`. Rich snippets dans Google.
*/
export const prerender = true;
import Layout from "../../layouts/Layout.astro";
import { getCollection, render } from "astro:content";
const entries = (await getCollection("changelog")).sort(
(a, b) => b.data.date.getTime() - a.data.date.getTime(),
);
const rendered = await Promise.all(
entries.map(async (entry) => {
const { Content } = await render(entry);
return { data: entry.data, Content };
}),
);
const dateLong = new Intl.DateTimeFormat("fr-FR", {
day: "numeric",
month: "long",
year: "numeric",
});
const dateShort = new Intl.DateTimeFormat("fr-FR", {
day: "2-digit",
month: "short",
});
const typeLabel: Record<string, string> = {
feature: "Nouveauté",
improvement: "Amélioration",
fix: "Correction",
};
const title = "Changelog — ce qui change dans Rubis";
const description =
"Toutes les nouveautés, améliorations et corrections livrées sur Rubis. Régulier, transparent, sans superlatifs.";
const jsonLd = {
"@context": "https://schema.org",
"@type": "WebPage",
name: title,
description,
url: "https://rubis.pro/changelog",
publisher: {
"@type": "Organization",
name: "Rubis sur l'ongle",
url: "https://rubis.pro",
},
mainEntity: entries.map((e) => ({
"@type": "TechArticle",
headline: e.data.title,
datePublished: e.data.date.toISOString(),
url: `https://rubis.pro/changelog#${e.data.version}`,
version: e.data.version,
})),
};
---
<Layout title={title} description={description} solidHeader jsonLd={jsonLd}>
{/* ============ Hero ============ */}
<section class="bg-cream-2 border-b border-line">
<div class="max-w-[1180px] mx-auto px-5 sm:px-8 py-20 lg:py-24 text-center">
<p class="inline-flex items-center gap-2 text-[11px] uppercase tracking-[0.16em] font-semibold text-rubis">
<span aria-hidden class="size-[7px] bg-current rotate-45 inline-block"></span>
Tout ce qui change
</p>
<h1 class="mt-5 font-display font-extrabold text-ink leading-[1.05] tracking-[-0.025em] text-[40px] sm:text-[56px] max-w-[780px] mx-auto">
Changelog
</h1>
<p class="mt-5 max-w-[620px] mx-auto text-[18px] text-ink-2 leading-relaxed">
Les nouveautés, les améliorations, les corrections livrées sur Rubis.
Régulier, transparent, sans superlatifs.
</p>
<div class="mt-7 inline-flex items-center gap-4 text-[13px] text-ink-3">
<a
href="/changelog/rss.xml"
class="inline-flex items-center gap-1.5 hover:text-rubis transition-colors"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden
>
<path d="M4 11a9 9 0 0 1 9 9"></path>
<path d="M4 4a16 16 0 0 1 16 16"></path>
<circle cx="5" cy="19" r="1"></circle>
</svg>
S'abonner au flux RSS
</a>
</div>
</div>
</section>
{/* ============ Body : feed + rail ============ */}
<section class="max-w-[1180px] mx-auto px-5 sm:px-8 py-16 lg:py-20">
<div class="lg:grid lg:grid-cols-[1fr_220px] lg:gap-16">
{/* Feed des versions */}
<main class="min-w-0">
{
rendered.length === 0 ? (
<div class="text-center py-16 text-ink-3">
<p>Aucune entrée publiée pour l'instant.</p>
</div>
) : (
<ol class="space-y-12 lg:space-y-16 list-none p-0">
{rendered.map(({ data, Content }) => (
<li
id={data.version}
class="changelog-card scroll-mt-24 bg-white border border-line rounded-card shadow-soft p-7 sm:p-9 lg:p-10"
>
{/* Header : chip version + type + date */}
<header class="flex items-center flex-wrap gap-3">
<a
href={`#${data.version}`}
class="inline-flex items-center gap-1.5 font-display font-extrabold text-[13px] text-rubis-deep bg-rubis-glow border border-rubis/15 px-3 py-1.5 rounded-full tracking-[-0.005em] hover:bg-rubis hover:text-cream transition-colors no-underline"
aria-label={`Lien direct vers la version ${data.version}`}
>
v{data.version}
</a>
<span
class:list={[
"inline-flex items-center text-[11px] uppercase tracking-[0.08em] font-semibold px-2.5 py-1 rounded-full",
data.type === "feature" &&
"bg-rubis text-cream",
data.type === "improvement" &&
"bg-cream-2 text-ink-2 border border-line",
data.type === "fix" &&
"bg-white text-ink-3 border border-line",
]}
>
{typeLabel[data.type]}
</span>
<time
datetime={data.date.toISOString()}
class="text-[13px] text-ink-3 tabular-nums ml-auto"
>
{dateLong.format(data.date)}
</time>
</header>
{/* Titre */}
<h2 class="mt-5 font-display font-bold text-ink text-[26px] sm:text-[30px] lg:text-[32px] tracking-[-0.022em] leading-[1.15]">
{data.title}
</h2>
{/* Highlights — bullets losanges rubis */}
<ul class="mt-5 space-y-2.5 list-none p-0">
{data.highlights.map((h) => (
<li class="flex items-start gap-3 text-[16px] leading-relaxed text-ink-2">
<span
aria-hidden
class="mt-[10px] inline-block size-[7px] bg-rubis rotate-45 shrink-0"
></span>
<span set:html={h.replace(/`([^`]+)`/g, '<code class="font-mono text-[14.5px] bg-cream-2 px-1.5 py-0.5 rounded border border-line text-ink">$1</code>')} />
</li>
))}
</ul>
{/* Body markdown — narrative courte */}
<div class="mt-6 prose-changelog text-[16px] leading-relaxed text-ink-2">
<Content />
</div>
</li>
))}
</ol>
)
}
</main>
{/* Sticky rail — jump nav versions, desktop only */}
<aside class="hidden lg:block" aria-label="Navigation des versions">
<div class="sticky top-24">
<h2 class="text-[11px] uppercase tracking-[0.08em] font-semibold text-ink-3 mb-4">
Versions
</h2>
<nav class="rail flex flex-col gap-0.5 border-l border-line">
{
entries.map((e) => (
<a
href={`#${e.data.version}`}
class="rail-link group relative flex items-baseline gap-3 pl-5 pr-3 py-2 text-[13px] text-ink-2 hover:text-rubis transition-colors no-underline"
data-version={e.data.version}
>
<span
aria-hidden
class="rail-dot absolute left-[-5px] top-1/2 -translate-y-1/2 size-[9px] rounded-full bg-cream border-2 border-line group-hover:border-rubis transition-colors"
/>
<span class="rail-version font-display font-semibold tabular-nums">
v{e.data.version}
</span>
<span class="rail-date ml-auto text-[11.5px] text-ink-3 tabular-nums">
{dateShort.format(e.data.date)}
</span>
</a>
))
}
</nav>
</div>
</aside>
</div>
</section>
{/* Active state du rail via IntersectionObserver — vanilla JS, inline */}
<script is:inline>
(() => {
const links = document.querySelectorAll(".rail-link");
const cards = document.querySelectorAll(".changelog-card");
if (!links.length || !cards.length) return;
// Map version → link element
const linkByVersion = new Map();
links.forEach((l) => linkByVersion.set(l.dataset.version, l));
function setActive(version) {
links.forEach((l) => {
const active = l.dataset.version === version;
l.classList.toggle("rail-link--active", active);
const dot = l.querySelector(".rail-dot");
if (dot) {
dot.classList.toggle("bg-rubis", active);
dot.classList.toggle("border-rubis", active);
dot.classList.toggle("bg-cream", !active);
}
});
}
// Observe les cartes — l'active est la carte la plus haute encore au-dessus
// de la mi-hauteur viewport.
const observer = new IntersectionObserver(
(entries) => {
const visible = entries
.filter((e) => e.isIntersecting)
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);
if (visible.length > 0) {
const top = visible[0].target;
const version = top.getAttribute("id");
if (version) setActive(version);
}
},
{
rootMargin: "-20% 0px -60% 0px",
threshold: 0,
},
);
cards.forEach((c) => observer.observe(c));
// Initial active state — première carte
if (cards[0]?.id) setActive(cards[0].id);
})();
</script>
<style is:global>
/* Style des liens rail actifs — appliqué par le script ci-dessus.
En global parce que les classes sont toggled dynamiquement. */
.rail-link--active {
color: var(--color-rubis) !important;
font-weight: 600;
}
.rail-link--active .rail-version {
color: var(--color-rubis);
}
/* Prose pour le body markdown des entries — typo cohérente avec la landing,
espacements rythmiques, code inline propre. Pas de @tailwindcss/typography
pour pas alourdir le bundle, custom suffit largement vu la simplicité. */
.prose-changelog p {
margin-bottom: 0.9em;
}
.prose-changelog p:last-child {
margin-bottom: 0;
}
.prose-changelog ul {
list-style: none;
padding-left: 0;
margin: 0.5em 0 1em;
}
.prose-changelog ul li {
position: relative;
padding-left: 1.4em;
margin-bottom: 0.4em;
}
.prose-changelog ul li::before {
content: "";
position: absolute;
left: 0;
top: 0.6em;
width: 6px;
height: 6px;
background: var(--color-rubis);
transform: rotate(45deg);
}
.prose-changelog strong {
color: var(--color-ink);
font-weight: 600;
}
.prose-changelog code {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.92em;
background: var(--color-cream-2);
border: 1px solid var(--color-line);
padding: 1px 6px;
border-radius: 4px;
color: var(--color-ink);
}
.prose-changelog a {
color: var(--color-rubis);
text-decoration: underline;
text-underline-offset: 2px;
}
.prose-changelog a:hover {
color: var(--color-rubis-deep);
}
</style>
</Layout>

View File

@ -0,0 +1,77 @@
import type { APIRoute } from "astro";
import { getCollection } from "astro:content";
const SITE = "https://rubis.pro";
function escapeXml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}
function toRfc2822(d: Date): string {
return d.toUTCString();
}
/**
* Flux RSS 2.0 du changelog toutes les versions livrées en reverse-chrono.
* Le titre + les highlights sont concaténés dans `<description>` pour que les
* lecteurs RSS aient le résumé complet sans avoir à fetch la page.
*
* Auto-discovered depuis /changelog via <link rel="alternate" type="application/rss+xml">.
* Prerendered au build comme la page parente.
*/
export const prerender = true;
export const GET: APIRoute = async () => {
const entries = (await getCollection("changelog")).sort(
(a, b) => b.data.date.getTime() - a.data.date.getTime(),
);
const lastBuild =
entries[0]?.data.date
? toRfc2822(entries[0].data.date)
: new Date().toUTCString();
const items = entries
.map((e) => {
const url = `${SITE}/changelog#${e.data.version}`;
// Description = titre + highlights bulletés, pour lecteurs RSS textuels.
const desc =
`<![CDATA[<p><strong>${e.data.title}</strong></p>` +
`<ul>${e.data.highlights.map((h) => `<li>${h}</li>`).join("")}</ul>]]>`;
return ` <item>
<title>v${escapeXml(e.data.version)} ${escapeXml(e.data.title)}</title>
<link>${escapeXml(url)}</link>
<guid isPermaLink="true">${escapeXml(url)}</guid>
<pubDate>${toRfc2822(e.data.date)}</pubDate>
<description>${desc}</description>
<category>${escapeXml(e.data.type)}</category>
</item>`;
})
.join("\n");
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>Rubis Changelog</title>
<link>${SITE}/changelog</link>
<atom:link href="${SITE}/changelog/rss.xml" rel="self" type="application/rss+xml" />
<description>Toutes les nouveautés, améliorations et corrections livrées sur Rubis.</description>
<language>fr-FR</language>
<lastBuildDate>${lastBuild}</lastBuildDate>
${items}
</channel>
</rss>`;
return new Response(xml, {
status: 200,
headers: {
"Content-Type": "application/xml; charset=utf-8",
"Cache-Control": "public, max-age=300, stale-while-revalidate=86400",
},
});
};