From 48ec5b99a956ae3ae0301ea96e2574534c64e854 Mon Sep 17 00:00:00 2001 From: Zallom Date: Fri, 18 Sep 2026 14:12:37 +0200 Subject: [PATCH 1/4] refactor(seo): renomme le plugin og-images en social-preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le plugin ne se limitera plus aux images Open Graph : il va aussi porter l'aperçu des liens dans Discord. Le nom du dossier, celui du plugin, les logs et le dossier de cache suivent. --- docusaurus.config.ts | 2 +- .../fonts/nohemi-700.ttf | Bin .../fonts/objectivity-regular.otf | Bin plugins/{og-images => social-preview}/index.js | 13 ++++++++----- 4 files changed, 9 insertions(+), 6 deletions(-) rename plugins/{og-images => social-preview}/fonts/nohemi-700.ttf (100%) rename plugins/{og-images => social-preview}/fonts/objectivity-regular.otf (100%) rename plugins/{og-images => social-preview}/index.js (97%) diff --git a/docusaurus.config.ts b/docusaurus.config.ts index 0b0b6db..56b4162 100644 --- a/docusaurus.config.ts +++ b/docusaurus.config.ts @@ -106,7 +106,7 @@ export default async function createConfigAsync() { }, plugins: [ require.resolve('./plugins/llms-txt'), - require.resolve('./plugins/og-images'), + require.resolve('./plugins/social-preview'), [ 'docusaurus-plugin-github-editor', { diff --git a/plugins/og-images/fonts/nohemi-700.ttf b/plugins/social-preview/fonts/nohemi-700.ttf similarity index 100% rename from plugins/og-images/fonts/nohemi-700.ttf rename to plugins/social-preview/fonts/nohemi-700.ttf diff --git a/plugins/og-images/fonts/objectivity-regular.otf b/plugins/social-preview/fonts/objectivity-regular.otf similarity index 100% rename from plugins/og-images/fonts/objectivity-regular.otf rename to plugins/social-preview/fonts/objectivity-regular.otf diff --git a/plugins/og-images/index.js b/plugins/social-preview/index.js similarity index 97% rename from plugins/og-images/index.js rename to plugins/social-preview/index.js index c889483..664cf2c 100644 --- a/plugins/og-images/index.js +++ b/plugins/social-preview/index.js @@ -1,4 +1,7 @@ -/* Plugin Docusaurus : génère au build une image Open Graph (1200x630) par page +/* Plugin Docusaurus « social-preview » : gère l'aperçu des liens du site + * quand ils sont partagés ailleurs. + * + * Génère au build une image Open Graph (1200x630) par page * de doc, de glossaire, d'article de blog et pour les pages marketing, à partir * du titre + l'icône de la fonctionnalité (et un chiffre clé sur l'accueil), * puis injecte og:image / twitter:image / og:type / og:image:alt dans le HTML. @@ -130,17 +133,17 @@ function extractTitle(html) { const el = (type, style, children) => ({type, props: {style, ...(children !== undefined ? {children} : {})}}); -module.exports = function ogImagesPlugin(context) { +module.exports = function socialPreviewPlugin(context) { const {currentLocale, defaultLocale} = context.i18n; const localePrefix = currentLocale === defaultLocale ? '' : `/${currentLocale}`; const cat = CATEGORY[currentLocale] || CATEGORY[defaultLocale] || CATEGORY.fr; const tagline = TAGLINE[currentLocale] || TAGLINE[defaultLocale] || TAGLINE.fr; const stat = STAT[currentLocale] || STAT[defaultLocale] || STAT.fr; const iconsDir = path.join(context.siteDir, 'static/img/icons'); - const cacheDir = path.join(context.siteDir, 'node_modules/.cache/og-images'); + const cacheDir = path.join(context.siteDir, 'node_modules/.cache/social-preview'); return { - name: 'og-images', + name: 'social-preview', async postBuild({siteConfig, outDir}) { const satori = (await import('satori')).default; const {Resvg} = require('@resvg/resvg-js'); @@ -370,7 +373,7 @@ module.exports = function ogImagesPlugin(context) { count++; } - console.log(`[og-images] (${currentLocale}) ${count} images OG (docs, learn, marketing, accueil).`); + console.log(`[social-preview] (${currentLocale}) ${count} images OG (docs, learn, marketing, accueil).`); }, }; }; From 6b0bd2de15aa08f4d731f328b5ee8fa9e80d4f7a Mon Sep 17 00:00:00 2001 From: Zallom Date: Fri, 18 Sep 2026 15:41:09 +0200 Subject: [PATCH 2/4] =?UTF-8?q?feat(seo):=20g=C3=A9n=C3=A8re=20une=20carte?= =?UTF-8?q?=20Discord=20pour=20chaque=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Discord permet maintenant à un site de remplacer la carte Open Graph par une mise en page construite avec ses propres composants, via un payload `discord:component-embed` posé dans le (discord/discord-api-docs#8606). La passe tourne après la génération des images : elle relit le HTML déjà écrit, donc elle récupère l'image OG de la page sans dupliquer la logique de nommage. Chaque carte porte la couleur du site, le titre et la description de la page, son image, et une rangée de boutons vers la documentation, le support et l'invitation du bot, dans la langue de la page. --- plugins/social-preview/component-embed.js | 260 ++++++++++++++++++++++ plugins/social-preview/index.js | 15 +- 2 files changed, 273 insertions(+), 2 deletions(-) create mode 100644 plugins/social-preview/component-embed.js diff --git a/plugins/social-preview/component-embed.js b/plugins/social-preview/component-embed.js new file mode 100644 index 0000000..8e7b626 --- /dev/null +++ b/plugins/social-preview/component-embed.js @@ -0,0 +1,260 @@ +/* Passe 2 du plugin social-preview : component embeds Discord (`discord:component-embed`) : quand un lien du site + * est collé dans Discord, la carte Open Graph standard est remplacée par une + * mise en page Components V2 aux couleurs du site. Display-only : les boutons + * ouvrent une URL, rien n'envoie d'interaction, aucun bot n'est requis. + * Les balises OG restent en place et servent de repli. + * + * Passe exécutée après la génération des images OG, sur le HTML déjà écrit : + * l'image de la carte est donc celle que le HTML final déclare en og:image. + * Spec : https://discord.com/developers/docs/link-previews/component-embeds */ + +const fs = require('fs'); +const path = require('path'); + +const ACCENT_COLOR = 0xd35f5f; // --ifm-color-primary +const SUPPORT_URL = 'https://raidprotect.bot/discord'; +const MAX_COMPONENTS = 40; + +const T = { + fr: { + docs: 'Documentation', + support: 'Support', + invite: 'Ajouter RaidProtect', + changelog: (v, url) => + `Découvrez la liste complète des nouveautés dans le [changelog ${v}](${url}).`, + threats: (url) => + `ScamLens tourne par défaut sur tous les serveurs protégés : [comment ça marche](${url}).`, + }, + en: { + docs: 'Documentation', + support: 'Support', + invite: 'Add RaidProtect', + changelog: (v, url) => `See the full list of changes in the [${v} changelog](${url}).`, + threats: (url) => + `ScamLens runs by default on every protected server: [how it works](${url}).`, + }, + de: { + docs: 'Dokumentation', + support: 'Support', + invite: 'RaidProtect hinzufügen', + changelog: (v, url) => + `Die vollständige Liste der Neuerungen steht im [Changelog ${v}](${url}).`, + threats: (url) => + `ScamLens läuft standardmäßig auf jedem geschützten Server: [so funktioniert es](${url}).`, + }, + es: { + docs: 'Documentación', + support: 'Soporte', + invite: 'Añadir RaidProtect', + changelog: (v, url) => + `Consulta la lista completa de novedades en el [changelog ${v}](${url}).`, + threats: (url) => + `ScamLens funciona por defecto en todos los servidores protegidos: [cómo funciona](${url}).`, + }, + pt: { + docs: 'Documentação', + support: 'Suporte', + invite: 'Adicionar o RaidProtect', + changelog: (v, url) => `Veja a lista completa das novidades no [changelog ${v}](${url}).`, + threats: (url) => + `O ScamLens funciona por padrão em todos os servidores protegidos: [como funciona](${url}).`, + }, +}; + +const decode = (s) => + s + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/'/g, "'"); + +function walk(dir) { + const out = []; + if (!fs.existsSync(dir)) return out; + for (const e of fs.readdirSync(dir, {withFileTypes: true})) { + const full = path.join(dir, e.name); + if (e.isDirectory()) out.push(...walk(full)); + else if (e.name.endsWith('.html')) out.push(full); + } + return out; +} + +function meta(html) { + const title = html.match(/]*>([^<]*)<\/title>/i); + const desc = html.match(/]+name="description"[^>]+content="([^"]*)"/i); + const image = html.match(/]+property="og:image"[^>]+content="([^"]*)"/i); + return { + title: title ? decode(title[1]).replace(/\s*\|\s*RaidProtect\s*$/, '').trim() : null, + description: desc ? decode(desc[1]).trim() : null, + image: image ? decode(image[1]).trim() : null, + }; +} + +// La carte Discord est étroite : on coupe sur une frontière de phrase. +function trim(text, max) { + if (!text) return null; + if (text.length <= max) return text; + const cut = text.slice(0, max); + const stop = Math.max(cut.lastIndexOf('. '), cut.lastIndexOf(', '), cut.lastIndexOf(' ')); + return `${cut.slice(0, stop > max / 3 ? stop : max).trim()}…`; +} + +const escapeMd = (s) => s.replace(/([[\]])/g, '\\$1'); + +/* Un titre court (« Blog », « HoneyPot ») ne dit pas de quel site il vient : + * on lui remet le suffixe retiré du , sauf s'il se nomme déjà. */ +const withBrand = (title) => + title.length < 24 && !/raidprotect/i.test(title) ? `${title} | RaidProtect` : title; + +/* Métadonnées des articles de blog, lues une fois dans les sources FR : le + * slug et la date pilotent « derniers articles », les tags et le dossier de + * version pilotent la ligne contextuelle de chaque article. */ +function readBlogPosts(siteDir) { + const dir = path.join(siteDir, 'blog'); + const posts = []; + if (!fs.existsSync(dir)) return posts; + for (const folder of fs.readdirSync(dir, {withFileTypes: true})) { + if (!folder.isDirectory()) continue; + for (const file of fs.readdirSync(path.join(dir, folder.name))) { + if (!/\.mdx?$/.test(file)) continue; + const head = fs.readFileSync(path.join(dir, folder.name, file), 'utf8').slice(0, 1200); + const get = (k) => { + const m = head.match(new RegExp(`^${k}:\\s*(.+)$`, 'm')); + return m ? m[1].trim().replace(/^['"]|['"]$/g, '') : null; + }; + const slug = get('slug'); + if (!slug) continue; + const tags = (get('tags') || '').replace(/[[\]]/g, '').split(',').map((s) => s.trim()); + posts.push({ + slug, + date: get('date') || '', + tags, + version: /^\d+\.\d+/.test(folder.name) ? folder.name : null, + }); + } + } + return posts.sort((a, b) => (a.date < b.date ? 1 : -1)); +} + +const textDisplay = (content) => ({type: 10, content}); +const separator = () => ({type: 14, spacing: 1}); +const gallery = (url, description) => ({type: 12, items: [{media: {url}, description}]}); + +function footerRow(t, base) { + return { + type: 1, + components: [ + {type: 2, style: 5, url: `${base}/docs`, label: t.docs}, + {type: 2, style: 5, url: SUPPORT_URL, label: t.support}, + {type: 2, style: 5, url: `${base}/invite`, label: t.invite}, + ], + }; +} + +function countComponents(node) { + let n = 1; + for (const child of node.components || []) n += countComponents(child); + for (const item of node.items || []) n += 1; + if (node.accessory) n += 1; + return n; +} + +function scriptTag(components) { + const payload = {component: {type: 17, accent_color: ACCENT_COLOR, components}}; + const json = JSON.stringify(payload).replace(/</g, '\\u003c'); + return `<script id="discord:component-embed" type="application/json">${json}</script>`; +} + +module.exports = function injectComponentEmbeds({siteDir, outDir, base, locale, marketing}) { + const t = T[locale] || T.fr; + const posts = readBlogPosts(siteDir); + const LOCALES = ['en', 'de', 'es', 'pt']; + + const read = (rel) => { + const file = path.join(outDir, rel); + if (!fs.existsSync(file)) return null; + return {file, html: fs.readFileSync(file, 'utf8')}; + }; + const metaOf = (rel) => { + const r = read(rel); + return r ? meta(r.html) : null; + }; + const urlOf = (rel) => + rel === 'index.html' ? `${base}/` : `${base}/${rel.replace(/\.html$/, '')}`; + + // Pages à traiter, avec leur type. + const pages = []; + const addIfExists = (rel, kind) => { + if (fs.existsSync(path.join(outDir, rel))) pages.push({rel, kind}); + }; + for (const file of walk(path.join(outDir, 'docs'))) { + const rel = path.relative(outDir, file).split(path.sep).join('/'); + if (LOCALES.includes(rel.split('/')[0])) continue; + if (/(^|\/)edit\.html$/.test(rel) || /\/tags\//.test(rel) || /(^|\/)404/.test(rel)) continue; + pages.push({rel, kind: 'docs'}); // bêta incluse : non indexée, mais partageable + } + for (const file of walk(path.join(outDir, 'learn'))) { + const rel = path.relative(outDir, file).split(path.sep).join('/'); + if (LOCALES.includes(rel.split('/')[0]) || /\/tags\//.test(rel)) continue; + pages.push({rel, kind: 'learn'}); + } + for (const file of walk(path.join(outDir, 'blog'))) { + const rel = path.relative(outDir, file).split(path.sep).join('/'); + if (LOCALES.includes(rel.split('/')[0])) continue; + if (/\/(tags|page|archive)\b/.test(rel) || /^blog\/authors/.test(rel)) continue; + pages.push({rel, kind: 'blog-post'}); + } + addIfExists('index.html', 'home'); + for (const rel of marketing) addIfExists(rel, 'marketing'); + + let count = 0; + for (const p of pages) { + const r = read(p.rel); + if (!r) continue; + const m = meta(r.html); + if (!m.title) continue; + + // Les pages bêta n'ont pas d'image générée : on reprend celle de la page + // stable équivalente, sinon l'image par défaut du site. + let image = m.image; + const betaTwin = p.rel.match(/^docs\/beta\/(.+)$/); + if (betaTwin) { + const stable = metaOf(`docs/${betaTwin[1]}`); + if (stable && stable.image) image = stable.image; + } + + const pageUrl = urlOf(p.rel); + const head = `# [${escapeMd(withBrand(m.title))}](${pageUrl})`; + const components = []; + if (image) components.push(gallery(image, m.title)); + components.push(textDisplay(`${head}\n${trim(m.description, 220) || ''}`.trim())); + + if (p.kind === 'blog-post') { + const post = posts.find((x) => x.slug === p.rel.replace(/^blog\//, '').replace(/\.html$/, '')); + if (post && post.tags.includes('release') && post.version) { + const anchor = post.version.replace(/\./g, '-'); + components.push( + textDisplay(t.changelog(post.version, `${base}/docs/changelog#${anchor}`)), + ); + } else if (post && post.tags.includes('threats')) { + components.push(textDisplay(t.threats(`${base}/docs/features/scam-images`))); + } + } + + components.push(separator(), footerRow(t, base)); + + const tag = scriptTag(components); + if (countComponents({components}) > MAX_COMPONENTS) { + console.warn(`[social-preview] ${p.rel} : trop de composants, ignoré.`); + continue; + } + const html = r.html + .replace(/<script id="discord:component-embed"[^>]*>[\s\S]*?<\/script>/gi, '') + .replace('</head>', `${tag}</head>`); + fs.writeFileSync(r.file, html); + count++; + } + return count; +}; diff --git a/plugins/social-preview/index.js b/plugins/social-preview/index.js index 664cf2c..24faaca 100644 --- a/plugins/social-preview/index.js +++ b/plugins/social-preview/index.js @@ -1,10 +1,11 @@ /* Plugin Docusaurus « social-preview » : gère l'aperçu des liens du site * quand ils sont partagés ailleurs. * - * Génère au build une image Open Graph (1200x630) par page + * Passe 1 (ce fichier) : génère au build une image Open Graph (1200x630) par page * de doc, de glossaire, d'article de blog et pour les pages marketing, à partir * du titre + l'icône de la fonctionnalité (et un chiffre clé sur l'accueil), - * puis injecte og:image / twitter:image / og:type / og:image:alt dans le HTML. + * puis injecte og:image / twitter:image / og:type / og:image:alt et le payload + * discord:component-embed (carte enrichie quand le lien est collé dans Discord). * Rendu via Satori (HTML/CSS -> SVG) + resvg (SVG -> PNG) + sharp (compression). * Tourne par locale (titres localisés). Cache persistant pour des rebuilds rapides. * @@ -13,6 +14,7 @@ const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); +const injectComponentEmbeds = require('./component-embed'); const TEMPLATE_VERSION = '3'; // bump pour invalider le cache const LOCALES = ['en', 'de', 'es', 'pt']; @@ -374,6 +376,15 @@ module.exports = function socialPreviewPlugin(context) { } console.log(`[social-preview] (${currentLocale}) ${count} images OG (docs, learn, marketing, accueil).`); + + const embeds = injectComponentEmbeds({ + siteDir: context.siteDir, + outDir, + base, + locale: currentLocale, + marketing: MARKETING, + }); + console.log(`[social-preview] (${currentLocale}) ${embeds} cartes Discord.`); }, }; }; From fee463d8b51c5941f1d6905908607f69412d2829 Mon Sep 17 00:00:00 2001 From: Zallom <matthias@fca.gg> Date: Fri, 18 Sep 2026 16:58:24 +0200 Subject: [PATCH 3/4] =?UTF-8?q?feat(seo):=20cartes=20d=C3=A9di=C3=A9es=20p?= =?UTF-8?q?our=20les=20hubs=20blog,=20learn=20et=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sur une page de hub, reprendre le gabarit générique ne dit rien de ce qu'on y trouve. Le blog liste ses deux derniers articles et le glossaire trois articles à la une, chacun avec sa vignette ; l'accueil de la documentation pointe vers l'installation, les fonctionnalités, les commandes et les dysfonctionnements. Les titres de ces entrées sont lus dans les pages cibles, donc ils sont déjà traduits. Les deux hubs qui listent des entrées se passent de la grande image en tête : les vignettes suffisent, et la carte reste lisible. --- plugins/social-preview/component-embed.js | 73 ++++++++++++++++++++++- 1 file changed, 72 insertions(+), 1 deletion(-) diff --git a/plugins/social-preview/component-embed.js b/plugins/social-preview/component-embed.js index 8e7b626..cf39169 100644 --- a/plugins/social-preview/component-embed.js +++ b/plugins/social-preview/component-embed.js @@ -15,6 +15,18 @@ const ACCENT_COLOR = 0xd35f5f; // --ifm-color-primary const SUPPORT_URL = 'https://raidprotect.bot/discord'; const MAX_COMPONENTS = 40; +// Derniers articles listés sur la carte du hub Blog. +const LATEST_POSTS = 2; +// Articles mis en avant sur la carte du hub Learn. +const FEATURED_LEARN = ['discord-raid', 'discord-nuke', 'discord-sanctions']; +// Entrées mises en avant sur la carte de l'accueil de la documentation. +const DOCS_QUICK_LINKS = [ + 'docs/setup.html', + 'docs/features.html', + 'docs/commands.html', + 'docs/guides/malfunctions.html', +]; + const T = { fr: { docs: 'Documentation', @@ -24,6 +36,9 @@ const T = { `Découvrez la liste complète des nouveautés dans le [changelog ${v}](${url}).`, threats: (url) => `ScamLens tourne par défaut sur tous les serveurs protégés : [comment ça marche](${url}).`, + latestPosts: 'Derniers articles', + featuredLearn: 'Articles à la une', + startHere: 'Pour commencer', }, en: { docs: 'Documentation', @@ -32,6 +47,9 @@ const T = { changelog: (v, url) => `See the full list of changes in the [${v} changelog](${url}).`, threats: (url) => `ScamLens runs by default on every protected server: [how it works](${url}).`, + latestPosts: 'Latest posts', + featuredLearn: 'Featured articles', + startHere: 'Start here', }, de: { docs: 'Dokumentation', @@ -41,6 +59,9 @@ const T = { `Die vollständige Liste der Neuerungen steht im [Changelog ${v}](${url}).`, threats: (url) => `ScamLens läuft standardmäßig auf jedem geschützten Server: [so funktioniert es](${url}).`, + latestPosts: 'Neueste Beiträge', + featuredLearn: 'Ausgewählte Artikel', + startHere: 'Zum Einstieg', }, es: { docs: 'Documentación', @@ -50,6 +71,9 @@ const T = { `Consulta la lista completa de novedades en el [changelog ${v}](${url}).`, threats: (url) => `ScamLens funciona por defecto en todos los servidores protegidos: [cómo funciona](${url}).`, + latestPosts: 'Últimos artículos', + featuredLearn: 'Artículos destacados', + startHere: 'Para empezar', }, pt: { docs: 'Documentação', @@ -58,6 +82,9 @@ const T = { changelog: (v, url) => `Veja a lista completa das novidades no [changelog ${v}](${url}).`, threats: (url) => `O ScamLens funciona por padrão em todos os servidores protegidos: [como funciona](${url}).`, + latestPosts: 'Últimos artigos', + featuredLearn: 'Artigos em destaque', + startHere: 'Para começar', }, }; @@ -142,6 +169,12 @@ const textDisplay = (content) => ({type: 10, content}); const separator = () => ({type: 14, spacing: 1}); const gallery = (url, description) => ({type: 12, items: [{media: {url}, description}]}); +const section = (content, thumbnailUrl) => ({ + type: 9, + components: [textDisplay(content)], + accessory: {type: 11, media: {url: thumbnailUrl}}, +}); + function footerRow(t, base) { return { type: 1, @@ -206,6 +239,9 @@ module.exports = function injectComponentEmbeds({siteDir, outDir, base, locale, if (/\/(tags|page|archive)\b/.test(rel) || /^blog\/authors/.test(rel)) continue; pages.push({rel, kind: 'blog-post'}); } + addIfExists('docs.html', 'docs-index'); + addIfExists('learn.html', 'learn-index'); + addIfExists('blog.html', 'blog-index'); addIfExists('index.html', 'home'); for (const rel of marketing) addIfExists(rel, 'marketing'); @@ -228,7 +264,10 @@ module.exports = function injectComponentEmbeds({siteDir, outDir, base, locale, const pageUrl = urlOf(p.rel); const head = `# [${escapeMd(withBrand(m.title))}](${pageUrl})`; const components = []; - if (image) components.push(gallery(image, m.title)); + // Sur les hubs qui listent des entrées, les vignettes portent déjà les + // illustrations : une grande image en tête rendrait la carte interminable. + const listsEntries = p.kind === 'blog-index' || p.kind === 'learn-index'; + if (image && !listsEntries) components.push(gallery(image, m.title)); components.push(textDisplay(`${head}\n${trim(m.description, 220) || ''}`.trim())); if (p.kind === 'blog-post') { @@ -243,6 +282,38 @@ module.exports = function injectComponentEmbeds({siteDir, outDir, base, locale, } } + if (p.kind === 'blog-index' || p.kind === 'learn-index') { + const label = p.kind === 'blog-index' ? t.latestPosts : t.featuredLearn; + const rels = + p.kind === 'blog-index' + ? posts.slice(0, LATEST_POSTS).map((x) => `blog/${x.slug}.html`) + : FEATURED_LEARN.map((s) => `learn/${s}.html`); + const entries = []; + for (const rel of rels) { + const em = metaOf(rel); + if (!em || !em.title || !em.image) continue; + entries.push( + section( + `**[${escapeMd(em.title)}](${urlOf(rel)})**\n${trim(em.description, 110) || ''}`.trim(), + em.image, + ), + ); + } + if (entries.length) { + components.push(separator(), textDisplay(`**${label}**`), ...entries); + } + } + + if (p.kind === 'docs-index') { + const links = DOCS_QUICK_LINKS.map((rel) => { + const em = metaOf(rel); + return em && em.title ? `- [${escapeMd(em.title)}](${urlOf(rel)})` : null; + }).filter(Boolean); + if (links.length) { + components.push(textDisplay(`**${t.startHere}**\n${links.join('\n')}`)); + } + } + components.push(separator(), footerRow(t, base)); const tag = scriptTag(components); From ae9cf4101b270078bbc06ae45e30a4cb731222fe Mon Sep 17 00:00:00 2001 From: Zallom <matthias@fca.gg> Date: Fri, 18 Sep 2026 18:06:52 +0200 Subject: [PATCH 4/4] fix(seo): une seule balise og:image par page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deux acteurs écrivaient la balise : react-helmet la posait depuis `themeConfig.image`, le plugin la réécrivait ensuite dans le HTML. Le crawler lisait la bonne valeur, le DOM affichait l'ancienne après hydratation. On retire donc la valeur par défaut du thème. Le plugin devient seul responsable, sauf pour les articles de blog qui gardent l'image de leur frontmatter. Les pages sans carte dédiée reçoivent celle de l'accueil, ce qui supprime au passage la dernière dépendance à une image externe. --- docusaurus.config.ts | 1 - plugins/social-preview/index.js | 35 +++++++++++++++++++++++++++++++-- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/docusaurus.config.ts b/docusaurus.config.ts index 56b4162..ab52a5a 100644 --- a/docusaurus.config.ts +++ b/docusaurus.config.ts @@ -231,7 +231,6 @@ export default async function createConfigAsync() { } ], themeConfig: { - image: 'https://cdn.prod.website-files.com/677fbd67c3c9318f7fb56659/678fefffd131bc2bbafd4468_RP-embed.webp', metadata: [ { name: 'theme-color', content: '#D35F5F' }, { property: 'og:site_name', content: 'RaidProtect' }, diff --git a/plugins/social-preview/index.js b/plugins/social-preview/index.js index 24faaca..0be979f 100644 --- a/plugins/social-preview/index.js +++ b/plugins/social-preview/index.js @@ -361,7 +361,8 @@ module.exports = function socialPreviewPlugin(context) { .replace(/<meta[^>]+property="og:image(:width|:height|:alt)?"[^>]*>/gi, '') .replace(/<meta[^>]+name="twitter:image"[^>]*>/gi, '') .replace(/<meta[^>]+name="twitter:card"[^>]*>/gi, '') - .replace(/<meta[^>]+property="og:type"[^>]*>/gi, ''); + .replace(/<meta[^>]+property="og:type"[^>]*>/gi, '') + .replace(/<script id="discord:component-embed"[^>]*>[\s\S]*?<\/script>/gi, ''); const tags = `<meta property="og:image" content="${url}"/>` + `<meta property="og:image:width" content="1200"/>` + @@ -375,7 +376,37 @@ module.exports = function socialPreviewPlugin(context) { count++; } - console.log(`[social-preview] (${currentLocale}) ${count} images OG (docs, learn, marketing, accueil).`); + /* La carte de l'accueil (logo, accroche, chiffre clé) fait une carte + * générique parfaitement valable : on la duplique plutôt que d'en + * composer une seconde qui dirait la même chose. */ + const homeCard = path.join(ogDir, 'home.png'); + if (fs.existsSync(homeCard)) fs.copyFileSync(homeCard, path.join(ogDir, 'default.png')); + + /* Repli : les pages que le plugin n'illustre pas (mentions légales, + * listings, doc bêta…) n'ont plus d'image depuis que `themeConfig.image` + * a été retiré. On leur donne la carte générique. Les pages qui ont déjà + * une og:image sont laissées telles quelles : c'est le cas des articles + * de blog, dont l'image vient de leur frontmatter via react-helmet. Une + * page, un seul acteur qui écrit la balise. */ + const defaultUrl = `${base}/img/og/default.png`; + let fallback = 0; + for (const file of walk(outDir)) { + const rel = path.relative(outDir, file).split(path.sep).join('/'); + if (LOCALES.includes(rel.split('/')[0])) continue; + const html = fs.readFileSync(file, 'utf8'); + if (/<meta[^>]+property="og:image"/i.test(html)) continue; + const tags = + `<meta property="og:image" content="${defaultUrl}"/>` + + `<meta property="og:image:width" content="1200"/>` + + `<meta property="og:image:height" content="630"/>` + + `<meta property="og:image:alt" content="RaidProtect"/>` + + `<meta name="twitter:image" content="${defaultUrl}"/>` + + `<meta name="twitter:card" content="summary_large_image"/>`; + fs.writeFileSync(file, html.replace('</head>', `${tags}</head>`)); + fallback++; + } + + console.log(`[social-preview] (${currentLocale}) ${count} images OG + ${fallback} pages sur la carte générique.`); const embeds = injectComponentEmbeds({ siteDir: context.siteDir,