fix(blog/admin): expose contentMd dans PostTransformer + nullish guards
All checks were successful
Build & Deploy Web / build-and-deploy (push) Successful in 35s
Build & Deploy API / build-and-deploy (push) Successful in 1m27s

Le PostTransformer ne renvoyait que contentHtml — l'éditeur admin avait
besoin du contentMd source pour permettre l'édition, et plantait avec
"Cannot read properties of undefined (reading 'replace')" dans countWords()
au mount.

* PostTransformer expose maintenant contentMd, status et createdAt en
  plus de l'existant. Surcoût ~quelques KB par requête côté landing
  publique (négligeable). Si volume devient un problème, on splittera
  en PublicPostTransformer + AdminPostTransformer.
* admin.blog_.$id.tsx : nullish coalescing sur tous les champs string
  au moment d'init le draft (defense in depth — si l'API renvoie
  jamais un payload partiel, l'éditeur reste fonctionnel).
* countWords() accepte maintenant string | null | undefined.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
ordinarthur 2026-05-09 17:40:53 +02:00
parent 6dcae6956c
commit 52bc7507fb
2 changed files with 22 additions and 16 deletions

View File

@ -2,12 +2,14 @@ import type Post from '#models/post'
import { BaseTransformer } from '@adonisjs/core/transformers'
/**
* PostTransformer sérialise un Post en JSON public pour l'app Astro qui
* rend les pages blog en SSR (cf. apps/landing/src/pages/blog/*.astro).
* PostTransformer sérialise un Post en JSON pour les consommateurs
* (Astro landing en SSR public, et apps/web admin en édition).
*
* On ne retourne JAMAIS contentMd (c'est la source d'édition) uniquement
* contentHtml déjà rendu par blog_renderer côté API. Garde le payload léger
* et évite de re-parser markdown côté front.
* On expose à la fois `contentMd` (source markdown utile pour l'éditeur
* admin) et `contentHtml` (déjà rendu utilisé par la landing). Le surcoût
* en bytes côté landing est marginal (~quelques KB/article) et la
* simplicité d'avoir un seul transformer prévaut. Si le volume devient
* problématique, on splittera en PublicPostTransformer + AdminPostTransformer.
*/
export default class PostTransformer extends BaseTransformer<Post> {
toObject() {
@ -17,7 +19,9 @@ export default class PostTransformer extends BaseTransformer<Post> {
slug: p.slug,
title: p.title,
excerpt: p.excerpt,
contentMd: p.contentMd,
contentHtml: p.contentHtml,
status: p.status,
heroImageUrl: p.heroImageUrl,
heroImageAlt: p.heroImageAlt,
ogImageUrl: p.ogImageUrl,
@ -25,6 +29,7 @@ export default class PostTransformer extends BaseTransformer<Post> {
authorName: p.authorName,
tags: p.tags,
publishedAt: p.publishedAt?.toISO() ?? null,
createdAt: p.createdAt.toISO()!,
updatedAt: p.updatedAt?.toISO() ?? null,
readingTimeMinutes: p.readingTimeMinutes,
wordCount: p.wordCount,

View File

@ -118,25 +118,25 @@ function Editor({ post, onBack }: { post: AdminPost; onBack: () => void }) {
// É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,
slug: post.slug ?? "",
title: post.title ?? "",
excerpt: post.excerpt ?? "",
contentMd: post.contentMd ?? "",
heroImageUrl: post.heroImageUrl,
heroImageAlt: post.heroImageAlt,
tags: post.tags.join(", "),
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,
slug: post.slug ?? "",
title: post.title ?? "",
excerpt: post.excerpt ?? "",
contentMd: post.contentMd ?? "",
heroImageUrl: post.heroImageUrl,
heroImageAlt: post.heroImageAlt,
tags: post.tags.join(", "),
tags: (post.tags ?? []).join(", "),
});
}, [post.id]); // eslint-disable-line react-hooks/exhaustive-deps
@ -510,7 +510,8 @@ function CharCounter({ value, max, label }: { value: number; max: number; label:
}
/** Compte les mots côté client — heuristique simple, le serveur a la source de vérité. */
function countWords(md: string): number {
function countWords(md: string | null | undefined): number {
if (!md) return 0;
const stripped = md
.replace(/```[\s\S]*?```/g, " ")
.replace(/`[^`]*`/g, " ")