feat(blog): admin CRUD + image upload + sidebar link
All checks were successful
Build & Deploy Web / build-and-deploy (push) Successful in 1m32s
Build & Deploy API / build-and-deploy (push) Successful in 2m20s
Build & Deploy Landing / build-and-deploy (push) Successful in 1m20s

L'éditeur du blog (jusqu'ici limité au seeder) a maintenant une vraie
interface au-dessus de l'API.

Backend (apps/api) :
* Migration users.is_admin (boolean default false).
* Middleware admin (404 si user.is_admin=false après auth).
* Commande ace promote:admin --email=… [--revoke].
* AdminPostsController CRUD complet : list/show/store/update/publish/
  unpublish/destroy + suggest-slug. Au save, contentHtml + wordCount +
  readingTime sont re-calculés via blog_renderer. Au publish, durcit la
  validation SEO (titre ≤60, excerpt 120-160, hero+alt requis, ≥600 mots),
  flippe status='published' + publishedAt, ping Google+Bing pour le sitemap.
* BlogUploadsController :
  - POST /api/v1/admin/uploads (multipart, JPEG/PNG/WebP, max 4MB)
    → MinIO clé uploads/blog/{uuid}.{ext}
    → renvoie URL relative /api/v1/uploads/blog/{filename}
  - GET /api/v1/uploads/blog/:filename (public, cache immutable 1 an)
    → stream depuis MinIO, regex anti-traversal sur le nom.
* UserTransformer expose isAdmin (cf. shared/types/user).
* k3s/app/landing.yml : NodePort 30111 explicite (pour Traefik repo proxmox).

Frontend (apps/web) :
* Lib typée admin-blog (calls API, queryKeys, helpers URL).
* Route /admin/blog : liste filtrable avec status badge, ouverture
  publique, dépublier, supprimer, "+ Nouveau brouillon".
* Route /admin/blog/:id : éditeur 2-colonnes
  - Gauche : @uiw/react-md-editor (lazy import) avec preview live.
  - Droite : hero image (drag&drop + alt), excerpt avec compteur
    120-160, tags, aperçu Google snippet, validations bloquantes.
  - Autosave debounce 2s + bouton Publier qui sauve d'abord.
  - Hero image upload via MinIO (HeroImageUpload component).
* Sidebar : lien "Blog (admin)" si user.isAdmin.
* Gate côté client (beforeLoad redirect si non admin) + côté serveur
  (middleware admin) — defense in depth.

Note : les requirements de publish miroir backend ↔ frontend (cf.
PUBLISH_REQUIREMENTS dans validators/post.ts et VALIDATION_RULES dans
admin.blog_.\$id.tsx). À synchroniser si un seuil bouge.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
ordinarthur 2026-05-09 17:25:34 +02:00
parent 77fdb6af48
commit 6dcae6956c
21 changed files with 2031 additions and 4 deletions

View File

@ -0,0 +1,221 @@
import type { HttpContext } from '@adonisjs/core/http'
import { DateTime } from 'luxon'
import Post from '#models/post'
import { renderPost, slugify } from '#services/blog_renderer'
import { createPostValidator, updatePostValidator, PUBLISH_REQUIREMENTS } from '#validators/post'
import PostTransformer from '#transformers/post_transformer'
const SITE_URL = 'https://rubis.pro'
/**
* AdminPostsController CRUD complet pour l'admin du blog.
*
* Routes (sous /api/v1/admin/posts/*, auth + admin) :
* GET / liste tous (drafts + published)
* POST / crée un brouillon
* GET /:id détail (par UUID, pas par slug)
* PATCH /:id modifie (re-render contentHtml + recompte mots)
* POST /:id/publish durcit la validation puis flip published
* POST /:id/unpublish revient en draft
* DELETE /:id hard delete (V1, on n'a pas beaucoup d'articles)
*
* Convention : on retourne TOUJOURS le post complet sérialisé après mutation
* pour que l'admin SPA puisse refresh sans re-fetch.
*/
export default class AdminPostsController {
/** GET /api/v1/admin/posts */
async index({ response }: HttpContext) {
const posts = await Post.query().orderBy('updatedAt', 'desc').orderBy('createdAt', 'desc')
return response.json({
data: posts.map((p) => new PostTransformer(p).toObject()),
})
}
/** GET /api/v1/admin/posts/:id */
async show({ params, response }: HttpContext) {
const post = await Post.find(params.id)
if (!post) return response.status(404).json({ error: 'post_not_found' })
return response.json({ data: new PostTransformer(post).toObject() })
}
/** POST /api/v1/admin/posts */
async store({ request, response }: HttpContext) {
const payload = await request.validateUsing(createPostValidator)
// Slug unicité
const existing = await Post.findBy('slug', payload.slug)
if (existing) {
return response.status(422).json({
errors: [{ field: 'slug', message: 'Slug déjà pris.' }],
})
}
const { contentHtml, wordCount, readingTimeMinutes } = renderPost(payload.contentMd)
const post = await Post.create({
slug: payload.slug,
title: payload.title,
excerpt: payload.excerpt,
contentMd: payload.contentMd,
contentHtml,
wordCount,
readingTimeMinutes,
tags: payload.tags ?? [],
authorName: payload.authorName ?? 'Arthur Barré',
heroImageUrl: payload.heroImageUrl ?? null,
heroImageAlt: payload.heroImageAlt ?? null,
ogImageUrl: payload.ogImageUrl ?? null,
canonicalUrl: payload.canonicalUrl ?? null,
noindex: payload.noindex ?? false,
status: 'draft',
aiGenerated: false,
})
return response.status(201).json({ data: new PostTransformer(post).toObject() })
}
/** PATCH /api/v1/admin/posts/:id */
async update({ params, request, response }: HttpContext) {
const post = await Post.find(params.id)
if (!post) return response.status(404).json({ error: 'post_not_found' })
const payload = await request.validateUsing(updatePostValidator)
// Slug unicité si changement
if (payload.slug && payload.slug !== post.slug) {
const conflicting = await Post.findBy('slug', payload.slug)
if (conflicting && conflicting.id !== post.id) {
return response.status(422).json({
errors: [{ field: 'slug', message: 'Slug déjà pris.' }],
})
}
post.slug = payload.slug
}
if (payload.title !== undefined) post.title = payload.title
if (payload.excerpt !== undefined) post.excerpt = payload.excerpt
if (payload.tags !== undefined) post.tags = payload.tags
if (payload.authorName !== undefined) post.authorName = payload.authorName
if (payload.heroImageUrl !== undefined) post.heroImageUrl = payload.heroImageUrl
if (payload.heroImageAlt !== undefined) post.heroImageAlt = payload.heroImageAlt
if (payload.ogImageUrl !== undefined) post.ogImageUrl = payload.ogImageUrl
if (payload.canonicalUrl !== undefined) post.canonicalUrl = payload.canonicalUrl
if (payload.noindex !== undefined) post.noindex = payload.noindex
if (payload.contentMd !== undefined) {
post.contentMd = payload.contentMd
const { contentHtml, wordCount, readingTimeMinutes } = renderPost(payload.contentMd)
post.contentHtml = contentHtml
post.wordCount = wordCount
post.readingTimeMinutes = readingTimeMinutes
}
await post.save()
return response.json({ data: new PostTransformer(post).toObject() })
}
/**
* POST /api/v1/admin/posts/:id/publish
* Durcit la validation SEO ici (on raisonne sur l'objet déjà persisté
* avec wordCount calculé).
*/
async publish({ params, response }: HttpContext) {
const post = await Post.find(params.id)
if (!post) return response.status(404).json({ error: 'post_not_found' })
const errors: Array<{ field: string; message: string }> = []
if (post.title.length > PUBLISH_REQUIREMENTS.titleMaxChars) {
errors.push({
field: 'title',
message: `Titre trop long pour Google (${post.title.length} > ${PUBLISH_REQUIREMENTS.titleMaxChars} chars).`,
})
}
if (
post.excerpt.length < PUBLISH_REQUIREMENTS.excerptMinChars
|| post.excerpt.length > PUBLISH_REQUIREMENTS.excerptMaxChars
) {
errors.push({
field: 'excerpt',
message: `L'excerpt doit faire entre ${PUBLISH_REQUIREMENTS.excerptMinChars} et ${PUBLISH_REQUIREMENTS.excerptMaxChars} chars (actuel : ${post.excerpt.length}).`,
})
}
if (!post.heroImageUrl) {
errors.push({ field: 'heroImageUrl', message: 'Hero image requise pour publication.' })
}
if (!post.heroImageAlt) {
errors.push({ field: 'heroImageAlt', message: "Texte alt de l'image hero requis (SEO + accessibilité)." })
}
if (post.wordCount < PUBLISH_REQUIREMENTS.minWordCount) {
errors.push({
field: 'contentMd',
message: `L'article doit faire au moins ${PUBLISH_REQUIREMENTS.minWordCount} mots (actuel : ${post.wordCount}).`,
})
}
if (errors.length > 0) {
return response.status(422).json({ errors })
}
post.status = 'published'
post.publishedAt = DateTime.now().toUTC().startOf('minute')
await post.save()
// Ping fire-and-forget — on n'attend pas (pas critique).
pingSitemap(SITE_URL).catch(() => {
/* swallow — l'article est publié, le ping est best-effort */
})
return response.json({ data: new PostTransformer(post).toObject() })
}
/** POST /api/v1/admin/posts/:id/unpublish */
async unpublish({ params, response }: HttpContext) {
const post = await Post.find(params.id)
if (!post) return response.status(404).json({ error: 'post_not_found' })
post.status = 'draft'
// On garde publishedAt en historique (même si plus visible publiquement).
await post.save()
return response.json({ data: new PostTransformer(post).toObject() })
}
/** DELETE /api/v1/admin/posts/:id */
async destroy({ params, response }: HttpContext) {
const post = await Post.find(params.id)
if (!post) return response.status(404).json({ error: 'post_not_found' })
await post.delete()
return response.status(204)
}
/**
* GET /api/v1/admin/posts/suggest-slug?title=...
* Helper pour l'UI : génère un slug propre depuis un titre.
*/
async suggestSlug({ request, response }: HttpContext) {
const title = String(request.input('title', '')).trim()
if (!title) return response.json({ data: { slug: '' } })
const base = slugify(title)
// Si dispo, on retourne tel quel ; sinon on suffixe.
let candidate = base
let n = 2
while (await Post.findBy('slug', candidate)) {
candidate = `${base}-${n}`
n += 1
}
return response.json({ data: { slug: candidate } })
}
}
/**
* Ping Google + Bing pour qu'ils re-crawlent le sitemap.
* Ces endpoints sont stables et n'exigent pas d'auth pour les sitemaps publics.
*/
async function pingSitemap(siteUrl: string): Promise<void> {
const sitemapUrl = encodeURIComponent(`${siteUrl}/sitemap.xml`)
await Promise.allSettled([
fetch(`https://www.google.com/ping?sitemap=${sitemapUrl}`),
fetch(`https://www.bing.com/ping?sitemap=${sitemapUrl}`),
])
}

