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>
104 lines
3.5 KiB
TypeScript
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, '"').replace(/</g, '<').replace(/>/g, '>')
|
|
}
|
|
|
|
function stripTags(s: string): string {
|
|
return s.replace(/<[^>]+>/g, '')
|
|
}
|