diff --git a/docusaurus.config.ts b/docusaurus.config.ts index 0b0b6db..ab52a5a 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', { @@ -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/component-embed.js b/plugins/social-preview/component-embed.js new file mode 100644 index 0000000..cf39169 --- /dev/null +++ b/plugins/social-preview/component-embed.js @@ -0,0 +1,331 @@ +/* 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; + +// 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', + 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}).`, + latestPosts: 'Derniers articles', + featuredLearn: 'Articles à la une', + startHere: 'Pour commencer', + }, + 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}).`, + latestPosts: 'Latest posts', + featuredLearn: 'Featured articles', + startHere: 'Start here', + }, + 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}).`, + latestPosts: 'Neueste Beiträge', + featuredLearn: 'Ausgewählte Artikel', + startHere: 'Zum Einstieg', + }, + 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}).`, + latestPosts: 'Últimos artículos', + featuredLearn: 'Artículos destacados', + startHere: 'Para empezar', + }, + 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}).`, + latestPosts: 'Últimos artigos', + featuredLearn: 'Artigos em destaque', + startHere: 'Para começar', + }, +}; + +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}]}); + +const section = (content, thumbnailUrl) => ({ + type: 9, + components: [textDisplay(content)], + accessory: {type: 11, media: {url: thumbnailUrl}}, +}); + +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('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'); + + 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 = []; + // 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') { + 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`))); + } + } + + 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); + 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/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 85% rename from plugins/og-images/index.js rename to plugins/social-preview/index.js index c889483..0be979f 100644 --- a/plugins/og-images/index.js +++ b/plugins/social-preview/index.js @@ -1,7 +1,11 @@ -/* 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. + * + * 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. * @@ -10,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']; @@ -130,17 +135,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'); @@ -356,7 +361,8 @@ module.exports = function ogImagesPlugin(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"/>` + @@ -370,7 +376,46 @@ module.exports = function ogImagesPlugin(context) { count++; } - console.log(`[og-images] (${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, + outDir, + base, + locale: currentLocale, + marketing: MARKETING, + }); + console.log(`[social-preview] (${currentLocale}) ${embeds} cartes Discord.`); }, }; };