View File

@ -0,0 +1,64 @@
import type { HttpContext } from '@adonisjs/core/http'
import { uploadBlogImage, readBlogImage } from '#services/blog_uploads'
/**
* BlogUploadsController gère les images du blog.
*
* - POST /api/v1/admin/uploads (auth + admin)
* multipart 'file' upload MinIO renvoie { url, contentType, sizeBytes }
* - GET /api/v1/uploads/blog/:filename (public)
* stream depuis MinIO avec Cache-Control immutable.
*/
export default class BlogUploadsController {
/**
* POST /api/v1/admin/uploads admin only.
*/
async store({ request, response }: HttpContext) {
const file = request.file('file', {
size: '4mb',
extnames: ['jpg', 'jpeg', 'png', 'webp'],
})
if (!file) {
return response.status(422).json({
errors: [{ field: 'file', message: 'Fichier manquant (champ multipart `file`).' }],
})
}
if (!file.isValid) {
return response.status(422).json({
errors: file.errors.map((e) => ({ field: 'file', message: e.message })),
})
}
try {
const result = await uploadBlogImage(file)
return response.status(201).json({
data: {
url: result.publicPath,
contentType: result.contentType,
sizeBytes: result.sizeBytes,
},
})
} catch (err) {
const msg = err instanceof Error ? err.message : 'upload_failed'
return response.status(422).json({ errors: [{ field: 'file', message: msg }] })
}
}
/**
* GET /api/v1/uploads/blog/:filename public, cache long.
*/
async show({ params, response }: HttpContext) {
const filename = String(params.filename ?? '')
const result = await readBlogImage(filename)
if (!result) {
return response.status(404).send('not_found')
}
response.header('Content-Type', result.contentType)
// Filename = uuid → contenu immuable. Cache infini, pas de revalidation.
response.header('Cache-Control', 'public, max-age=31536000, immutable')
return response.send(result.buffer)
}
}

View File

@ -0,0 +1,19 @@
import type { HttpContext } from '@adonisjs/core/http'
import type { NextFn } from '@adonisjs/core/types/http'
/**
* AdminMiddleware bloque l'accès aux endpoints réservés aux admins.
*
* Doit être stacké APRÈS auth() (l'auth est responsable de poser ctx.auth.user).
* Un user authentifié mais sans `is_admin` reçoit 403 ; un user non authentifié
* a déjà reçu 401 par le middleware auth.
*/
export default class AdminMiddleware {
async handle(ctx: HttpContext, next: NextFn) {
const user = ctx.auth.getUserOrFail()
if (!user.isAdmin) {
return ctx.response.status(403).json({ error: 'admin_only' })
}
return next()
}
}

View File

@ -0,0 +1,107 @@
/**
* blog_uploads upload + serve d'images du blog.
*
* Architecture (cf. /docs/tech/architecture.md §3) :
* - Upload : POST /api/v1/admin/uploads multipart MinIO (drive S3)
* sous la clé `uploads/blog/{uuid}.{ext}`, visibilité privée.
* - Lecture : GET /api/v1/uploads/blog/:filename stream depuis MinIO
* avec Cache-Control: public, max-age=31536000, immutable. Le fichier
* est immuable (uuid dans le nom = chaque upload = nouvelle URL), donc
* cache infini sans risque d'invalidation.
* - L'URL publique est ensuite stockée dans posts.hero_image_url (et
* optionnellement og_image_url) pas de FK, simple reference texte.
*
* Orphelins : quand un post change de hero, l'ancienne image reste sur
* MinIO (~quelques KB par fichier). Acceptable pour V1 ; périodique
* cleanup à ajouter quand on dépassera ~100 articles.
*/
import { randomUUID } from 'node:crypto'
import path from 'node:path'
import drive from '@adonisjs/drive/services/main'
import type { MultipartFile } from '@adonisjs/core/bodyparser'
const ALLOWED_EXTS = ['jpg', 'jpeg', 'png', 'webp'] as const
const MAX_BYTES = 4 * 1024 * 1024 // 4 MB
export type UploadResult = {
/** URL publique relative (à préfixer du host API côté client). */
publicPath: string
/** Clé S3 réelle dans le bucket (debug / cleanup). */
storageKey: string
/** Type MIME inféré de l'extension. */
contentType: string
/** Taille réelle en bytes. */
sizeBytes: number
}
/**
* Reçoit un MultipartFile (validé Adonis) et le pousse sur MinIO.
* Throw si le fichier ne respecte pas les contraintes (taille, MIME).
*/
export async function uploadBlogImage(file: MultipartFile): Promise<UploadResult> {
const ext = (file.extname ?? '').toLowerCase()
if (!ALLOWED_EXTS.includes(ext as (typeof ALLOWED_EXTS)[number])) {
throw new Error(`unsupported_extension: ${ext} (autorisés : ${ALLOWED_EXTS.join(', ')})`)
}
if (file.size > MAX_BYTES) {
throw new Error(`file_too_large: ${file.size}B (max ${MAX_BYTES}B)`)
}
const uuid = randomUUID()
const filename = `${uuid}.${ext}`
const storageKey = `uploads/blog/${filename}`
// Adonis Drive accepte un path local (le multipart écrit le tmp file).
if (!file.tmpPath) {
throw new Error('multipart_no_tmpPath')
}
await drive.use().moveFromFs(file.tmpPath, storageKey)
return {
publicPath: `/api/v1/uploads/blog/${filename}`,
storageKey,
contentType: extToContentType(ext),
sizeBytes: file.size,
}
}
/**
* Stream une image depuis MinIO. Renvoie un Buffer + le contentType
* pour que le contrôleur réponde avec les bons headers.
*/
export async function readBlogImage(filename: string): Promise<{
buffer: Buffer
contentType: string
} | null> {
// Sécurité : pas de path traversal. Le slug doit matcher `uuid.ext`.
if (!/^[a-f0-9-]{36}\.(jpg|jpeg|png|webp)$/i.test(filename)) {
return null
}
const storageKey = `uploads/blog/${filename}`
try {
const buffer = Buffer.from(await drive.use().getArrayBuffer(storageKey))
return {
buffer,
contentType: extToContentType(path.extname(filename).slice(1).toLowerCase()),
}
} catch {
return null
}
}
function extToContentType(ext: string): string {
switch (ext) {
case 'jpg':
case 'jpeg':
return 'image/jpeg'
case 'png':
return 'image/png'
case 'webp':
return 'image/webp'
default:
return 'application/octet-stream'
}
}

View File

@ -12,6 +12,9 @@ export default class UserTransformer extends BaseTransformer<User> {
organizationId: u.organizationId,
signature: u.signature,
initials: u.initials,
// Permet au SPA d'afficher (ou cacher) le lien "Admin" dans la sidebar
// et d'auto-rediriger /admin/* si non-admin.
isAdmin: u.isAdmin,
createdAt: u.createdAt.toISO()!,
updatedAt: u.updatedAt?.toISO() ?? u.createdAt.toISO()!,
}

View File

@ -0,0 +1,72 @@
import vine from '@vinejs/vine'
/**
* Règles SEO + édito :
* - title 60 chars (sweet spot Google snippet)
* - excerpt 120-160 chars (sweet spot meta description)
* - slug kebab-case ASCII unique
* - tags max 5
*/
const slug = () =>
vine
.string()
.minLength(3)
.maxLength(200)
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/)
const title = () => vine.string().minLength(5).maxLength(80)
const excerpt = () => vine.string().minLength(80).maxLength(280)
const tags = () => vine.array(vine.string().maxLength(50)).maxLength(5).distinct()
/**
* Création d'un brouillon. On accepte `status` mais c'est toujours `draft`
* à la création la publication passe par /publish.
*/
export const createPostValidator = vine.compile(
vine.object({
slug: slug(),
title: title(),
excerpt: excerpt(),
contentMd: vine.string().minLength(50),
tags: tags().optional(),
authorName: vine.string().minLength(2).maxLength(100).optional(),
heroImageUrl: vine.string().url().nullable().optional(),
heroImageAlt: vine.string().maxLength(250).nullable().optional(),
ogImageUrl: vine.string().url().nullable().optional(),
canonicalUrl: vine.string().url().nullable().optional(),
noindex: vine.boolean().optional(),
}),
)
/**
* Update partiel d'un post existant. Tous les champs optionnels.
* `status` n'est pas mutable ici (passer par publish/unpublish).
*/
export const updatePostValidator = vine.compile(
vine.object({
slug: slug().optional(),
title: title().optional(),
excerpt: excerpt().optional(),
contentMd: vine.string().minLength(50).optional(),
tags: tags().optional(),
authorName: vine.string().minLength(2).maxLength(100).optional(),
heroImageUrl: vine.string().url().nullable().optional(),
heroImageAlt: vine.string().maxLength(250).nullable().optional(),
ogImageUrl: vine.string().url().nullable().optional(),
canonicalUrl: vine.string().url().nullable().optional(),
noindex: vine.boolean().optional(),
}),
)
/**
* Au publish on durcit les règles SEO inline dans AdminPostsController.publish
* (cf. PUBLISH_REQUIREMENTS) pas un validator vine séparé car on raisonne
* sur l'objet Post déjà persisté (avec wordCount calculé), pas sur un payload
* de requête.
*/
export const PUBLISH_REQUIREMENTS = {
titleMaxChars: 60, // sweet spot snippet Google
excerptMinChars: 120,
excerptMaxChars: 160,
minWordCount: 600, // ~3 min de lecture, considéré "substantiel"
} as const

View File

@ -0,0 +1,47 @@
import { BaseCommand, flags } from '@adonisjs/core/ace'
import type { CommandOptions } from '@adonisjs/core/types/ace'
import User from '#models/user'
/**
* Bascule un user en admin (accès à /admin/blog + endpoints /api/v1/admin/*).
*
* node ace promote:admin --email=arthurbarre.js@gmail.com
* node ace promote:admin --email=... --revoke # retire le rôle
*/
export default class PromoteAdmin extends BaseCommand {
static commandName = 'promote:admin'
static description = 'Promeut (ou révoque) le rôle admin pour un user existant'
static options: CommandOptions = {
startApp: true,
}
@flags.string({
description: "Email du user à promouvoir",
required: true,
})
declare email: string
@flags.boolean({
description: 'Retire le rôle admin au lieu de l\'octroyer',
default: false,
})
declare revoke: boolean
async run() {
const user = await User.findBy('email', String(this.email).toLowerCase())
if (!user) {
this.logger.error(`User introuvable : ${this.email}`)
this.exitCode = 1
return
}
user.isAdmin = !this.revoke
await user.save()
this.logger.success(
`${user.email} → is_admin = ${user.isAdmin}`,
)
}
}

View File

