ordinarthur fc0d13e955 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>
2026-05-11 00:05:45 +02:00

338 lines
12 KiB
Plaintext

---
/**
* /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>