fix(seo): smart title suffix + OG image par défaut 1200×630
All checks were successful
Build & Deploy Landing / build-and-deploy (push) Successful in 1m1s

Audit SEO révélait deux pertes de valeur :

1. **Titres trop longs** (86 chars sur la home, 71 chars sur les articles).
   Google tronque à ~60 chars dans le SERP. Le suffixe automatique
   `— Rubis sur l'ongle` (20 chars) écrasait le message-clé.

   Layout.astro fait maintenant un suffix smart :
   * Si title <45 chars ET ne contient pas "Rubis" → suffix `— Rubis` (8 chars)
   * Sinon → titre tel quel (la brand est déjà couverte par og:site_name +
     JSON-LD publisher + le hostname rubis.pro visible dans la SERP).

   Résultat : home 64 chars, article 51 chars, "Mentions légales" → 24 chars
   avec suffix.

2. **Pas d'`og:image` sur les pages sans hero** (home, légal). Sur
   LinkedIn/X/Slack, aucune preview image — perte d'engagement énorme.

   Ajout d'un og-default.png 1200×630 (165 KB optimisé) dans
   apps/landing/public/, monté par fallback par Layout.astro quand la
   page ne fournit pas d'`ogImage` explicite. Twitter:card devient
   toujours `summary_large_image`.

   L'image a été générée via le nouvel outil HTML
   docs/marketing/assets/og-default.html (clone de la mécanique du
   linkedin-banner.html — clic sur "Télécharger PNG" et go).
   Compose : brand + tagline + mock card 124 rubis + CTA rubis.pro.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
ordinarthur 2026-05-09 18:14:28 +02:00
parent b2dd991c58
commit eda5436d12
3 changed files with 456 additions and 15 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

View File