@ -0,0 +1,25 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/**
* Flag `is_admin` sur les users ouvre l'accès à l'admin du blog
* (apps/web /admin/blog) et aux endpoints /api/v1/admin/*.
*
* Bool simple plutôt qu'une table de rôles : on a un seul rôle privilégié
* pour V1 (l'éditeur du blog). Si la palette de rôles grandit (modération,
* support, etc.), on basculera sur RBAC propre.
*/
export default class extends BaseSchema {
protected tableName = 'users'
async up() {
this.schema.alterTable(this.tableName, (table) => {
table.boolean('is_admin').notNullable().defaultTo(false)
})
}
async down() {
this.schema.alterTable(this.tableName, (table) => {
table.dropColumn('is_admin')
})
}
}

View File

@ -48,4 +48,5 @@ router.use([
*/
export const middleware = router.named({
auth: () => import('#middleware/auth_middleware'),
admin: () => import('#middleware/admin_middleware'),
})

View File

@ -18,6 +18,8 @@ import { controllers } from '#generated/controllers'
* Pas auth, pas de paginiation V1 (volume cible <100 articles).
*/
const BlogController = () => import('#controllers/blog_controller')
const AdminPostsController = () => import('#controllers/admin_posts_controller')
const BlogUploadsController = () => import('#controllers/blog_uploads_controller')
router
@ -55,6 +57,14 @@ router
})
.prefix('posts')
.as('posts')
/**
* Image hero servie depuis MinIO. Public, cache infini (le filename est
* un UUID immuable, chaque upload = nouvelle URL).
*/
router
.get('uploads/blog/:filename', [BlogUploadsController, 'show'])
.as('uploads.blog.show')
})
.prefix('/api/v1')
@ -317,5 +327,47 @@ router
.prefix('invoices')
.as('invoices')
.use(middleware.auth())
/**
* Admin édition du blog. Toutes auth + admin (cf. is_admin sur users).
* Cf. apps/web/src/routes/_app/admin.* pour le SPA consommateur.
*/
router
.group(() => {
router
.group(() => {
router.get('', [AdminPostsController, 'index']).as('index')
router.post('', [AdminPostsController, 'store']).as('store')
router.get('suggest-slug', [AdminPostsController, 'suggestSlug']).as('suggest-slug')
router
.get(':id', [AdminPostsController, 'show'])
.as('show')
.where('id', router.matchers.uuid())
router
.patch(':id', [AdminPostsController, 'update'])
.as('update')
.where('id', router.matchers.uuid())
router
.post(':id/publish', [AdminPostsController, 'publish'])
.as('publish')
.where('id', router.matchers.uuid())
router
.post(':id/unpublish', [AdminPostsController, 'unpublish'])
.as('unpublish')
.where('id', router.matchers.uuid())
router
.delete(':id', [AdminPostsController, 'destroy'])
.as('destroy')
.where('id', router.matchers.uuid())
})
.prefix('posts')
.as('posts')
router.post('uploads', [BlogUploadsController, 'store']).as('uploads.store')
})
.prefix('admin')
.as('admin')
.use(middleware.auth())
.use(middleware.admin())
})
.prefix('/api/v1')

View File

@ -94,7 +94,7 @@ function Step({ num, title, body, flip = false, children }: StepProps) {
<h3 className="font-display font-bold text-ink text-[26px] sm:text-[30px] lg:text-[32px] tracking-[-0.022em] leading-[1.15]">
{title}
</h3>
<div className="mt-4 md:mt-5 text-[16.5px] md:text-[17px] leading-relaxed text-ink-2 md:text-justify hyphens-auto">
<div className="mt-4 md:mt-5 text-[16.5px] md:text-[17px] leading-relaxed text-ink-2">
{body}
</div>
</div>

View File

@ -38,6 +38,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"@uiw/react-md-editor": "^4.0.5",
"lucide-react": "^0.475.0",
"react": "^19.2.5",
"react-dom": "^19.2.5",

View File

@ -0,0 +1,159 @@
import { useState, useRef, type DragEvent } from "react";
import { Upload, X, Image as ImageIcon } from "lucide-react";
import { toast } from "sonner";
import { adminBlogApi, absolutizeApiUrl } from "@/lib/admin-blog";
import { Button, cn } from "@rubis/ui";
const ACCEPTED = "image/jpeg,image/png,image/webp";
type Props = {
value: string | null;
alt: string | null;
onChange: (next: { url: string | null; alt: string | null }) => void;
};
/**
* HeroImageUpload drag & drop image (JPEG/PNG/WebP, max 4 MB).
* Upload va dans MinIO via /api/v1/admin/uploads, l'URL renvoyée est stockée
* dans posts.hero_image_url. L'alt est requis pour publier (SEO + a11y).
*/
export function HeroImageUpload({ value, alt, onChange }: Props) {
const [uploading, setUploading] = useState(false);
const [dragOver, setDragOver] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const handleFile = async (file: File) => {
setUploading(true);
try {
const result = await adminBlogApi.uploadImage(file);
onChange({ url: result.url, alt: alt ?? deriveAltFromFilename(file.name) });
toast.success("Image uploadée.");
} catch (err) {
const msg = err instanceof Error ? err.message : "Échec upload";
toast.error(`Échec upload : ${msg}`);
} finally {
setUploading(false);
}
};
const onDrop = (e: DragEvent) => {
e.preventDefault();
setDragOver(false);
const file = e.dataTransfer.files[0];
if (file) {
void handleFile(file);
}
};
return (
<div className="space-y-3">
{/* Single hidden input — réutilisé par le drop zone et le bouton "Remplacer". */}
<input
ref={inputRef}
type="file"
accept={ACCEPTED}
className="sr-only"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) void handleFile(file);
e.target.value = "";
}}
/>
{value ? (
<div className="relative group">
<img
src={absolutizeApiUrl(value)}
alt={alt ?? ""}
className="w-full aspect-[16/9] object-cover rounded-card border border-line"
/>
<button
type="button"
onClick={() => onChange({ url: null, alt: null })}
className="absolute top-3 right-3 size-8 rounded-full bg-white/90 backdrop-blur border border-line flex items-center justify-center text-ink-2 hover:text-rubis-deep hover:bg-white transition-colors shadow-soft"
title="Retirer l'image"
aria-label="Retirer l'image"
>
<X size={16} />
</button>
</div>
) : (
<div
onDragOver={(e) => {
e.preventDefault();
setDragOver(true);
}}
onDragLeave={() => setDragOver(false)}
onDrop={onDrop}
onClick={() => inputRef.current?.click()}
className={cn(
"relative cursor-pointer w-full aspect-[16/9] rounded-card border-2 border-dashed",
"flex flex-col items-center justify-center gap-3 transition-colors",
dragOver
? "border-rubis bg-rubis-glow/40"
: "border-line bg-cream-2 hover:border-ink-3 hover:bg-cream-2/60",
)}
>
{uploading ? (
<div className="flex flex-col items-center gap-2 text-ink-2">
<div className="size-8 border-2 border-current border-r-transparent rounded-full animate-spin" />
<span className="text-[13px]">Upload en cours</span>
</div>
) : (
<>
<div className="size-12 rounded-full bg-white border border-line flex items-center justify-center text-ink-3">
<Upload size={20} />
</div>
<div className="text-center">
<div className="font-display font-semibold text-[15px] text-ink">
Drag & drop ou clique pour uploader
</div>
<div className="text-[12.5px] text-ink-3 mt-0.5">
JPEG, PNG, WebP max 4 MB · ratio 16:9 idéal
</div>
</div>
</>
)}
</div>
)}
{/* Alt text — bloquant pour publier */}
<div>
<label className="block text-[12px] uppercase tracking-[0.08em] font-semibold text-ink-3 mb-1.5">
Texte alternatif (alt) requis pour publier
</label>
<input
type="text"
value={alt ?? ""}
onChange={(e) => onChange({ url: value, alt: e.target.value })}
placeholder="ex. Une facture en papier traversée par un rubis facetté"
maxLength={250}
className="w-full px-3 py-2 rounded-default border border-line bg-white text-[14px] text-ink focus:border-rubis focus:outline-none focus:ring-4 focus:ring-rubis-glow/40"
/>
<div className="text-[11.5px] text-ink-3 mt-1">
Décris l'image pour les lecteurs d'écran et le SEO. {alt?.length ?? 0}/250.
</div>
</div>
{value && !uploading && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => inputRef.current?.click()}
>
<ImageIcon size={14} />
Remplacer l'image
</Button>
)}
</div>
);
}
function deriveAltFromFilename(filename: string): string {
return filename
.replace(/\.[^.]+$/, "")
.replace(/[-_]+/g, " ")
.replace(/\s+/g, " ")
.trim();
}

View File

