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>
338 lines
12 KiB
Plaintext
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>
|