fix(blog/admin): expose contentMd dans PostTransformer + nullish guards
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:
parent
6dcae6956c
commit
52bc7507fb
@ -2,12 +2,14 @@ import type Post from '#models/post'
|
|||||||
import { BaseTransformer } from '@adonisjs/core/transformers'
|
import { BaseTransformer } from '@adonisjs/core/transformers'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PostTransformer — sérialise un Post en JSON public pour l'app Astro qui
|
* PostTransformer — sérialise un Post en JSON pour les consommateurs
|
||||||
* rend les pages blog en SSR (cf. apps/landing/src/pages/blog/*.astro).
|
* (Astro landing en SSR public, et apps/web admin en édition).
|
||||||
*
|
*
|
||||||
* On ne retourne JAMAIS contentMd (c'est la source d'édition) — uniquement
|
* On expose à la fois `contentMd` (source markdown — utile pour l'éditeur
|
||||||
* contentHtml déjà rendu par blog_renderer côté API. Garde le payload léger
|
* admin) et `contentHtml` (déjà rendu — utilisé par la landing). Le surcoût
|
||||||
* et évite de re-parser markdown côté front.
|
* 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> {
|
export default class PostTransformer extends BaseTransformer<Post> {
|
||||||
toObject() {
|
toObject() {
|
||||||
@ -17,7 +19,9 @@ export default class PostTransformer extends BaseTransformer<Post> {
|
|||||||
slug: p.slug,
|
slug: p.slug,
|
||||||
title: p.title,
|
title: p.title,
|
||||||
excerpt: p.excerpt,
|
excerpt: p.excerpt,
|
||||||
|
contentMd: p.contentMd,
|
||||||
contentHtml: p.contentHtml,
|
contentHtml: p.contentHtml,
|
||||||
|
status: p.status,
|
||||||
heroImageUrl: p.heroImageUrl,
|
heroImageUrl: p.heroImageUrl,
|
||||||
heroImageAlt: p.heroImageAlt,
|
heroImageAlt: p.heroImageAlt,
|
||||||
ogImageUrl: p.ogImageUrl,
|
ogImageUrl: p.ogImageUrl,
|
||||||
@ -25,6 +29,7 @@ export default class PostTransformer extends BaseTransformer<Post> {
|
|||||||
authorName: p.authorName,
|
authorName: p.authorName,
|
||||||
tags: p.tags,
|
tags: p.tags,
|
||||||
publishedAt: p.publishedAt?.toISO() ?? null,
|
publishedAt: p.publishedAt?.toISO() ?? null,
|
||||||
|
createdAt: p.createdAt.toISO()!,
|
||||||
updatedAt: p.updatedAt?.toISO() ?? null,
|
updatedAt: p.updatedAt?.toISO() ?? null,
|
||||||
readingTimeMinutes: p.readingTimeMinutes,
|
readingTimeMinutes: p.readingTimeMinutes,
|
||||||
wordCount: p.wordCount,
|
wordCount: p.wordCount,
|
||||||
|
|||||||
@ -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.
|
// État local — on travaille sur une copie du post pour autosave debouncée.
|
||||||
const [draft, setDraft] = useState({
|
const [draft, setDraft] = useState({
|
||||||
slug: post.slug,
|
slug: post.slug ?? "",
|
||||||
title: post.title,
|
title: post.title ?? "",
|
||||||
excerpt: post.excerpt,
|
excerpt: post.excerpt ?? "",
|
||||||
contentMd: post.contentMd,
|
contentMd: post.contentMd ?? "",
|
||||||
heroImageUrl: post.heroImageUrl,
|
heroImageUrl: post.heroImageUrl,
|
||||||
heroImageAlt: post.heroImageAlt,
|
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).
|
// Re-sync si le post est rafraîchi par un autre canal (rare mais sûr).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setDraft({
|
setDraft({
|
||||||
slug: post.slug,
|
slug: post.slug ?? "",
|
||||||
title: post.title,
|
title: post.title ?? "",
|
||||||
excerpt: post.excerpt,
|
excerpt: post.excerpt ?? "",
|
||||||
contentMd: post.contentMd,
|
contentMd: post.contentMd ?? "",
|
||||||
heroImageUrl: post.heroImageUrl,
|
heroImageUrl: post.heroImageUrl,
|
||||||
heroImageAlt: post.heroImageAlt,
|
heroImageAlt: post.heroImageAlt,
|
||||||
tags: post.tags.join(", "),
|
tags: (post.tags ?? []).join(", "),
|
||||||
});
|
});
|
||||||
}, [post.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [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é. */
|
/** 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
|
const stripped = md
|
||||||
.replace(/```[\s\S]*?```/g, " ")
|
.replace(/```[\s\S]*?```/g, " ")
|
||||||
.replace(/`[^`]*`/g, " ")
|
.replace(/`[^`]*`/g, " ")
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user