@ -16,12 +16,20 @@ import { SiteFooter } from "../components/SiteFooter";
const SITE_URL = "https://rubis.pro";
/**
* OG image par défaut (1200×630) servie depuis apps/landing/public/.
* Utilisée pour les partages sociaux quand aucune image n'est fournie
* par la page (cas de la home + pages légales). Les articles de blog
* ont leur propre hero qui prend précédence.
*/
const DEFAULT_OG_IMAGE = `${SITE_URL}/og-default.png`;
interface Props {
title: string;
description: string;
/** Path absolu de la page courante (ex. "/blog/foo"). Default = location actuelle. */
pathname?: string;
/** OG image absolue (1200×630 idéal). */
/** OG image absolue (1200×630 idéal). Fallback sur og-default.png si non fournie. */
ogImage?: string;
/** OG type. Default "website". Mettre "article" sur les pages de blog. */
ogType?: "website" | "article";
@ -44,7 +52,20 @@ const {
jsonLd,
} = Astro.props;
const fullTitle = title.includes("Rubis") ? title : `${title} — Rubis sur l'ongle`;
/**
* Suffixe brand intelligent :
* - Si le titre est court (<45 chars) ET ne contient pas "Rubis", on ajoute
* " — Rubis" pour la cohérence brand dans les SERP.
* - Sinon (titre long ou déjà brandé), on laisse tel quel : Google tronque
* à ~60 chars dans le snippet, autant garder le message-clé en entier.
*
* Le branding reste assuré par og:site_name + JSON-LD publisher + le hostname
* rubis.pro visible dans la SERP — pas besoin de le marteler dans le title.
*/
const fullTitle =
title.length < 45 && !title.includes("Rubis") ? `${title} — Rubis` : title;
const resolvedOgImage = ogImage ?? DEFAULT_OG_IMAGE;
const url = `${SITE_URL}${pathname ?? Astro.url.pathname}`;
const robots = noindex ? "noindex,nofollow" : "index,follow,max-image-preview:large";
const jsonLdArray = jsonLd ? (Array.isArray(jsonLd) ? jsonLd : [jsonLd]) : [];
@ -72,22 +93,17 @@ const jsonLdArray = jsonLd ? (Array.isArray(jsonLd) ? jsonLd : [jsonLd]) : [];
<meta property="og:title" content={fullTitle} />
<meta property="og:description" content={description} />
<meta property="og:url" content={url} />
{
ogImage && (
<>
<meta property="og:image" content={ogImage} />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:image:alt" content={title} />
</>
)
}
<meta property="og:image" content={resolvedOgImage} />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:image:alt" content={title} />
{/* Twitter Card */}
<meta name="twitter:card" content={ogImage ? "summary_large_image" : "summary"} />
{/* Twitter Card — toujours summary_large_image puisqu'on a maintenant
un og-default.png 1200×630 servi par défaut sur les pages sans hero. */}
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={fullTitle} />
<meta name="twitter:description" content={description} />
{ogImage && <meta name="twitter:image" content={ogImage} />}
<meta name="twitter:image" content={resolvedOgImage} />
{/* Favicons */}
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />

View File

@ -0,0 +1,425 @@
<!doctype html>
<!--
OG image par défaut — 1200 × 630 px (ratio 1.91:1, standard Open Graph).
Servie sur rubis.pro/og-default.png par Astro static (apps/landing/public/).
Pourquoi ce ratio :
- Facebook / LinkedIn / Twitter / Slack utilisent tous le carrousel
1200 × 630 (~1.91:1) pour les preview cards.
- Les images plus larges (LinkedIn banner 1584 × 280) sont rognées en
preview car le viewport square les recoupe.
Ouvre ce fichier dans Chrome et clique :
- "Télécharger PNG" → og-default.png, prêt à poser dans
apps/landing/public/og-default.png
La page est volontairement BUSY (mock card visible, tagline complète) —
l'objectif est de transmettre le produit en un coup d'œil quand quelqu'un
partage rubis.pro sur LinkedIn / X / Slack / iMessage.
-->
<html lang="fr">
<head>
<meta charset="UTF-8" />
<title>OG image par défaut — Rubis sur l'ongle</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,500;12..96,600;12..96,700;12..96,800&family=Inter:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
<style>
:root {
--rubis: #9f1239;
--rubis-deep: #771328;
--rubis-light: #c9415c;
--rubis-glow: #fbe4ea;
--ink: #1a1410;
--ink-2: #4f4640;
--ink-3: #8a7f76;
--line: #e8e0d6;
--cream: #faf7f2;
--cream-2: #f5efe7;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: #d9d4cb;
font-family: "Inter", -apple-system, BlinkMacSystemFont, sans-serif;
-webkit-font-smoothing: antialiased;
padding: 24px;
}
.controls {
display: flex;
gap: 12px;
margin-bottom: 20px;
align-items: center;
}
.controls h2 {
font-family: "Bricolage Grotesque", sans-serif;
font-weight: 700;
font-size: 18px;
color: #1a1410;
margin: 0 16px 0 0;
letter-spacing: -0.012em;
}
.btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 18px;
border: none;
border-radius: 6px;
font-family: "Inter", sans-serif;
font-weight: 600;
font-size: 14px;
cursor: pointer;
transition: background 0.15s, transform 0.1s;
}
.btn-primary {
background: #9f1239;
color: white;
box-shadow: 0 2px 6px rgba(159, 18, 57, 0.25);
}
.btn-primary:hover { background: #771328; transform: translateY(-1px); }
.btn-secondary {
background: white;
color: #1a1410;
border: 1px solid #e8e0d6;
}
.btn-secondary:hover { background: #faf7f2; }
.btn:disabled { opacity: 0.6; cursor: wait; }
.hint { font-size: 12.5px; color: #4f4640; margin-left: 8px; }
/* ===== OG image — 1200 × 630 ===== */
.og {
width: 1200px;
height: 630px;
background: var(--cream);
position: relative;
display: grid;
grid-template-columns: 1.15fr 0.85fr;
gap: 56px;
padding: 64px 72px;
overflow: hidden;
}
/* Halo rubis discret en haut-droite */
.og::before {
content: "";
position: absolute;
top: -240px;
right: -180px;
width: 520px;
height: 520px;
background: radial-gradient(circle, var(--rubis-glow) 0%, transparent 65%);
pointer-events: none;
}
/* Gem watermark très transparent en pied */
.og::after {
content: "";
position: absolute;
bottom: -200px;
left: 25%;
width: 380px;
height: 380px;
transform: rotate(45deg);
background: var(--rubis);
opacity: 0.04;
border-radius: 28px;
pointer-events: none;
}
/* ============== LEFT — brand + tagline ============== */
.left {
display: flex;
flex-direction: column;
justify-content: center;
z-index: 1;
max-width: 580px;
}
.brand-row {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 28px;
}
.gem-svg {
width: 38px;
height: 38px;
filter: drop-shadow(0 2px 4px rgba(159, 18, 57, 0.18));
}
.brand-name {
font-family: "Bricolage Grotesque", sans-serif;
font-weight: 800;
font-size: 28px;
color: var(--ink);
letter-spacing: -0.02em;
line-height: 1;
}
.brand-name .suffix {
font-style: italic;
font-weight: 500;
color: var(--ink-3);
font-size: 18px;
margin-left: 4px;
}
.tagline {
font-family: "Bricolage Grotesque", sans-serif;
font-weight: 700;
font-size: 56px;
color: var(--ink);
line-height: 1.05;
letter-spacing: -0.028em;
}
.tagline em {
font-style: italic;
color: var(--rubis);
}
.pitch {
margin-top: 20px;
font-size: 18px;
color: var(--ink-2);
line-height: 1.5;
max-width: 480px;
}
.pitch b {
font-weight: 700;
color: var(--ink);
}
.url {
margin-top: 32px;
display: inline-flex;
align-items: center;
gap: 10px;
font-family: "Bricolage Grotesque", sans-serif;
font-weight: 600;
font-size: 16px;
color: var(--ink-2);
background: white;
padding: 10px 18px;
border-radius: 8px;
border: 1px solid var(--line);
align-self: flex-start;
box-shadow: 0 2px 8px rgba(26, 20, 16, 0.04);
}
.url::before {
content: "";
width: 9px;
height: 9px;
background: var(--rubis);
transform: rotate(45deg);
}
/* ============== RIGHT — mock card ============== */
.right {
display: flex;
flex-direction: column;
justify-content: center;
align-items: flex-end;
z-index: 1;
}
.card {
width: 100%;
max-width: 380px;
background: white;
border: 1px solid var(--line);
border-radius: 18px;
padding: 28px 30px;
box-shadow:
0 24px 48px -16px rgba(26, 20, 16, 0.18),
0 6px 12px -4px rgba(26, 20, 16, 0.08);
}
.rubis-hero {
display: flex;
align-items: center;
gap: 18px;
padding-bottom: 20px;
border-bottom: 1px solid var(--line);
}
.rubis-hero .gem-big {
width: 52px;
height: 52px;
filter: drop-shadow(0 4px 10px rgba(159, 18, 57, 0.32));
flex-shrink: 0;
}
.rubis-count {
font-family: "Bricolage Grotesque", sans-serif;
font-weight: 700;
font-size: 32px;
color: var(--ink);
letter-spacing: -0.022em;
line-height: 1;
}
.rubis-sub {
font-size: 14px;
color: var(--ink-2);
margin-top: 6px;
line-height: 1.4;
}
.rubis-sub b { font-weight: 600; color: var(--ink); }
.kpis {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 22px;
margin-top: 20px;
}
.kpi-label {
font-size: 11px;
color: var(--ink-3);
text-transform: uppercase;
letter-spacing: 0.08em;
font-weight: 600;
}
.kpi-value {
font-family: "Bricolage Grotesque", sans-serif;
font-weight: 700;
font-size: 22px;
color: var(--ink);
letter-spacing: -0.015em;
margin-top: 5px;
font-variant-numeric: tabular-nums;
}
.kpi-delta {
font-size: 12px;
color: var(--rubis);
font-weight: 500;
margin-top: 3px;
}
.activity {
margin-top: 18px;
padding-top: 14px;
border-top: 1px dashed var(--line);
font-size: 12.5px;
color: var(--ink-2);
display: flex;
justify-content: space-between;
align-items: baseline;
}
.activity b { color: var(--ink); font-weight: 600; }
.activity time { color: var(--ink-3); font-size: 11.5px; font-variant-numeric: tabular-nums; }
</style>
</head>
<body>
<div class="controls">
<h2>OG image — 1200 × 630</h2>
<button id="exportPng" class="btn btn-primary">🖼️ Télécharger og-default.png</button>
<span class="hint">Pose ensuite dans <code>apps/landing/public/og-default.png</code></span>
</div>
<div class="og">
<!-- ============ LEFT ============ -->
<div class="left">
<div class="brand-row">
<svg class="gem-svg" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<polygon points="50,6 6,50 50,50" fill="#9F1239" fill-opacity="1" />
<polygon points="50,6 94,50 50,50" fill="#9F1239" fill-opacity="0.8" />
<polygon points="6,50 50,50 50,94" fill="#9F1239" fill-opacity="0.65" />
<polygon points="50,50 94,50 50,94" fill="#9F1239" fill-opacity="0.48" />
<polygon points="50,6 94,50 50,94 6,50" fill="none" stroke="#9F1239" stroke-width="1.6" stroke-linejoin="round" />
</svg>
<span class="brand-name">Rubis<span class="suffix">sur l'ongle</span></span>
</div>
<h1 class="tagline">
Vos factures relancées <em>toutes seules</em>.
</h1>
<p class="pitch">
Le SaaS de relance pour TPE-PME françaises. <b>5 heures par semaine</b>
récupérées, automatiquement.
</p>
<div class="url">rubis.pro</div>
</div>
<!-- ============ RIGHT — mock card ============ -->
<div class="right">
<div class="card">
<div class="rubis-hero">
<svg class="gem-big" viewBox="0 0 100 100">
<polygon points="50,6 6,50 50,50" fill="#9F1239" fill-opacity="1" />
<polygon points="50,6 94,50 50,50" fill="#9F1239" fill-opacity="0.8" />
<polygon points="6,50 50,50 50,94" fill="#9F1239" fill-opacity="0.65" />
<polygon points="50,50 94,50 50,94" fill="#9F1239" fill-opacity="0.48" />
<polygon points="50,6 94,50 50,94 6,50" fill="none" stroke="#9F1239" stroke-width="1.6" stroke-linejoin="round" />
</svg>
<div>
<div class="rubis-count">124 rubis</div>
<div class="rubis-sub"><b>24 h 48</b> libérées ce mois</div>
</div>
</div>
<div class="kpis">
<div>
<div class="kpi-label">Encaissé</div>
<div class="kpi-value">14 320 €</div>
<div class="kpi-delta">+ 2 800 € vs avril</div>
</div>
<div>
<div class="kpi-label">DSO</div>
<div class="kpi-value">38 j</div>
<div class="kpi-delta">6 j depuis Rubis</div>
</div>
</div>
<div class="activity">
<span>✓ Facture <b>F-2024-035</b> encaissée</span>
<time>10:02</time>
</div>
</div>
</div>
</div>
<!-- Lib CDN pour export PNG -->
<script src="https://cdn.jsdelivr.net/npm/html-to-image@1.11.13/dist/html-to-image.min.js"></script>
<script>
const og = document.querySelector(".og");
const btnPng = document.getElementById("exportPng");
// Capture en 2× pour rendu net, puis le navigateur télécharge en 2400 × 1260
// (LinkedIn / Twitter / Facebook downsamplent au format final 1200 × 630).
const captureOptions = {
width: 1200,
height: 630,
canvasWidth: 1200 * 2,
canvasHeight: 630 * 2,
pixelRatio: 2,
backgroundColor: "#FAF7F2",
cacheBust: true,
};
btnPng.addEventListener("click", async () => {
btnPng.disabled = true;
btnPng.textContent = "⏳ Génération…";
try {
await document.fonts.ready;
const dataUrl = await htmlToImage.toPng(og, captureOptions);
const link = document.createElement("a");
link.download = "og-default.png";
link.href = dataUrl;
link.click();
btnPng.textContent = "✓ PNG téléchargé";
setTimeout(() => {
btnPng.textContent = "🖼️ Télécharger og-default.png";
btnPng.disabled = false;
}, 1800);
} catch (err) {
console.error("Export PNG :", err);
alert("Erreur export PNG : " + err.message);
btnPng.disabled = false;
btnPng.textContent = "🖼️ Télécharger og-default.png";
}
});
</script>
</body>
</html>