@ -10,6 +10,7 @@ import {
Users,
Settings,
TrendingUp,
PenSquare,
} from "lucide-react";
import { Brand } from "@rubis/ui";
@ -35,7 +36,7 @@ const STORAGE_KEY = "rubis.sidebar.collapsed";
* en haut reste, donc l'identité de marque ne disparaît jamais.
*/
export function AppSidebar({ rubisThisMonth = 0 }: { rubisThisMonth?: number }) {
const { user: _user } = useAuth();
const { user } = useAuth();
const [collapsed, setCollapsed] = useState(false);
// Lecture localStorage à l'init (côté client uniquement).
@ -110,6 +111,16 @@ export function AppSidebar({ rubisThisMonth = 0 }: { rubisThisMonth?: number })
label="Paramètres"
collapsed={collapsed}
/>
{/* Admin — visible uniquement pour les users avec is_admin=true. */}
{user?.isAdmin && (
<NavLink
to="/admin/blog"
icon={<PenSquare size={17} />}
label="Blog (admin)"
collapsed={collapsed}
/>
)}
</nav>
<div className="mt-auto flex flex-col gap-3">

View File

@ -0,0 +1,86 @@
import { api } from "./api";
import { env } from "./env";
/**
* Client API typé pour l'admin du blog. Toutes les routes nécessitent
* `auth + is_admin` côté serveur (cf. apps/api/start/routes.ts).
*/
export type PostStatus = "draft" | "published";
export type AdminPost = {
id: string;
slug: string;
title: string;
excerpt: string;
contentMd: string;
contentHtml: string;
authorName: string;
tags: string[];
status: PostStatus;
publishedAt: string | null;
heroImageUrl: string | null;
heroImageAlt: string | null;
ogImageUrl: string | null;
canonicalUrl: string | null;
noindex: boolean;
wordCount: number;
readingTimeMinutes: number;
createdAt: string;
updatedAt: string | null;
};
export type CreatePostInput = {
slug: string;
title: string;
excerpt: string;
contentMd: string;
tags?: string[];
authorName?: string;
heroImageUrl?: string | null;
heroImageAlt?: string | null;
ogImageUrl?: string | null;
canonicalUrl?: string | null;
noindex?: boolean;
};
export type UpdatePostInput = Partial<CreatePostInput>;
export type UploadResult = {
/** URL publique relative (ex: /api/v1/uploads/blog/{uuid}.jpg) — préfixée par VITE_API_URL pour usage navigateur. */
url: string;
contentType: string;
sizeBytes: number;
};
/** Renvoie l'URL absolue exploitable côté navigateur depuis une URL relative API. */
export function absolutizeApiUrl(relativeOrAbsolute: string): string {
if (!relativeOrAbsolute) return relativeOrAbsolute;
if (relativeOrAbsolute.startsWith("http")) return relativeOrAbsolute;
return `${env.VITE_API_URL}${relativeOrAbsolute}`;
}
export const adminBlogApi = {
list: () => api.get<AdminPost[]>("/api/v1/admin/posts"),
get: (id: string) => api.get<AdminPost>(`/api/v1/admin/posts/${id}`),
create: (input: CreatePostInput) => api.post<AdminPost>("/api/v1/admin/posts", input),
update: (id: string, input: UpdatePostInput) =>
api.patch<AdminPost>(`/api/v1/admin/posts/${id}`, input),
publish: (id: string) => api.post<AdminPost>(`/api/v1/admin/posts/${id}/publish`, {}),
unpublish: (id: string) => api.post<AdminPost>(`/api/v1/admin/posts/${id}/unpublish`, {}),
delete: (id: string) => api.delete<void>(`/api/v1/admin/posts/${id}`),
suggestSlug: (title: string) =>
api.get<{ slug: string }>(`/api/v1/admin/posts/suggest-slug?title=${encodeURIComponent(title)}`),
uploadImage: async (file: File): Promise<UploadResult> => {
const form = new FormData();
form.append("file", file);
return api.post<UploadResult>("/api/v1/admin/uploads", form);
},
};
export const adminBlogQueryKeys = {
all: () => ["admin", "blog"] as const,
list: () => ["admin", "blog", "list"] as const,
detail: (id: string) => ["admin", "blog", "detail", id] as const,
};

View File

@ -72,6 +72,7 @@ const seedDb = (): Db => ({
fullName: "Arthur Démo",
organizationId: "org_demo",
signature: "Cordialement,\nArthur — Rubis Démo",
isAdmin: true,
createdAt: new Date("2026-01-01").toISOString(),
updatedAt: new Date().toISOString(),
passwordHash: "demo1234",
@ -188,6 +189,7 @@ export const mockDb = {
fullName: input.fullName,
organizationId: orgId,
signature: null,
isAdmin: false,
createdAt: now,
updatedAt: now,
passwordHash: input.password,

View File

@ -0,0 +1,232 @@
import { createFileRoute, Link, redirect } from "@tanstack/react-router";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { Plus, FileText, ExternalLink, Trash2, Eye, EyeOff } from "lucide-react";
import { toast } from "sonner";
import { authStore } from "@/lib/auth";
import {
adminBlogApi,
adminBlogQueryKeys,
type AdminPost,
} from "@/lib/admin-blog";
import { Button, EmptyState } from "@rubis/ui";
export const Route = createFileRoute("/_app/admin/blog")({
beforeLoad: () => {
// Gate côté client : si pas admin, on redirige vers / (404 visuel).
// Le backend re-vérifie de toute façon (middleware admin sur API).
if (!authStore.user?.isAdmin) {
throw redirect({ to: "/" });
}
},
loader: ({ context }) => {
void context.queryClient.prefetchQuery({
queryKey: adminBlogQueryKeys.list(),
queryFn: () => adminBlogApi.list(),
});
},
component: AdminBlogList,
});
function AdminBlogList() {
const qc = useQueryClient();
const { data: posts = [], isPending } = useQuery({
queryKey: adminBlogQueryKeys.list(),
queryFn: () => adminBlogApi.list(),
staleTime: 5_000,
});
const [creating, setCreating] = useState(false);
const createMut = useMutation({
mutationFn: () =>
adminBlogApi.create({
slug: `nouveau-brouillon-${Date.now()}`,
title: "Nouveau brouillon",
excerpt:
"Brouillon vide à compléter avant publication — fais une description suffisamment longue pour le SEO (minimum 120 caractères).",
contentMd:
"# Brouillon\n\nÉcris ton contenu ici. **Markdown** supporté.",
}),
onSuccess: (post) => {
qc.invalidateQueries({ queryKey: adminBlogQueryKeys.all() });
toast.success("Brouillon créé.");
setCreating(false);
window.location.href = `/admin/blog/${post.id}`;
},
onError: (err: Error) => {
toast.error(`Échec création : ${err.message}`);
setCreating(false);
},
});
return (
<div className="container mx-auto max-w-[1100px] px-5 py-8">
<div className="flex items-baseline justify-between mb-8">
<div>
<h1 className="font-display text-[28px] font-bold tracking-[-0.02em] text-ink">
Articles du blog
</h1>
<p className="text-[14px] text-ink-3 mt-1">
Crée, édite et publie sur{" "}
<a href="https://rubis.pro/blog" target="_blank" rel="noopener" className="underline">
rubis.pro/blog
</a>
.
</p>
</div>
<Button
onClick={() => {
setCreating(true);
createMut.mutate();
}}
loading={creating || createMut.isPending}
>
<Plus size={16} />
Nouveau brouillon
</Button>
</div>
{isPending ? (
<div className="text-ink-3 text-sm">Chargement</div>
) : posts.length === 0 ? (
<EmptyState
icon={<FileText size={32} />}
title="Aucun article"
description="Tu n'as pas encore d'article. Clique sur Nouveau brouillon pour commencer."
/>
) : (
<div className="bg-white rounded-card border border-line shadow-soft overflow-hidden">
<table className="w-full text-[14px]">
<thead className="bg-cream-2 text-[11.5px] uppercase tracking-[0.08em] font-semibold text-ink-3">
<tr>
<th className="text-left px-5 py-3">Statut</th>
<th className="text-left px-5 py-3">Titre</th>
<th className="text-left px-5 py-3">Slug</th>
<th className="text-left px-5 py-3">Mots</th>
<th className="text-left px-5 py-3">Mise à jour</th>
<th className="text-right px-5 py-3" />
</tr>
</thead>
<tbody className="divide-y divide-line">
{posts.map((post) => (
<PostRow key={post.id} post={post} />
))}
</tbody>
</table>
</div>
)}
</div>
);
}
function PostRow({ post }: { post: AdminPost }) {
const qc = useQueryClient();
const deleteMut = useMutation({
mutationFn: () => adminBlogApi.delete(post.id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: adminBlogQueryKeys.all() });
toast.success("Article supprimé.");
},
onError: (err: Error) => toast.error(`Échec suppression : ${err.message}`),
});
const unpublishMut = useMutation({
mutationFn: () => adminBlogApi.unpublish(post.id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: adminBlogQueryKeys.all() });
toast.success("Article repassé en brouillon.");
},
onError: (err: Error) => toast.error(`Échec : ${err.message}`),
});
return (
<tr className="hover:bg-cream-2/40 transition-colors">
<td className="px-5 py-4">
<StatusBadge status={post.status} />
</td>
<td className="px-5 py-4">
<Link
to="/admin/blog/$id"
params={{ id: post.id }}
className="font-medium text-ink hover:text-rubis"
>
{post.title}
</Link>
</td>
<td className="px-5 py-4 text-ink-3 font-mono text-[12.5px]">{post.slug}</td>
<td className="px-5 py-4 text-ink-2 tabular-nums">
{post.wordCount} <span className="text-ink-3">·</span> {post.readingTimeMinutes} min
</td>
<td className="px-5 py-4 text-ink-3 text-[13px]">
{new Intl.DateTimeFormat("fr-FR", {
dateStyle: "medium",
timeStyle: "short",
}).format(new Date(post.updatedAt ?? post.createdAt))}
</td>
<td className="px-5 py-4 text-right whitespace-nowrap">
{post.status === "published" && (
<a
href={`https://rubis.pro/blog/${post.slug}`}
target="_blank"
rel="noopener"
className="inline-flex items-center gap-1 text-[12.5px] text-ink-3 hover:text-rubis mr-3"
title="Voir sur rubis.pro"
>
<ExternalLink size={13} />
</a>
)}
{post.status === "published" && (
<button
type="button"
onClick={() => unpublishMut.mutate()}
className="inline-flex items-center gap-1 text-[12.5px] text-ink-3 hover:text-rubis-deep mr-3"
title="Dépublier"
>
<EyeOff size={13} />
</button>
)}
{post.status === "draft" && (
<Link
to="/admin/blog/$id"
params={{ id: post.id }}
className="inline-flex items-center gap-1 text-[12.5px] text-ink-3 hover:text-rubis mr-3"
title="Éditer"
>
<Eye size={13} />
</Link>
)}
<button
type="button"
onClick={() => {
if (confirm(`Supprimer "${post.title}" ?`)) {
deleteMut.mutate();
}
}}
className="inline-flex items-center gap-1 text-[12.5px] text-ink-3 hover:text-rubis-deep"
title="Supprimer"
>
<Trash2 size={13} />
</button>
</td>
</tr>
);
}
function StatusBadge({ status }: { status: AdminPost["status"] }) {
if (status === "published") {
return (
<span className="inline-flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-[0.08em] text-rubis">
<span aria-hidden className="size-1.5 rounded-full bg-rubis" />
Publié
</span>
);
}
return (
<span className="inline-flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-[0.08em] text-ink-3">
<span aria-hidden className="size-1.5 rounded-full bg-ink-3" />
Brouillon
</span>
);
}

View File

