rubis/apps/api/app/services/blog_renderer.ts
ordinarthur e5530930b3
Some checks failed
Build & Deploy API / build-and-deploy (push) Failing after 17s
Build & Deploy Web / build-and-deploy (push) Successful in 1m15s
Build & Deploy Landing / build-and-deploy (push) Failing after 3m43s
feat: refactor frontend en stack React unifiée (Astro + packages/ui)
Trois surfaces partagent désormais le même design system, Tailwind v4
et React 19 — au lieu d'avoir landing en HTML vanilla, app en React, et
blog en Adonis SSR :

* packages/ui — design system partagé (tokens Tailwind v4 + composants
  TSX) extrait depuis apps/web : Brand, Gem, Button, Card, Chip, Eyebrow,
  EmptyState. apps/web migre 41 imports vers @rubis/ui.

* apps/landing — nouvelle app Astro 6 SSR (rubis.pro), remplace l'ancienne
  landing nginx vanilla. Embarque :
  - Landing complète portée en sections React (Hero, Stats, Promise,
    HowItWorks, Gamification, Legal, Pricing, FAQ, FinalCTA, Footnotes)
  - Pages légales (mentions, confidentialité, CGV) via LegalLayout.astro
  - Blog SSR (/blog, /blog/:slug) qui consomme /api/v1/posts
  - sitemap.xml, blog/rss.xml, robots.txt en endpoints Astro
  - SEO complet (canonical, hreflang, OG, Twitter Card, JSON-LD
    Article/BreadcrumbList/Blog/SoftwareApplication)

* apps/api — BlogController réduit à 2 endpoints JSON (GET /api/v1/posts
  + GET /api/v1/posts/:slug). Suppression des templates SSR Adonis
  (apps/api/app/blog/), de l'alias #blog/*, des deps react-dom et
  @types/react-dom. PostTransformer + PostSummaryTransformer ajoutés.
  Le service blog_renderer + le seeder + les 3 articles fondateurs
  restent intacts (réutilisés par futurs admin + cron IA).

* Infra :
  - Dockerfile.landing (multi-stage Node 22 + tini, Astro standalone)
  - k3s/app/landing.yml (Deployment + Service rubis-landing:4321 +
    ConfigMap avec API_URL=http://rubis-api.rubis.svc.cluster.local:3333)
  - .gitea/workflows/deploy.yml mis à jour pour build rubis-landing
  - .gitea/workflows/deploy-web.yml + Dockerfile.web : prennent en
    compte packages/ui/ comme dépendance
  - Suppression du Dockerfile nginx legacy + k3s/{deployment,service}.yml
  - Suppression de landing/ (assets favicons migrés vers
    apps/landing/public/)

* Docs : architecture.md (vue d'ensemble + §4bis apps/landing complet,
  §3 endpoints JSON blog, layout monorepo), CLAUDE.md (stack technique,
  documents associés, déploiement).

Note infra : l'ancien Deployment "rubis" (nginx) et son Service ne sont
PAS supprimés par la CI — à nettoyer manuellement après validation que
Traefik a été repointé sur rubis-landing:4321 dans le repo proxmox.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 15:09:13 +02:00

104 lines
3.5 KiB
TypeScript

/**
* blog_renderer — pipeline markdown → HTML pour les articles du blog.
*
* Appelé :
* - au seeder (3 articles fondateurs en DB)
* - depuis l'admin React au save (PR3) via un endpoint dédié
* - depuis le cron weekly_blog_generator (PR4) pour les drafts IA
*
* Le HTML rendu est cache dans `posts.content_html` pour éviter de re-parser
* le markdown à chaque hit page. Si tu changes ce module, prévois une
* migration de re-render des posts existants.
*/
import { Marked, type Tokens } from 'marked'
/** Mots/min retenus pour le calcul reading_time (moyenne lecteur fr web). */
const WORDS_PER_MINUTE = 220
/**
* Renderer marked configuré pour le blog Rubis :
* - GFM (tables, autolinks, ~strikethrough~)
* - heading IDs auto pour ancres / future TOC
* - liens externes en target=_blank rel=noopener
* - br: false (un saut de ligne ne devient pas <br>, seul un \n\n crée un <p>)
*/
const marked = new Marked({
gfm: true,
breaks: false,
pedantic: false,
})
marked.use({
renderer: {
heading({ tokens, depth }: Tokens.Heading): string {
const text = this.parser.parseInline(tokens)
const id = slugify(stripTags(text))
return `<h${depth} id="${id}">${text}</h${depth}>\n`
},
link({ href, title, tokens }: Tokens.Link): string {
const text = this.parser.parseInline(tokens)
const isExternal = /^https?:\/\//.test(href) && !href.startsWith('https://rubis.pro')
const titleAttr = title ? ` title="${escapeHtmlAttr(title)}"` : ''
const relAttr = isExternal ? ' rel="noopener noreferrer"' : ''
const targetAttr = isExternal ? ' target="_blank"' : ''
return `<a href="${href}"${titleAttr}${relAttr}${targetAttr}>${text}</a>`
},
image({ href, title, text }: Tokens.Image): string {
// Lazy par défaut, dimensions à enrichir via image processing futur.
const altAttr = `alt="${escapeHtmlAttr(text || '')}"`
const titleAttr = title ? ` title="${escapeHtmlAttr(title)}"` : ''
return `<img src="${href}" ${altAttr}${titleAttr} loading="lazy" decoding="async" />`
},
},
})
export type RenderedPost = {
contentHtml: string
wordCount: number
readingTimeMinutes: number
}
/** Markdown → HTML + métriques de lecture. */
export function renderPost(contentMd: string): RenderedPost {
const contentHtml = marked.parse(contentMd, { async: false }) as string
const wordCount = countWords(contentMd)
const readingTimeMinutes = Math.max(1, Math.round(wordCount / WORDS_PER_MINUTE))
return { contentHtml, wordCount, readingTimeMinutes }
}
/**
* Slug ASCII kebab-case déterministe.
* "Comment relancer un client — sans rien casser" → "comment-relancer-un-client-sans-rien-casser"
*/
export function slugify(input: string): string {
return input
.normalize('NFD')
.replace(/[̀-ͯ]/g, '') // diacritiques
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 200)
}
function countWords(md: string): number {
// On strippe code blocks + balisage MD avant de compter — sinon les ``` et **
// gonflent artificiellement.
const text = md
.replace(/```[\s\S]*?```/g, ' ')
.replace(/`[^`]*`/g, ' ')
.replace(/!\[[^\]]*\]\([^)]*\)/g, ' ')
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
.replace(/[*_~#>]/g, ' ')
const matches = text.match(/\S+/g)
return matches ? matches.length : 0
}
function escapeHtmlAttr(s: string): string {
return s.replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
function stripTags(s: string): string {
return s.replace(/<[^>]+>/g, '')
}