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:
parent
642747d762
commit
fc0d13e955
@ -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>
|
||||
|
||||
31
apps/landing/src/content.config.ts
Normal file
31
apps/landing/src/content.config.ts
Normal 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 };
|
||||
28
apps/landing/src/content/changelog/1.0.0.md
Normal file
28
apps/landing/src/content/changelog/1.0.0.md
Normal 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.
|
||||
14
apps/landing/src/content/changelog/1.1.0.md
Normal file
14
apps/landing/src/content/changelog/1.1.0.md
Normal 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.
|
||||
14
apps/landing/src/content/changelog/1.10.0.md
Normal file
14
apps/landing/src/content/changelog/1.10.0.md
Normal 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.
|
||||
14
apps/landing/src/content/changelog/1.2.0.md
Normal file
14
apps/landing/src/content/changelog/1.2.0.md
Normal 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.
|
||||
14
apps/landing/src/content/changelog/1.3.0.md
Normal file
14
apps/landing/src/content/changelog/1.3.0.md
Normal 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.
|
||||
17
apps/landing/src/content/changelog/1.4.0.md
Normal file
17
apps/landing/src/content/changelog/1.4.0.md
Normal 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.
|
||||
14
apps/landing/src/content/changelog/1.5.0.md
Normal file
14
apps/landing/src/content/changelog/1.5.0.md
Normal 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.
|
||||
16
apps/landing/src/content/changelog/1.6.0.md
Normal file
16
apps/landing/src/content/changelog/1.6.0.md
Normal 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.
|
||||
15
apps/landing/src/content/changelog/1.7.0.md
Normal file
15
apps/landing/src/content/changelog/1.7.0.md
Normal 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.
|
||||
14
apps/landing/src/content/changelog/1.8.0.md
Normal file
14
apps/landing/src/content/changelog/1.8.0.md
Normal 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.
|
||||
14
apps/landing/src/content/changelog/1.9.0.md
Normal file
14
apps/landing/src/content/changelog/1.9.0.md
Normal 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.
|
||||
@ -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 */}
|
||||
{
|
||||
|
||||
337
apps/landing/src/pages/changelog/index.astro
Normal file
337
apps/landing/src/pages/changelog/index.astro
Normal 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>
|
||||
77
apps/landing/src/pages/changelog/rss.xml.ts
Normal file
77
apps/landing/src/pages/changelog/rss.xml.ts
Normal 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
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",
|
||||
},
|
||||
});
|
||||
};
|
||||
Loading…
x
Reference in New Issue
Block a user