@ -0,0 +1,522 @@
import { lazy, Suspense, useEffect, useMemo, useRef, useState } from "react";
import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ExternalLink, Save, Eye, Trash2, ChevronLeft } from "lucide-react";
import { toast } from "sonner";
import { authStore } from "@/lib/auth";
import {
adminBlogApi,
adminBlogQueryKeys,
type AdminPost,
} from "@/lib/admin-blog";
import { Button, Card, cn } from "@rubis/ui";
import { HeroImageUpload } from "@/components/admin/HeroImageUpload";
// Lazy : @uiw/react-md-editor charge ~250 KB d'éditeur + preview, on ne veut
// pas le bundler dans le critical path du SaaS. Code-splitté par route.
const MdEditor = lazy(() => import("@uiw/react-md-editor"));
export const Route = createFileRoute("/_app/admin/blog_/$id")({
beforeLoad: () => {
if (!authStore.user?.isAdmin) {
throw redirect({ to: "/" });
}
},
loader: ({ params, context }) => {
void context.queryClient.prefetchQuery({
queryKey: adminBlogQueryKeys.detail(params.id),
queryFn: () => adminBlogApi.get(params.id),
});
},
component: AdminBlogEditor,
});
/* ============================================================================
* Validations SEO miroir des règles backend (cf. apps/api/app/validators/post)
* on les exécute ici pour bloquer le bouton "Publier" avec feedback live.
* ========================================================================= */
const VALIDATION_RULES = {
titleMaxChars: 60,
excerptMinChars: 120,
excerptMaxChars: 160,
minWordCount: 600,
} as const;
type ValidationIssue = { field: string; message: string };
function validateForPublish(
draft: { title: string; excerpt: string; heroImageUrl: string | null; heroImageAlt: string | null; wordCount: number; slug: string },
): ValidationIssue[] {
const issues: ValidationIssue[] = [];
if (!draft.slug || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(draft.slug)) {
issues.push({ field: "slug", message: "Slug invalide (kebab-case ASCII)." });
}
if (draft.title.length > VALIDATION_RULES.titleMaxChars) {
issues.push({
field: "title",
message: `Titre trop long pour Google (${draft.title.length} > ${VALIDATION_RULES.titleMaxChars}).`,
});
}
if (draft.title.length < 5) {
issues.push({ field: "title", message: "Titre trop court (min 5 chars)." });
}
if (draft.excerpt.length < VALIDATION_RULES.excerptMinChars) {
issues.push({
field: "excerpt",
message: `Excerpt trop court (${draft.excerpt.length} < ${VALIDATION_RULES.excerptMinChars}).`,
});
}
if (draft.excerpt.length > VALIDATION_RULES.excerptMaxChars) {
issues.push({
field: "excerpt",
message: `Excerpt trop long (${draft.excerpt.length} > ${VALIDATION_RULES.excerptMaxChars}).`,
});
}
if (!draft.heroImageUrl) {
issues.push({ field: "heroImageUrl", message: "Hero image requise." });
}
if (!draft.heroImageAlt || draft.heroImageAlt.length < 3) {
issues.push({ field: "heroImageAlt", message: "Texte alt requis." });
}
if (draft.wordCount < VALIDATION_RULES.minWordCount) {
issues.push({
field: "contentMd",
message: `Article trop court (${draft.wordCount} < ${VALIDATION_RULES.minWordCount} mots).`,
});
}
return issues;
}
/* ============================================================================
* Component
* ========================================================================= */
function AdminBlogEditor() {
const { id } = Route.useParams();
const navigate = useNavigate();
const { data: post, isPending } = useQuery({
queryKey: adminBlogQueryKeys.detail(id),
queryFn: () => adminBlogApi.get(id),
});
if (isPending || !post) {
return (
<div className="container mx-auto max-w-[1100px] px-5 py-8 text-ink-3">
Chargement
</div>
);
}
return <Editor post={post} onBack={() => navigate({ to: "/admin/blog" })} />;
}
function Editor({ post, onBack }: { post: AdminPost; onBack: () => void }) {
const qc = useQueryClient();
// État local — on travaille sur une copie du post pour autosave debouncée.
const [draft, setDraft] = useState({
slug: post.slug,
title: post.title,
excerpt: post.excerpt,
contentMd: post.contentMd,
heroImageUrl: post.heroImageUrl,
heroImageAlt: post.heroImageAlt,
tags: post.tags.join(", "),
});
// Re-sync si le post est rafraîchi par un autre canal (rare mais sûr).
useEffect(() => {
setDraft({
slug: post.slug,
title: post.title,
excerpt: post.excerpt,
contentMd: post.contentMd,
heroImageUrl: post.heroImageUrl,
heroImageAlt: post.heroImageAlt,
tags: post.tags.join(", "),
});
}, [post.id]); // eslint-disable-line react-hooks/exhaustive-deps
const wordCount = useMemo(() => countWords(draft.contentMd), [draft.contentMd]);
const issues = useMemo(
() =>
validateForPublish({
title: draft.title,
excerpt: draft.excerpt,
slug: draft.slug,
heroImageUrl: draft.heroImageUrl,
heroImageAlt: draft.heroImageAlt,
wordCount,
}),
[draft, wordCount],
);
/* === Save mutation (manuel + autosave debounce) === */
const saveMut = useMutation({
mutationFn: () =>
adminBlogApi.update(post.id, {
slug: draft.slug,
title: draft.title,
excerpt: draft.excerpt,
contentMd: draft.contentMd,
heroImageUrl: draft.heroImageUrl,
heroImageAlt: draft.heroImageAlt,
tags: draft.tags
.split(",")
.map((t) => t.trim())
.filter((t) => t.length > 0),
}),
onSuccess: (saved) => {
qc.setQueryData(adminBlogQueryKeys.detail(post.id), saved);
qc.invalidateQueries({ queryKey: adminBlogQueryKeys.list() });
},
});
// Autosave debounce 2s sur changement de draft.
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
// Skip si rien n'a changé vs persisted
const persisted = {
slug: post.slug,
title: post.title,
excerpt: post.excerpt,
contentMd: post.contentMd,
heroImageUrl: post.heroImageUrl,
heroImageAlt: post.heroImageAlt,
tags: post.tags.join(", "),
};
const changed = JSON.stringify(persisted) !== JSON.stringify(draft);
if (changed && !saveMut.isPending) {
saveMut.mutate();
}
}, 2000);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [draft]); // eslint-disable-line react-hooks/exhaustive-deps
/* === Publish === */
const publishMut = useMutation({
mutationFn: async () => {
// S'assurer qu'on a sauvé avant de publier (sinon l'API juge sur l'état persisté).
await saveMut.mutateAsync();
return adminBlogApi.publish(post.id);
},
onSuccess: (saved) => {
qc.setQueryData(adminBlogQueryKeys.detail(post.id), saved);
qc.invalidateQueries({ queryKey: adminBlogQueryKeys.list() });
toast.success("Article publié — visible sur rubis.pro/blog (cache 5 min).");
},
onError: (err: Error) => {
toast.error(`Échec publication : ${err.message}`);
},
});
const unpublishMut = useMutation({
mutationFn: () => adminBlogApi.unpublish(post.id),
onSuccess: (saved) => {
qc.setQueryData(adminBlogQueryKeys.detail(post.id), saved);
qc.invalidateQueries({ queryKey: adminBlogQueryKeys.list() });
toast.success("Article repassé en brouillon.");
},
});
const deleteMut = useMutation({
mutationFn: () => adminBlogApi.delete(post.id),
onSuccess: () => {
toast.success("Article supprimé.");
onBack();
},
});
const canPublish = issues.length === 0;
const isPublished = post.status === "published";
const publicUrl = `https://rubis.pro/blog/${post.slug}`;
return (
<div className="container mx-auto max-w-[1280px] px-5 py-6">
{/* Topbar */}
<div className="flex items-center justify-between mb-6 flex-wrap gap-4">
<button
type="button"
onClick={onBack}
className="inline-flex items-center gap-1 text-[14px] text-ink-2 hover:text-rubis"
>
<ChevronLeft size={16} />
Retour aux articles
</button>
<div className="flex items-center gap-2 flex-wrap">
<SaveStatus
isSaving={saveMut.isPending}
lastSavedAt={saveMut.data?.updatedAt ?? post.updatedAt}
/>
{isPublished && (
<a
href={publicUrl}
target="_blank"
rel="noopener"
className="inline-flex items-center gap-1.5 text-[13px] text-ink-2 hover:text-rubis px-3 py-2"
>
<ExternalLink size={14} />
Voir sur rubis.pro
</a>
)}
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => saveMut.mutate()}
loading={saveMut.isPending}
>
<Save size={14} />
Sauvegarder
</Button>
{isPublished ? (
<Button
type="button"
variant="secondary"
onClick={() => unpublishMut.mutate()}
loading={unpublishMut.isPending}
>
Dépublier
</Button>
) : (
<Button
type="button"
onClick={() => publishMut.mutate()}
disabled={!canPublish}
loading={publishMut.isPending}
title={canPublish ? "Publier sur rubis.pro/blog" : "Corrige les erreurs SEO d'abord"}
>
<Eye size={14} />
Publier
</Button>
)}
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
if (confirm(`Supprimer définitivement "${post.title}" ?`)) {
deleteMut.mutate();
}
}}
>
<Trash2 size={14} />
</Button>
</div>
</div>
{/* Title */}
<input
type="text"
value={draft.title}
onChange={(e) => setDraft((d) => ({ ...d, title: e.target.value }))}
placeholder="Titre de l'article…"
className="w-full bg-transparent border-none font-display text-[32px] sm:text-[40px] font-bold tracking-[-0.025em] text-ink placeholder:text-ink-3 focus:outline-none mb-2"
/>
<CharCounter
value={draft.title.length}
max={VALIDATION_RULES.titleMaxChars}
label="Titre"
/>
{/* Slug */}
<div className="mt-3 flex items-center gap-2 text-[13px] text-ink-3">
<span>rubis.pro/blog/</span>
<input
type="text"
value={draft.slug}
onChange={(e) => setDraft((d) => ({ ...d, slug: e.target.value }))}
placeholder="slug-de-l-article"
className="flex-1 max-w-[400px] bg-transparent border-b border-line font-mono text-ink focus:outline-none focus:border-rubis"
/>
<button
type="button"
onClick={async () => {
try {
const result = await adminBlogApi.suggestSlug(draft.title);
setDraft((d) => ({ ...d, slug: result.slug }));
} catch {
/* swallow */
}
}}
className="text-rubis hover:text-rubis-deep underline-offset-2 hover:underline"
>
générer depuis le titre
</button>
</div>
{/* Layout 2 colonnes */}
<div className="mt-8 grid lg:grid-cols-[2fr_1fr] gap-8">
{/* Colonne gauche : éditeur markdown */}
<div data-color-mode="light" className="rubis-md-editor-wrapper">
<Suspense fallback={<div className="text-ink-3">Chargement de l'éditeur</div>}>
<MdEditor
value={draft.contentMd}
onChange={(val) => setDraft((d) => ({ ...d, contentMd: val ?? "" }))}
height={650}
preview="live"
previewOptions={{
/* On utilise le preview natif @uiw, qui rend correctement le GFM
markdown pour donner un aperçu. Le rendu final sur rubis.pro
passe par notre blog_renderer (marked v15), légèrement
différent l'aperçu sert à valider la STRUCTURE, pas le
pixel-perfect. Pour ça : "Voir sur rubis.pro" après publish. */
}}
/>
</Suspense>
<div className="mt-3 text-[12.5px] text-ink-3 flex items-center gap-3">
<span>{wordCount} mots · ~{Math.max(1, Math.round(wordCount / 220))} min</span>
{wordCount < VALIDATION_RULES.minWordCount && (
<span className="text-rubis-deep">
· Min {VALIDATION_RULES.minWordCount} mots requis pour publier
</span>
)}
</div>
</div>
{/* Colonne droite : meta SEO + hero */}
<div className="space-y-6">
{/* Hero image */}
<Card padding="md">
<div className="font-display font-semibold text-[14px] uppercase tracking-[0.08em] text-ink mb-3">
Image de couverture
</div>
<HeroImageUpload
value={draft.heroImageUrl}
alt={draft.heroImageAlt}
onChange={({ url, alt }) =>
setDraft((d) => ({ ...d, heroImageUrl: url, heroImageAlt: alt }))
}
/>
</Card>
{/* Meta description */}
<Card padding="md">
<div className="font-display font-semibold text-[14px] uppercase tracking-[0.08em] text-ink mb-3">
Description (SEO)
</div>
<textarea
value={draft.excerpt}
onChange={(e) => setDraft((d) => ({ ...d, excerpt: e.target.value }))}
rows={4}
placeholder="120 à 160 caractères. Cette ligne apparaît dans les résultats Google et sur LinkedIn/Twitter quand l'article est partagé."
className="w-full px-3 py-2 rounded-default border border-line bg-white text-[14px] text-ink leading-relaxed focus:border-rubis focus:outline-none focus:ring-4 focus:ring-rubis-glow/40 resize-none"
/>
<div className="text-[11.5px] mt-1.5 flex items-center justify-between">
<span
className={cn(
"tabular-nums",
draft.excerpt.length < VALIDATION_RULES.excerptMinChars ||
draft.excerpt.length > VALIDATION_RULES.excerptMaxChars
? "text-rubis-deep"
: "text-ink-3",
)}
>
{draft.excerpt.length} / {VALIDATION_RULES.excerptMinChars}-
{VALIDATION_RULES.excerptMaxChars} chars
</span>
</div>
</Card>
{/* Tags */}
<Card padding="md">
<div className="font-display font-semibold text-[14px] uppercase tracking-[0.08em] text-ink mb-3">
Tags
</div>
<input
type="text"
value={draft.tags}
onChange={(e) => setDraft((d) => ({ ...d, tags: e.target.value }))}
placeholder="trésorerie, LME, retards (séparés par virgules)"
className="w-full px-3 py-2 rounded-default border border-line bg-white text-[14px] text-ink focus:border-rubis focus:outline-none focus:ring-4 focus:ring-rubis-glow/40"
/>
<div className="text-[11.5px] text-ink-3 mt-1.5">
Sert au "Articles liés" sur rubis.pro/blog (intersection de tags). Max 5.
</div>
</Card>
{/* Aperçu Google snippet */}
<Card padding="md">
<div className="font-display font-semibold text-[14px] uppercase tracking-[0.08em] text-ink mb-3">
Aperçu Google
</div>
<div className="bg-white border border-line rounded-default p-4">
<div className="text-[12.5px] text-ink-3">rubis.pro blog {draft.slug}</div>
<div className="mt-1 font-display text-[18px] text-rubis hover:underline cursor-pointer">
{(draft.title || "…").slice(0, 60)}
</div>
<div className="text-[13.5px] text-ink-2 leading-snug line-clamp-2 mt-1">
{draft.excerpt || "Description…"}
</div>
</div>
</Card>
{/* Validations bloquantes */}
{issues.length > 0 && (
<Card padding="md" variant="flat">
<div className="font-display font-semibold text-[14px] uppercase tracking-[0.08em] text-rubis-deep mb-2">
À corriger avant publication
</div>
<ul className="space-y-1.5 text-[13px] text-ink-2">
{issues.map((issue, i) => (
<li key={i} className="flex items-baseline gap-2">
<span aria-hidden className="size-1.5 rounded-full bg-rubis flex-shrink-0 mt-1.5" />
<span>{issue.message}</span>
</li>
))}
</ul>
</Card>
)}
</div>
</div>
</div>
);
}
function SaveStatus({ isSaving, lastSavedAt }: { isSaving: boolean; lastSavedAt: string | null }) {
if (isSaving) {
return <span className="text-[12.5px] text-ink-3">Sauvegarde</span>;
}
if (!lastSavedAt) return null;
const date = new Date(lastSavedAt);
return (
<span className="text-[12.5px] text-ink-3" title={date.toISOString()}>
Sauvé à{" "}
{new Intl.DateTimeFormat("fr-FR", { hour: "2-digit", minute: "2-digit" }).format(date)}
</span>
);
}
function CharCounter({ value, max, label }: { value: number; max: number; label: string }) {
const isOver = value > max;
return (
<div
className={cn(
"text-[11.5px] tabular-nums",
isOver ? "text-rubis-deep" : "text-ink-3",
)}
>
{label} : {value}/{max} chars {isOver && "· trop long pour Google"}
</div>
);
}
/** Compte les mots côté client — heuristique simple, le serveur a la source de vérité. */
function countWords(md: string): number {
const stripped = md
.replace(/```[\s\S]*?```/g, " ")
.replace(/`[^`]*`/g, " ")
.replace(/!\[[^\]]*\]\([^)]*\)/g, " ")
.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
.replace(/[*_~#>]/g, " ");
const matches = stripped.match(/\S+/g);
return matches ? matches.length : 0;
}

View File

@ -59,19 +59,22 @@ spec:
timeoutSeconds: 3
failureThreshold: 3
---
# ClusterIP — exposé à Traefik via IngressRoute (gérée dans le repo proxmox).
# NodePort — Traefik (sur la VM Proxmox) tape sur 10.10.10.5:30111.
# Le routing dynamique Traefik vit dans le repo proxmox :
# ansible/roles/traefik/templates/rubis.yml.j2
apiVersion: v1
kind: Service
metadata:
name: rubis-landing
namespace: rubis
spec:
type: ClusterIP
type: NodePort
selector:
app: rubis-landing
ports:
- port: 4321
targetPort: http
nodePort: 30111
name: http
---
apiVersion: v1

View File

@ -10,6 +10,8 @@ export type User = {
organizationId: string;
/** Signature email utilisée comme expéditeur des relances. */
signature: string | null;
/** Accès aux endpoints /admin/* (édition du blog notamment). */
isAdmin: boolean;
createdAt: string;
updatedAt: string;
};

398
pnpm-lock.yaml generated
View File

@ -289,6 +289,9 @@ importers:
'@tuyau/client':
specifier: ^0.2.10
version: 0.2.10
'@uiw/react-md-editor':
specifier: ^4.0.5
version: 4.1.0(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@ -3912,6 +3915,9 @@ packages:
'@types/esrecurse@4.3.1':
resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==}
'@types/estree-jsx@1.0.5':
resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==}
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
@ -3969,6 +3975,9 @@ packages:
'@types/pluralize@0.0.33':
resolution: {integrity: sha512-JOqsl+ZoCpP4e8TDke9W79FDcSgPAR0l6pixx2JHkhnRjvShyYiAYw2LVsnA7K08Y6DeOnaU6ujmENO4os/cYg==}
'@types/prismjs@1.26.6':
resolution: {integrity: sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw==}
'@types/react-dom@19.2.3':
resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
peerDependencies:
@ -3989,6 +3998,9 @@ packages:
'@types/tedious@4.0.14':
resolution: {integrity: sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw==}
'@types/unist@2.0.11':
resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==}
'@types/unist@3.0.3':
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
@ -4057,6 +4069,21 @@ packages:
resolution: {integrity: sha512-NwjLUnGy8/Zfx23fl50tRC8rYaYnM52xNRYFAXvmiil9yh1+K6aRVQMnzW6gQB/1DLgWt977lYQn7C+wtgXZiA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@uiw/copy-to-clipboard@1.0.20':
resolution: {integrity: sha512-IFQhS62CLNon1YgYJTEzXR2N3WVXg7V1FaBRDLMlzU6JY5X6Hr3OPAcw4WNoKcz2XcFD6XCgwEjlsmj+JA0mWA==}
'@uiw/react-markdown-preview@5.2.0':
resolution: {integrity: sha512-39kgf+Wk6DXpYtztUt34xFPt0BzGkuxmFZKI7rNAlCFKXdAmyhqLlRbQKyrRwOVeuRyrQy/Z96UBSw5AHFBivg==}
peerDependencies:
react: '>=16.8.0'
react-dom: '>=16.8.0'
'@uiw/react-md-editor@4.1.0':
resolution: {integrity: sha512-ti8WO9Mf55bFdb5JXEJmQ77NQRG8VyUAuE6c5XRwMI3S05Q0uuiRKOs5yAyJWiISgkV39DMmf4UU8DbADPILhw==}
peerDependencies:
react: '>=16.8.0'
react-dom: '>=16.8.0'
'@ungap/structured-clone@1.3.1':
resolution: {integrity: sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==}
@ -4293,6 +4320,9 @@ packages:
resolution: {integrity: sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==}
engines: {node: '>= 0.8'}
bcp-47-match@2.0.3:
resolution: {integrity: sha512-JtTezzbAibu8G0R9op9zb3vcWZd9JF6M0xOYGPn0fNCd7wOpRB1mU2mH9T8gaBGbAAyIIVgB2G7xG0GP98zMAQ==}
better-sqlite3@12.9.0:
resolution: {integrity: sha512-wqUv4Gm3toFpHDQmaKD4QhZm3g1DjUBI0yzS4UBl6lElUmXFYdTQmmEDpAFa5o8FiFiymURypEnfVHzILKaxqQ==}
engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x}
@ -4415,6 +4445,9 @@ packages:
character-entities@2.0.2:
resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==}
character-reference-invalid@2.0.1:
resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==}
check-disk-space@3.4.0:
resolution: {integrity: sha512-drVkSqfwA+TvuEhFipiR1OC9boEGZL5RrWvVsOthdcvQNXyCCuKkEiTOTXZ7qxSf/GLwq4GvzfrQD/Wz325hgw==}
engines: {node: '>=16'}
@ -4599,6 +4632,9 @@ packages:
css-select@5.2.2:
resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==}
css-selector-parser@3.3.0:
resolution: {integrity: sha512-Y2asgMGFqJKF4fq4xHDSlFYIkeVfRsm69lQC1q9kbEsH5XtnINTMrweLkjYMeaUgiXBy/uvKeO/a1JHTNnmB2g==}
css-tree@2.2.1:
resolution: {integrity: sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==}
engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'}
@ -4797,6 +4833,10 @@ packages:
resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==}
engines: {node: '>=0.3.1'}
direction@2.0.1:
resolution: {integrity: sha512-9S6m9Sukh1cZNknO1CWAr2QAWsbKLafQiyM5gZ7VgXHeuaoUwffKN4q6NC4A/Mf9iiPlOXQEKW/Mv/mh9/3YFA==}
hasBin: true
dlv@1.1.3:
resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==}
@ -5036,6 +5076,9 @@ packages:
resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==}
engines: {node: '>=4.0'}
estree-util-is-identifier-name@3.0.0:
resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==}
estree-walker@2.0.2:
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
@ -5358,6 +5401,12 @@ packages:
hast-util-from-parse5@8.0.3:
resolution: {integrity: sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==}
hast-util-has-property@3.0.0:
resolution: {integrity: sha512-MNilsvEKLFpV604hwfhVStK0usFY/QmM5zX16bo7EjnAEGofr5YyI37kzopBlZJkHD4t887i+q/C8/tr5Q94cA==}
hast-util-heading-rank@3.0.0:
resolution: {integrity: sha512-EJKb8oMUXVHcWZTDepnr+WNbfnXKFNf9duMesmr4S8SXTJBJ9M4Yok08pu9vxdJwdlGRhVumk9mEhkEvKGifwA==}
hast-util-is-element@3.0.0:
resolution: {integrity: sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==}
@ -5367,12 +5416,21 @@ packages:
hast-util-raw@9.1.0:
resolution: {integrity: sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==}
hast-util-select@6.0.4:
resolution: {integrity: sha512-RqGS1ZgI0MwxLaKLDxjprynNzINEkRHY2i8ln4DDjgv9ZhcYVIHN9rlpiYsqtFwrgpYU361SyWDQcGNIBVu3lw==}
hast-util-to-html@9.0.5:
resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==}
hast-util-to-jsx-runtime@2.3.6:
resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==}
hast-util-to-parse5@8.0.1:
resolution: {integrity: sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==}
hast-util-to-string@3.0.1:
resolution: {integrity: sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A==}
hast-util-to-text@4.0.2:
resolution: {integrity: sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==}
@ -5422,6 +5480,9 @@ packages:
resolution: {integrity: sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==}
engines: {node: '>=14'}
html-url-attributes@3.0.1:
resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==}
html-void-elements@3.0.0:
resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==}
@ -5565,6 +5626,9 @@ packages:
ini@1.3.8:
resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==}
inline-style-parser@0.2.7:
resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==}
internmap@2.0.3:
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
engines: {node: '>=12'}
@ -5584,6 +5648,12 @@ packages:
iron-webcrypto@1.2.1:
resolution: {integrity: sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==}
is-alphabetical@2.0.1:
resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==}
is-alphanumerical@2.0.1:
resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==}
is-binary-path@2.1.0:
resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==}
engines: {node: '>=8'}
@ -5596,6 +5666,9 @@ packages:
resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==}
engines: {node: '>= 0.4'}
is-decimal@2.0.1:
resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==}
is-docker@3.0.0:
resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
@ -5626,6 +5699,9 @@ packages:
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
engines: {node: '>=0.10.0'}
is-hexadecimal@2.0.1:
resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==}
is-in-ssh@1.0.0:
resolution: {integrity: sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==}
engines: {node: '>=20'}
@ -6022,6 +6098,15 @@ packages:
mdast-util-gfm@3.1.0:
resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==}
mdast-util-mdx-expression@2.0.1:
resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==}
mdast-util-mdx-jsx@3.2.0:
resolution: {integrity: sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==}
mdast-util-mdxjs-esm@2.0.1:
resolution: {integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==}
mdast-util-phrasing@4.1.0:
resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==}
@ -6406,6 +6491,9 @@ packages:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'}
parse-entities@4.0.2:
resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==}
parse-imports@3.0.0:
resolution: {integrity: sha512-IwiqoJANa4O6M76LBWEvoS2iPIUqBOnKG1lV3/J0oVM6V2XjED+mYAXedEMX5xUglVjfGpZOfaEyuOUjBuUE4g==}
engines: {node: '>= 22'}
@ -6421,6 +6509,9 @@ packages:
resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==}
engines: {node: '>=18'}
parse-numeric-range@1.3.0:
resolution: {integrity: sha512-twN+njEipszzlMJd4ONUYgSfZPDxgHhT9Ahed5uTigpQn90FggW4SA/AIPq/6a149fTbE9qBEcSwE3FAEp6wQQ==}
parse-svg-path@0.1.2:
resolution: {integrity: sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ==}
@ -6699,6 +6790,12 @@ packages:
react-is@18.3.1:
resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==}
react-markdown@10.1.0:
resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==}
peerDependencies:
'@types/react': '>=18'
react: '>=18'
react-redux@9.2.0:
resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==}
peerDependencies:
@ -6812,6 +6909,9 @@ packages:
reflect-metadata@0.2.2:
resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==}
refractor@5.0.0:
resolution: {integrity: sha512-QXOrHQF5jOpjjLfiNk5GFnWhRXvxjUVnlFxkeDmewR5sXkr3iM46Zo+CnRR8B+MDVqkULW4EcLVcRBNOPXHosw==}
regex-recursion@6.0.2:
resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==}
@ -6829,12 +6929,33 @@ packages:
resolution: {integrity: sha512-dLsljMd9sqwRkby8zhO1gSg3PnJIBFid8f4CQj/sXx+7cKx+E7u0PKhZ+U4wmhx7EfmtvnA318oVaIkAB1lRJw==}
hasBin: true
rehype-attr@4.0.2:
resolution: {integrity: sha512-v4+gw7pvUVLbG/dUpLgBE6r3TWTBYJ7z+sfAH3zapmM5CKzk5+CopFQgr4gMR6OBSKl/qpI6HR7gv1Cbig0uow==}
engines: {node: '>=16'}
rehype-autolink-headings@7.1.0:
resolution: {integrity: sha512-rItO/pSdvnvsP4QRB1pmPiNHUskikqtPojZKJPPPAVx9Hj8i8TwMBhofrrAYRhYOOBZH9tgmG5lPqDLuIWPWmw==}
rehype-ignore@2.0.3:
resolution: {integrity: sha512-IzhP6/u/6sm49sdktuYSmeIuObWB+5yC/5eqVws8BhuGA9kY25/byz6uCy/Ravj6lXUShEd2ofHM5MyAIj86Sg==}
engines: {node: '>=16'}
rehype-parse@9.0.1:
resolution: {integrity: sha512-ksCzCD0Fgfh7trPDxr2rSylbwq9iYDkSn8TCDmEJ49ljEUBxDVCzCHv7QNzZOfODanX4+bWQ4WZqLCRWYLfhag==}
rehype-prism-plus@2.0.2:
resolution: {integrity: sha512-jTHb8ZtQHd2VWAAKeCINgv/8zNEF0+LesmwJak69GemoPVN9/8fGEARTvqOpKqmN57HwaM9z8UKBVNVJe8zggw==}
rehype-raw@7.0.0:
resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==}
rehype-rewrite@4.0.4:
resolution: {integrity: sha512-L/FO96EOzSA6bzOam4DVu61/PB3AGKcSPXpa53yMIozoxH4qg1+bVZDF8zh1EsuxtSauAhzt5cCnvoplAaSLrw==}
engines: {node: '>=16.0.0'}
rehype-slug@6.0.0:
resolution: {integrity: sha512-lWyvf/jwu+oS5+hL5eClVd3hNdmwM1kAC0BUvEGD19pajQMIzcNUd/k9GsfQ+FfECvX+JE+e9/btsKH0EjJT6A==}
rehype-stringify@10.0.1:
resolution: {integrity: sha512-k9ecfXHmIPuFVI61B9DeLPN0qFHfawM6RsuX48hoqlaKSF61RskNjSm1lI8PhBEM0MRdLxVVm4WmTqJQccH9mA==}
@ -6844,6 +6965,10 @@ packages:
remark-gfm@4.0.1:
resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==}
remark-github-blockquote-alert@1.3.1:
resolution: {integrity: sha512-OPNnimcKeozWN1w8KVQEuHOxgN3L4rah8geMOLhA5vN9wITqU4FWD+G26tkEsCGHiOVDbISx+Se5rGZ+D1p0Jg==}
engines: {node: '>=16'}
remark-parse@11.0.0:
resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==}
@ -7226,6 +7351,12 @@ packages:
resolution: {integrity: sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==}
engines: {node: '>=18'}
style-to-js@1.1.21:
resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==}
style-to-object@1.0.14:
resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==}
superagent@10.3.0:
resolution: {integrity: sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==}
engines: {node: '>=14.18.0'}
@ -7506,6 +7637,9 @@ packages:
unifont@0.7.4:
resolution: {integrity: sha512-oHeis4/xl42HUIeHuNZRGEvxj5AaIKR+bHPNegRq5LV1gdc3jundpONbjglKpihmJf+dswygdMJn3eftGIMemg==}
unist-util-filter@5.0.1:
resolution: {integrity: sha512-pHx7D4Zt6+TsfwylH9+lYhBhzyhEnCXs/lbq/Hstxno5z4gVdyc2WEW0asfjGKPyG4pEKrnBv5hdkO6+aRnQJw==}
unist-util-find-after@5.0.0:
resolution: {integrity: sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==}
@ -7530,6 +7664,9 @@ packages:
unist-util-visit-parents@6.0.2:
resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==}
unist-util-visit@5.0.0:
resolution: {integrity: sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==}
unist-util-visit@5.1.0:
resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==}
@ -11847,6 +11984,10 @@ snapshots:
'@types/esrecurse@4.3.1': {}
'@types/estree-jsx@1.0.5':
dependencies:
'@types/estree': 1.0.8
'@types/estree@1.0.8': {}
'@types/hast@3.0.4':
@ -11911,6 +12052,8 @@ snapshots:
'@types/pluralize@0.0.33': {}
'@types/prismjs@1.26.6': {}
'@types/react-dom@19.2.3(@types/react@19.2.14)':
dependencies:
'@types/react': 19.2.14
@ -11936,6 +12079,8 @@ snapshots:
dependencies:
'@types/node': 25.6.0
'@types/unist@2.0.11': {}
'@types/unist@3.0.3': {}
'@types/use-sync-external-store@0.0.6': {}
@ -12033,6 +12178,41 @@ snapshots:
'@typescript-eslint/types': 8.59.2
eslint-visitor-keys: 5.0.1
'@uiw/copy-to-clipboard@1.0.20': {}
'@uiw/react-markdown-preview@5.2.0(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@babel/runtime': 7.29.2
'@uiw/copy-to-clipboard': 1.0.20
react: 19.2.5
react-dom: 19.2.5(react@19.2.5)
react-markdown: 10.1.0(@types/react@19.2.14)(react@19.2.5)
rehype-attr: 4.0.2
rehype-autolink-headings: 7.1.0
rehype-ignore: 2.0.3
rehype-prism-plus: 2.0.2
rehype-raw: 7.0.0
rehype-rewrite: 4.0.4
rehype-slug: 6.0.0
remark-gfm: 4.0.1
remark-github-blockquote-alert: 1.3.1
unist-util-visit: 5.1.0
transitivePeerDependencies:
- '@types/react'
- supports-color
'@uiw/react-md-editor@4.1.0(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@babel/runtime': 7.29.2
'@uiw/react-markdown-preview': 5.2.0(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
react: 19.2.5
react-dom: 19.2.5(react@19.2.5)
rehype: 13.0.2
rehype-prism-plus: 2.0.2
transitivePeerDependencies:
- '@types/react'
- supports-color
'@ungap/structured-clone@1.3.1': {}
'@vinejs/compiler@4.1.3': {}
@ -12371,6 +12551,8 @@ snapshots:
dependencies:
safe-buffer: 5.1.2
bcp-47-match@2.0.3: {}
better-sqlite3@12.9.0:
dependencies:
bindings: 1.5.0
@ -12496,6 +12678,8 @@ snapshots:
character-entities@2.0.2: {}
character-reference-invalid@2.0.1: {}
check-disk-space@3.4.0: {}
check-error@2.1.3: {}
@ -12665,6 +12849,8 @@ snapshots:
domutils: 3.2.2
nth-check: 2.1.1
css-selector-parser@3.3.0: {}
css-tree@2.2.1:
dependencies:
mdn-data: 2.0.28
@ -12811,6 +12997,8 @@ snapshots:
diff@8.0.4: {}
direction@2.0.1: {}
dlv@1.1.3: {}
dom-accessibility-api@0.5.16: {}
@ -13118,6 +13306,8 @@ snapshots:
estraverse@5.3.0: {}
estree-util-is-identifier-name@3.0.0: {}
estree-walker@2.0.2: {}
estree-walker@3.0.3:
@ -13448,6 +13638,14 @@ snapshots:
vfile-location: 5.0.3
web-namespaces: 2.0.1
hast-util-has-property@3.0.0:
dependencies:
'@types/hast': 3.0.4
hast-util-heading-rank@3.0.0:
dependencies:
'@types/hast': 3.0.4
hast-util-is-element@3.0.0:
dependencies:
'@types/hast': 3.0.4
@ -13472,6 +13670,24 @@ snapshots:
web-namespaces: 2.0.1
zwitch: 2.0.4
hast-util-select@6.0.4:
dependencies:
'@types/hast': 3.0.4
'@types/unist': 3.0.3
bcp-47-match: 2.0.3
comma-separated-tokens: 2.0.3
css-selector-parser: 3.3.0
devlop: 1.1.0
direction: 2.0.1
hast-util-has-property: 3.0.0
hast-util-to-string: 3.0.1
hast-util-whitespace: 3.0.0
nth-check: 2.1.1
property-information: 7.1.0
space-separated-tokens: 2.0.2
unist-util-visit: 5.1.0
zwitch: 2.0.4
hast-util-to-html@9.0.5:
dependencies:
'@types/hast': 3.0.4
@ -13486,6 +13702,26 @@ snapshots:
stringify-entities: 4.0.4
zwitch: 2.0.4
hast-util-to-jsx-runtime@2.3.6:
dependencies:
'@types/estree': 1.0.8
'@types/hast': 3.0.4
'@types/unist': 3.0.3
comma-separated-tokens: 2.0.3
devlop: 1.1.0
estree-util-is-identifier-name: 3.0.0
hast-util-whitespace: 3.0.0
mdast-util-mdx-expression: 2.0.1
mdast-util-mdx-jsx: 3.2.0
mdast-util-mdxjs-esm: 2.0.1
property-information: 7.1.0
space-separated-tokens: 2.0.2
style-to-js: 1.1.21
unist-util-position: 5.0.0
vfile-message: 4.0.3
transitivePeerDependencies:
- supports-color
hast-util-to-parse5@8.0.1:
dependencies:
'@types/hast': 3.0.4
@ -13496,6 +13732,10 @@ snapshots:
web-namespaces: 2.0.1
zwitch: 2.0.4
hast-util-to-string@3.0.1:
dependencies:
'@types/hast': 3.0.4
hast-util-to-text@4.0.2:
dependencies:
'@types/hast': 3.0.4
@ -13562,6 +13802,8 @@ snapshots:
htmlparser2: 8.0.2
selderee: 0.11.0
html-url-attributes@3.0.1: {}
html-void-elements@3.0.0: {}
htmlparser2@8.0.2:
@ -13672,6 +13914,8 @@ snapshots:
ini@1.3.8: {}
inline-style-parser@0.2.7: {}
internmap@2.0.3: {}
interpret@2.2.0: {}
@ -13694,6 +13938,13 @@ snapshots:
iron-webcrypto@1.2.1: {}
is-alphabetical@2.0.1: {}
is-alphanumerical@2.0.1:
dependencies:
is-alphabetical: 2.0.1
is-decimal: 2.0.1
is-binary-path@2.1.0:
dependencies:
binary-extensions: 2.3.0
@ -13706,6 +13957,8 @@ snapshots:
dependencies:
hasown: 2.0.3
is-decimal@2.0.1: {}
is-docker@3.0.0: {}
is-docker@4.0.0: {}
@ -13724,6 +13977,8 @@ snapshots:
dependencies:
is-extglob: 2.1.1
is-hexadecimal@2.0.1: {}
is-in-ssh@1.0.0: {}
is-inside-container@1.0.0:
@ -14137,6 +14392,45 @@ snapshots:
transitivePeerDependencies:
- supports-color
mdast-util-mdx-expression@2.0.1:
dependencies:
'@types/estree-jsx': 1.0.5
'@types/hast': 3.0.4
'@types/mdast': 4.0.4
devlop: 1.1.0
mdast-util-from-markdown: 2.0.3
mdast-util-to-markdown: 2.1.2
transitivePeerDependencies:
- supports-color
mdast-util-mdx-jsx@3.2.0:
dependencies:
'@types/estree-jsx': 1.0.5
'@types/hast': 3.0.4
'@types/mdast': 4.0.4
'@types/unist': 3.0.3
ccount: 2.0.1
devlop: 1.1.0
mdast-util-from-markdown: 2.0.3
mdast-util-to-markdown: 2.1.2
parse-entities: 4.0.2
stringify-entities: 4.0.4
unist-util-stringify-position: 4.0.0
vfile-message: 4.0.3
transitivePeerDependencies:
- supports-color
mdast-util-mdxjs-esm@2.0.1:
dependencies:
'@types/estree-jsx': 1.0.5
'@types/hast': 3.0.4
'@types/mdast': 4.0.4
devlop: 1.1.0
mdast-util-from-markdown: 2.0.3
mdast-util-to-markdown: 2.1.2
transitivePeerDependencies:
- supports-color
mdast-util-phrasing@4.1.0:
dependencies:
'@types/mdast': 4.0.4
@ -14624,6 +14918,16 @@ snapshots:
dependencies:
callsites: 3.1.0
parse-entities@4.0.2:
dependencies:
'@types/unist': 2.0.11
character-entities-legacy: 3.0.0
character-reference-invalid: 2.0.1
decode-named-character-reference: 1.3.0
is-alphanumerical: 2.0.1
is-decimal: 2.0.1
is-hexadecimal: 2.0.1
parse-imports@3.0.0:
dependencies:
es-module-lexer: 1.7.0
@ -14646,6 +14950,8 @@ snapshots:
parse-ms@4.0.0: {}
parse-numeric-range@1.3.0: {}
parse-svg-path@0.1.2: {}
parse5@7.3.0:
@ -14918,6 +15224,24 @@ snapshots:
react-is@18.3.1: {}
react-markdown@10.1.0(@types/react@19.2.14)(react@19.2.5):
dependencies:
'@types/hast': 3.0.4
'@types/mdast': 4.0.4
'@types/react': 19.2.14
devlop: 1.1.0
hast-util-to-jsx-runtime: 2.3.6
html-url-attributes: 3.0.1
mdast-util-to-hast: 13.2.1
react: 19.2.5
remark-parse: 11.0.0
remark-rehype: 11.1.2
unified: 11.0.5
unist-util-visit: 5.1.0
vfile: 6.0.3
transitivePeerDependencies:
- supports-color
react-redux@9.2.0(@types/react@19.2.14)(react@19.2.5)(redux@5.0.1):
dependencies:
'@types/use-sync-external-store': 0.0.6
@ -15031,6 +15355,13 @@ snapshots:
reflect-metadata@0.2.2: {}
refractor@5.0.0:
dependencies:
'@types/hast': 3.0.4
'@types/prismjs': 1.26.6
hastscript: 9.0.1
parse-entities: 4.0.2
regex-recursion@6.0.2:
dependencies:
regex-utilities: 2.3.0
@ -15047,18 +15378,61 @@ snapshots:
dependencies:
jsesc: 3.1.0
rehype-attr@4.0.2:
dependencies:
unified: 11.0.5
unist-util-visit: 5.0.0
rehype-autolink-headings@7.1.0:
dependencies:
'@types/hast': 3.0.4
'@ungap/structured-clone': 1.3.1
hast-util-heading-rank: 3.0.0
hast-util-is-element: 3.0.0
unified: 11.0.5
unist-util-visit: 5.1.0
rehype-ignore@2.0.3:
dependencies:
hast-util-select: 6.0.4
unified: 11.0.5
unist-util-visit: 5.1.0
rehype-parse@9.0.1:
dependencies:
'@types/hast': 3.0.4
hast-util-from-html: 2.0.3
unified: 11.0.5
rehype-prism-plus@2.0.2:
dependencies:
hast-util-to-string: 3.0.1
parse-numeric-range: 1.3.0
refractor: 5.0.0
rehype-parse: 9.0.1
unist-util-filter: 5.0.1
unist-util-visit: 5.1.0
rehype-raw@7.0.0:
dependencies:
'@types/hast': 3.0.4
hast-util-raw: 9.1.0
vfile: 6.0.3
rehype-rewrite@4.0.4:
dependencies:
hast-util-select: 6.0.4
unified: 11.0.5
unist-util-visit: 5.1.0
rehype-slug@6.0.0:
dependencies:
'@types/hast': 3.0.4
github-slugger: 2.0.0
hast-util-heading-rank: 3.0.0
hast-util-to-string: 3.0.1
unist-util-visit: 5.1.0
rehype-stringify@10.0.1:
dependencies:
'@types/hast': 3.0.4
@ -15083,6 +15457,10 @@ snapshots:
transitivePeerDependencies:
- supports-color
remark-github-blockquote-alert@1.3.1:
dependencies:
unist-util-visit: 5.1.0
remark-parse@11.0.0:
dependencies:
'@types/mdast': 4.0.4
@ -15533,6 +15911,14 @@ snapshots:
dependencies:
'@tokenizer/token': 0.3.0
style-to-js@1.1.21:
dependencies:
style-to-object: 1.0.14
style-to-object@1.0.14:
dependencies:
inline-style-parser: 0.2.7
superagent@10.3.0:
dependencies:
component-emitter: 1.3.1
@ -15797,6 +16183,12 @@ snapshots:
ofetch: 1.5.1
ohash: 2.0.11
unist-util-filter@5.0.1:
dependencies:
'@types/unist': 3.0.3
unist-util-is: 6.0.1
unist-util-visit-parents: 6.0.2
unist-util-find-after@5.0.0:
dependencies:
'@types/unist': 3.0.3
@ -15833,6 +16225,12 @@ snapshots:
'@types/unist': 3.0.3
unist-util-is: 6.0.1
unist-util-visit@5.0.0:
dependencies:
'@types/unist': 3.0.3
unist-util-is: 6.0.1
unist-util-visit-parents: 6.0.2
unist-util-visit@5.1.0:
dependencies:
'@types/unist': 3.0.3