docs(BlockEditor): document mediaSlug media library link and add server helpers

- update image block schema in README table to include `mediaSlug?` and clarify fields
- add "Liaison avec la médiathèque" section documenting mediaSlug behavior, read-only alt/caption, and internal `_` fields
- document new server helpers (`normalizeImageBlocks`, `enrichBlocksWithMedia`, `syncBlockImageReferences`) with usage examples
- add `block_image` field convention to media feature README with cross-references
- implement `mediaLink.server.js` with the three server-side helpers
- store `mediaSlug` on image block at insertion time in `Image.client.js`
- persist `mediaSlug` through clipboard paste/duplicate in `clipboard.js`
- export `mediaLink` entry point in `package.json` exports map
This commit is contained in:
2026-04-26 20:22:22 -04:00
parent 8c5c3baec4
commit 31d0359163
6 changed files with 501 additions and 43 deletions
+3
View File
@@ -151,6 +151,9 @@
"./shared/components": { "./shared/components": {
"import": "./dist/shared/components/index.js" "import": "./dist/shared/components/index.js"
}, },
"./shared/components/BlockEditor/mediaLink": {
"import": "./dist/shared/components/BlockEditor/mediaLink.server.js"
},
"./shared/icons": { "./shared/icons": {
"import": "./dist/shared/icons/index.js" "import": "./dist/shared/icons/index.js"
}, },
+6
View File
@@ -40,6 +40,12 @@ L'item « Médias » apparaît comme entrée top-level de la sidebar admin (sans
`zen_media_references` — table de jointure (media_id, source_type, source_id, field). FK `ON DELETE RESTRICT` empêche la suppression d'un média référencé par un contenu existant. `zen_media_references` — table de jointure (media_id, source_type, source_id, field). FK `ON DELETE RESTRICT` empêche la suppression d'un média référencé par un contenu existant.
Conventions de `field` connues :
- `featured_image` / `gallery` / *etc.* — champs nommés des modules consommateurs (cf. `attachMedia`).
- `block_image` — image insérée dans du contenu BlockEditor. Synchronisée
automatiquement par [`syncBlockImageReferences`](../../shared/components/BlockEditor/mediaLink.server.js)
à chaque save du document hôte. Voir [BlockEditor README — Liaison avec la médiathèque](../../shared/components/BlockEditor/README.md#liaison-avec-la-médiathèque-mediaslug).
### Stockage ### Stockage
Tous les uploads sont stockés sous `media/<yyyy>/<mm>/<filename-unique>` via [`@zen/core/storage`](../../core/storage). **Tous les objets S3 sont privés** au niveau du bucket — l'accès public passe exclusivement par notre route HTTP qui valide la `visibility` en BD avant de proxyfier le contenu. Tous les uploads sont stockés sous `media/<yyyy>/<mm>/<filename-unique>` via [`@zen/core/storage`](../../core/storage). **Tous les objets S3 sont privés** au niveau du bucket — l'accès public passe exclusivement par notre route HTTP qui valide la `visibility` en BD avant de proxyfier le contenu.
+79 -10
View File
@@ -30,7 +30,7 @@ Chaque bloc a un `id` (UUID) et un `type`. Selon le type :
| `quote` | `content` | citation | | `quote` | `content` | citation |
| `code` | `content` | bloc de code (monospace) | | `code` | `content` | bloc de code (monospace) |
| `divider` | — | séparateur horizontal | | `divider` | — | séparateur horizontal |
| `image` | `src`, `alt`, `caption`, `align`, `href`, `newTab` | image (URL uniquement) `align``left\|center\|right\|full`, `href` optionnel | | `image` | `src`, `mediaSlug?`, `alt?`, `caption?`, `align`, `href`, `newTab` | image — voir [Bloc image](#bloc-image) ; `align``left\|center\|right\|full` |
`content` est un **tableau `InlineNode[]`** depuis Phase 2 — voir ci-dessous. `content` est un **tableau `InlineNode[]`** depuis Phase 2 — voir ci-dessous.
@@ -291,10 +291,75 @@ Le formulaire d'insertion accepte deux sources :
- **URL externe** : champ libre + bouton « Insérer ». - **URL externe** : champ libre + bouton « Insérer ».
- **Bibliothèque media** : bouton « Choisir un média » qui ouvre le `MediaPicker` - **Bibliothèque media** : bouton « Choisir un média » qui ouvre le `MediaPicker`
de `@zen/core/features/media`. À la sélection, le bloc est inséré avec de `@zen/core/features/media`. À la sélection, le bloc reçoit
`block.src = "/zen/api/media/file/<slug>"`. Les nouveaux uploads passent par `block.src = "/zen/api/media/file/<slug>"` **et** `block.mediaSlug = "<slug>"`.
ce picker en visibilité `public` (les blocs image vivent dans du contenu Les nouveaux uploads passent par ce picker en visibilité `public` (les blocs
publié — un média privé serait illisible côté frontal). image vivent dans du contenu publié — un média privé serait illisible côté
frontal).
### Liaison avec la médiathèque (`mediaSlug`)
Quand un bloc image est lié à un média (`mediaSlug` présent), le **média est
la source unique de vérité** pour `alt` et `caption`. Le bloc ne stocke ni l'un
ni l'autre — l'éditeur affiche les valeurs du média en lecture seule avec un
lien « Modifier dans la médiathèque ». Toute mise à jour de l'alt ou de la
légende côté médiathèque est immédiatement répercutée sur tous les blocs qui
référencent ce média, sans toucher aux contenus.
Pour les **URL externes** (pas de `mediaSlug`), les inputs alt/caption locaux
restent disponibles sur le bloc — il n'y a pas d'autre source possible.
Champs internes au composant (préfixe `_`, jamais persistés) :
- `_mediaAlt` / `_mediaCaption` : snapshot capté à l'insertion via le
`MediaPicker`, pour affichage immédiat dans le bandeau lecture-seule sans
appel réseau.
- `_resolvedAlt` / `_resolvedCaption` : injectés au rendu serveur par
`enrichBlocksWithMedia` (cf. ci-dessous).
### Helpers serveur — `mediaLink.server.js`
Trois helpers à appeler depuis les actions/routes serveur du module qui
héberge le contenu BlockEditor :
```js
import {
normalizeImageBlocks,
enrichBlocksWithMedia,
syncBlockImageReferences,
} from '@zen/core/shared/components/BlockEditor/mediaLink';
// Au save :
const normalized = await normalizeImageBlocks(blocks);
await persistDocument(documentId, normalized);
await syncBlockImageReferences({
sourceType: 'post', // ou 'page', '@zen/module-shop:product', …
sourceId: documentId,
blocks: normalized,
});
// Au render (page SSR) :
const blocks = await loadDocument(slug);
const enriched = await enrichBlocksWithMedia(blocks);
return <BlockEditor value={enriched} disabled />;
```
`normalizeImageBlocks` :
- Lazy upgrade : extrait `mediaSlug` depuis `src` quand l'URL matche
`/zen/api/media/file/<slug>`.
- Back-fill : si le média n'a pas encore d'`alt_text` / `caption` mais que le
bloc en porte (legacy), remonte la valeur côté `zen_media`.
- Strict : nettoie `alt` et `caption` du bloc dès qu'un `mediaSlug` est résolu.
`enrichBlocksWithMedia` : une seule requête batch pour tous les `mediaSlug`
du document, attache `_resolvedAlt` / `_resolvedCaption`. Le composant de
rendu lit ces champs en priorité.
`syncBlockImageReferences` : synchronise `zen_media_references` avec
`field = 'block_image'`. La contrainte FK `ON DELETE RESTRICT` empêche
ensuite la suppression d'un média référencé par un bloc image tant que le
document hôte existe. Pensez à appeler `detachAllForSource` (de
`@zen/core/features/media`) à la suppression du document hôte.
Une fois l'URL insérée, l'image affiche au survol une **toolbar flottante** Une fois l'URL insérée, l'image affiche au survol une **toolbar flottante**
(coin haut-droit) reprenant le style `BOX_CLASS` partagé : (coin haut-droit) reprenant le style `BOX_CLASS` partagé :
@@ -313,14 +378,18 @@ Une fois l'URL insérée, l'image affiche au survol une **toolbar flottante**
- **Supprimer** : vide tous les champs ; le formulaire URL réapparaît. - **Supprimer** : vide tous les champs ; le formulaire URL réapparaît.
Les inputs *Légende* et *Texte alternatif* sous l'image sont des `<input>` Les inputs *Légende* et *Texte alternatif* sous l'image sont des `<input>`
HTML standards. Le clic dans ces inputs ne déclenche pas la sélection HTML standards (uniquement pour les images sans `mediaSlug` — cf.
multi-blocs (cf. garde dans `handleContainerMouseDown` : tout `input`, [Liaison avec la médiathèque](#liaison-avec-la-médiathèque-mediaslug)).
`textarea`, `select` ou `[contenteditable="true"]` reçoit son focus normal Le clic dans ces inputs ne déclenche pas la sélection multi-blocs (cf. garde
et n'entre pas en mode sélection). dans `handleContainerMouseDown` : tout `input`, `textarea`, `select` ou
`[contenteditable="true"]` reçoit son focus normal et n'entre pas en mode
sélection).
Sérialisation HTML : `<figure data-align="…"><a href><img></a><figcaption></figcaption></figure>`. Sérialisation HTML : `<figure data-align="…"><a href><img></a><figcaption></figcaption></figure>`.
Le parser inverse (`htmlToBlocks`) lit `data-align`, et descend dans `<a>` Le parser inverse (`htmlToBlocks`) lit `data-align`, et descend dans `<a>`
quand l'`<img>` y est imbriqué pour récupérer `href` / `target`. quand l'`<img>` y est imbriqué pour récupérer `href` / `target`. Si le `src`
de l'image matche `/zen/api/media/file/<slug>`, il reconstitue `mediaSlug`
pour préserver la liaison média lors d'un copier-coller interne.
## Limitations connues ## Limitations connues
@@ -23,9 +23,42 @@ import { newBlockId } from '../utils/ids.js';
import MediaPicker from '../../../../features/media/components/MediaPicker.client.js'; import MediaPicker from '../../../../features/media/components/MediaPicker.client.js';
// Bloc image. État vide = formulaire d'insertion d'URL. État rempli = image // Bloc image. État vide = formulaire d'insertion d'URL. État rempli = image
// rendue + toolbar flottante (alignement, lien, remplacer, supprimer) + // rendue + toolbar flottante (alignement, lien, remplacer, supprimer).
// inputs caption / alt en dessous. Champs persistés : `src`, `alt`, //
// `caption`, `align` (`'left' | 'center' | 'right' | 'full'`), `href`. // Champs persistés :
// - `src` : URL (interne `/zen/api/media/file/<slug>` ou externe)
// - `mediaSlug` : slug du média lié, présent si l'image vient de la
// médiathèque. Sert de référence vers `zen_media` et
// fait du média la source de vérité pour alt/caption.
// - `alt`/`caption` : utilisés UNIQUEMENT pour les URL externes (pas de
// `mediaSlug`). Quand un mediaSlug existe, alt et
// caption sont lus depuis le média via l'enrichissement
// serveur (`enrichBlocksWithMedia`) et exposés sur le
// bloc dans `_resolvedAlt` / `_resolvedCaption`.
// - `align` : 'left' | 'center' | 'right' | 'full'
// - `href`/`newTab` : lien optionnel
//
// Snapshot d'édition : `_mediaAlt` / `_mediaCaption` portent la copie des
// méta du média captées au moment de l'insertion, pour affichage immédiat
// dans le bandeau lecture-seule sans round-trip réseau. Champs internes
// préfixés `_` — non persistés ; ré-hydratés par l'enrichissement serveur
// au prochain rendu.
const MEDIA_FILE_URL_RE = /^\/zen\/api\/media\/file\/([^/?#]+)$/;
function extractMediaSlug(src) {
if (typeof src !== 'string') return null;
const m = MEDIA_FILE_URL_RE.exec(src);
return m ? m[1] : null;
}
function resolvedAlt(block) {
return block._resolvedAlt ?? block._mediaAlt ?? block.alt ?? '';
}
function resolvedCaption(block) {
return block._resolvedCaption ?? block._mediaCaption ?? block.caption ?? '';
}
const ALIGN_OPTIONS = [ const ALIGN_OPTIONS = [
{ value: 'left', label: 'Aligner à gauche', Icon: AlignLeftIcon }, { value: 'left', label: 'Aligner à gauche', Icon: AlignLeftIcon },
@@ -114,7 +147,14 @@ function ImageUrlForm({ initialUrl, onSubmit, onCancel, disabled }) {
isOpen={pickerOpen} isOpen={pickerOpen}
onClose={() => setPickerOpen(false)} onClose={() => setPickerOpen(false)}
onSelect={(media) => { onSelect={(media) => {
if (media?.slug) onSubmit?.(`/zen/api/media/file/${media.slug}`); if (media?.slug) {
onSubmit?.({
src: `/zen/api/media/file/${media.slug}`,
mediaSlug: media.slug,
mediaAlt: media.alt_text ?? '',
mediaCaption: media.caption ?? '',
});
}
}} }}
accept="image/*" accept="image/*"
visibility="any" visibility="any"
@@ -270,13 +310,36 @@ function ImageBlock({ block, onChange, disabled }) {
// `block.src` est non vide. // `block.src` est non vide.
const [replacing, setReplacing] = useState(false); const [replacing, setReplacing] = useState(false);
const showForm = !block.src || replacing; const showForm = !block.src || replacing;
const linkedSlug = block.mediaSlug || extractMediaSlug(block.src);
function handleSetSrc(src) { // Le payload reçu peut être :
onChange?.({ src }); // - une string (URL libre saisie / Entrée dans le champ),
// - un objet { src, mediaSlug, mediaAlt, mediaCaption } (issu du picker).
// Quand on lie un média, alt/caption locaux sont nettoyés (mode strict :
// le média est la source de vérité). Pour les URL externes, on garde le
// alt/caption existant et on efface tout snapshot média précédent.
function handleSetSrc(payload) {
const isObj = payload && typeof payload === 'object';
const src = isObj ? payload.src : payload;
const explicitSlug = isObj ? payload.mediaSlug : null;
const slug = explicitSlug ?? extractMediaSlug(src);
const patch = { src: src ?? '', mediaSlug: slug || '' };
if (slug) {
patch.alt = '';
patch.caption = '';
patch._mediaAlt = isObj ? (payload.mediaAlt ?? '') : '';
patch._mediaCaption = isObj ? (payload.mediaCaption ?? '') : '';
} else {
patch._mediaAlt = '';
patch._mediaCaption = '';
}
onChange?.(patch);
setReplacing(false); setReplacing(false);
} }
function handleAltChange(e) { function handleAltChange(e) {
// Sans mediaSlug uniquement : si un média est lié, l'alt vit côté
// médiathèque (édition impossible depuis le bloc).
onChange?.({ alt: e.target.value }); onChange?.({ alt: e.target.value });
} }
@@ -286,7 +349,17 @@ function ImageBlock({ block, onChange, disabled }) {
function handleRemove() { function handleRemove() {
// Vide tous les champs : la prochaine frappe ouvre à nouveau le formulaire. // Vide tous les champs : la prochaine frappe ouvre à nouveau le formulaire.
onChange?.({ src: '', alt: '', caption: '', href: '', newTab: false, align: 'center' }); onChange?.({
src: '',
mediaSlug: '',
alt: '',
caption: '',
href: '',
newTab: false,
align: 'center',
_mediaAlt: '',
_mediaCaption: '',
});
setReplacing(false); setReplacing(false);
} }
@@ -307,6 +380,9 @@ function ImageBlock({ block, onChange, disabled }) {
? 'rounded-lg w-full block' ? 'rounded-lg w-full block'
: 'rounded-lg max-w-full block'; : 'rounded-lg max-w-full block';
const altForRender = resolvedAlt(block);
const captionForRender = resolvedCaption(block);
// Mode lecture seule : si href défini, on wrappe l'image dans un <a>. // Mode lecture seule : si href défini, on wrappe l'image dans un <a>.
// En édition on n'ajoute jamais le <a> — il piégerait les clics dans // En édition on n'ajoute jamais le <a> — il piégerait les clics dans
// l'éditeur. Le href est appliqué via la sérialisation HTML / l'export. // l'éditeur. Le href est appliqué via la sérialisation HTML / l'export.
@@ -314,7 +390,7 @@ function ImageBlock({ block, onChange, disabled }) {
/* eslint-disable-next-line @next/next/no-img-element */ /* eslint-disable-next-line @next/next/no-img-element */
<img <img
src={block.src} src={block.src}
alt={block.alt ?? ''} alt={altForRender}
className={imgClass} className={imgClass}
draggable={false} draggable={false}
/> />
@@ -351,6 +427,36 @@ function ImageBlock({ block, onChange, disabled }) {
</div> </div>
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
{linkedSlug ? (
// Image liée à un média : alt et caption viennent du média. Pas
// d'inputs locaux. En lecture seule on affiche juste la légende.
// En édition on montre un bandeau avec un lien vers la médiathèque.
disabled ? (
captionForRender ? (
<div className="w-full px-1 py-0.5 text-sm italic text-neutral-600 dark:text-neutral-400">
{captionForRender}
</div>
) : null
) : (
<div className="flex flex-col gap-0.5 px-1 py-1 rounded border border-dashed border-neutral-200 dark:border-neutral-700/60 bg-neutral-50/50 dark:bg-neutral-800/30">
<div className="text-sm italic text-neutral-600 dark:text-neutral-400 truncate">
{captionForRender || <span className="not-italic text-neutral-400 dark:text-neutral-500">Aucune légende</span>}
</div>
<div className="text-xs text-neutral-500 dark:text-neutral-500 truncate">
{altForRender || <span className="text-neutral-400 dark:text-neutral-500">Aucun texte alternatif</span>}
</div>
<a
href={`/admin/media?slug=${encodeURIComponent(linkedSlug)}`}
target="_blank"
rel="noopener noreferrer"
className="self-start text-[11px] text-blue-600 dark:text-blue-400 hover:underline"
>
Modifier dans la médiathèque
</a>
</div>
)
) : (
<>
<input <input
type="text" type="text"
placeholder="Légende (optionnelle)" placeholder="Légende (optionnelle)"
@@ -368,6 +474,8 @@ function ImageBlock({ block, onChange, disabled }) {
className="w-full px-1 py-0.5 text-xs text-neutral-500 dark:text-neutral-500 bg-transparent outline-none focus:border-b focus:border-neutral-300 dark:focus:border-neutral-700" className="w-full px-1 py-0.5 text-xs text-neutral-500 dark:text-neutral-500 bg-transparent outline-none focus:border-b focus:border-neutral-300 dark:focus:border-neutral-700"
/> />
)} )}
</>
)}
</div> </div>
</div> </div>
); );
@@ -384,6 +492,7 @@ const Image = {
id: newBlockId(), id: newBlockId(),
type: 'image', type: 'image',
src: '', src: '',
mediaSlug: '',
alt: '', alt: '',
caption: '', caption: '',
align: 'center', align: 'center',
@@ -15,6 +15,16 @@ import { inlineFromText, inlineToPlainText, normalize } from './types.js';
const HEADING_RE = /^heading_([1-6])$/; const HEADING_RE = /^heading_([1-6])$/;
const BLOCK_TAG_RE = /^(P|H[1-6]|UL|OL|BLOCKQUOTE|PRE|HR|FIGURE|DIV|TABLE)$/; const BLOCK_TAG_RE = /^(P|H[1-6]|UL|OL|BLOCKQUOTE|PRE|HR|FIGURE|DIV|TABLE)$/;
const MEDIA_FILE_URL_RE = /^\/zen\/api\/media\/file\/([^/?#]+)$/;
// Quand une image collée pointe sur la médiathèque interne, on dérive le
// slug pour reconstituer le lien `mediaSlug` (un copier-coller ne traverse
// pas les champs cachés). Pour les URL externes, retourne null.
function imageSlugFromSrc(src) {
if (typeof src !== 'string') return null;
const m = MEDIA_FILE_URL_RE.exec(src);
return m ? m[1] : null;
}
// Block[] → HTML string. Regroupe les listes consécutives sous un seul // Block[] → HTML string. Regroupe les listes consécutives sous un seul
// <ul>/<ol>. Les blocs inconnus deviennent un <p> au texte aplati. // <ul>/<ol>. Les blocs inconnus deviennent un <p> au texte aplati.
@@ -98,9 +108,15 @@ function blockToElement(block) {
: align === 'left' ? 'text-align:left' : align === 'left' ? 'text-align:left'
: 'text-align:right'); : 'text-align:right');
} }
// Quand le bloc est lié à un média (`mediaSlug`), alt/caption sont
// résolus côté serveur (`_resolvedAlt` / `_resolvedCaption`) ou
// captés à l'insertion (`_mediaAlt` / `_mediaCaption`). Pour les URL
// externes, on retombe sur les champs locaux du bloc.
const altText = block._resolvedAlt ?? block._mediaAlt ?? block.alt ?? '';
const captionText = block._resolvedCaption ?? block._mediaCaption ?? block.caption ?? '';
const img = document.createElement('img'); const img = document.createElement('img');
img.setAttribute('src', block.src || ''); img.setAttribute('src', block.src || '');
if (block.alt) img.setAttribute('alt', block.alt); if (altText) img.setAttribute('alt', altText);
if (align === 'full') img.setAttribute('width', '100%'); if (align === 'full') img.setAttribute('width', '100%');
let imgHost = img; let imgHost = img;
if (block.href) { if (block.href) {
@@ -114,9 +130,9 @@ function blockToElement(block) {
imgHost = a; imgHost = a;
} }
fig.appendChild(imgHost); fig.appendChild(imgHost);
if (block.caption) { if (captionText) {
const cap = document.createElement('figcaption'); const cap = document.createElement('figcaption');
cap.textContent = block.caption; cap.textContent = captionText;
fig.appendChild(cap); fig.appendChild(cap);
} }
return fig; return fig;
@@ -133,7 +149,11 @@ export function blocksToPlainText(blocks) {
return blocks return blocks
.map(b => { .map(b => {
if (b.type === 'divider') return '---'; if (b.type === 'divider') return '---';
if (b.type === 'image') return b.alt || b.caption || ''; if (b.type === 'image') {
const alt = b._resolvedAlt ?? b._mediaAlt ?? b.alt ?? '';
const cap = b._resolvedCaption ?? b._mediaCaption ?? b.caption ?? '';
return alt || cap || '';
}
return inlineToPlainText(b.content ?? []); return inlineToPlainText(b.content ?? []);
}) })
.join('\n'); .join('\n');
@@ -296,15 +316,21 @@ function parseChildren(node, out) {
const align = ['left', 'center', 'right', 'full'].includes(dataAlign) ? dataAlign : 'center'; const align = ['left', 'center', 'right', 'full'].includes(dataAlign) ? dataAlign : 'center';
const href = linkEl?.getAttribute('href') || ''; const href = linkEl?.getAttribute('href') || '';
const newTab = !!href && linkEl?.getAttribute('target') === '_blank'; const newTab = !!href && linkEl?.getAttribute('target') === '_blank';
const src = img.getAttribute('src') || '';
const slug = imageSlugFromSrc(src);
const altText = img.getAttribute('alt') || '';
const captionText = cap?.textContent?.trim() || '';
out.push({ out.push({
id: newBlockId(), id: newBlockId(),
type: 'image', type: 'image',
src: img.getAttribute('src') || '', src,
alt: img.getAttribute('alt') || '', mediaSlug: slug || '',
caption: cap?.textContent?.trim() || '', alt: slug ? '' : altText,
caption: slug ? '' : captionText,
align, align,
href, href,
newTab, newTab,
...(slug ? { _mediaAlt: altText, _mediaCaption: captionText } : null),
}); });
} }
continue; continue;
@@ -312,15 +338,20 @@ function parseChildren(node, out) {
if (tag === 'IMG') { if (tag === 'IMG') {
flush(); flush();
const src = child.getAttribute('src') || '';
const slug = imageSlugFromSrc(src);
const altText = child.getAttribute('alt') || '';
out.push({ out.push({
id: newBlockId(), id: newBlockId(),
type: 'image', type: 'image',
src: child.getAttribute('src') || '', src,
alt: child.getAttribute('alt') || '', mediaSlug: slug || '',
alt: slug ? '' : altText,
caption: '', caption: '',
align: 'center', align: 'center',
href: '', href: '',
newTab: false, newTab: false,
...(slug ? { _mediaAlt: altText } : null),
}); });
continue; continue;
} }
@@ -0,0 +1,240 @@
/**
* BlockEditor — liaison serveur avec la médiathèque pour les blocs image.
*
* Trois helpers, à appeler depuis les actions/routes serveur des modules
* consommateurs qui hébergent du contenu BlockEditor :
*
* - `normalizeImageBlocks(blocks)` : prépare les blocs pour la persistance.
* Lazy migration des blocs legacy (extraction du slug depuis `src`),
* back-fill de l'alt/caption côté média si manquants, suppression des
* champs `alt`/`caption` du bloc en mode strict (média = source unique).
*
* - `enrichBlocksWithMedia(blocks)` : prépare les blocs pour le rendu.
* Une seule requête batch à `zen_media`, attache `_resolvedAlt` /
* `_resolvedCaption` aux blocs liés. Champs internes (préfixe `_`),
* non persistés.
*
* - `syncBlockImageReferences({ sourceType, sourceId, blocks })` :
* synchronise `zen_media_references` pour la liste courante des médias
* référencés par les blocs image du document. À appeler dans la même
* transaction que le save.
*
* Module **serveur** : importe `@zen/core/database`. Les consommateurs
* client n'ont pas besoin de ces helpers — l'éditeur client persiste les
* blocs tels quels, c'est au moment du save côté serveur qu'on normalise.
*/
import { query } from '@zen/core/database';
const MEDIA_FILE_URL_RE = /^\/zen\/api\/media\/file\/([^/?#]+)$/;
const REFERENCE_FIELD = 'block_image';
function extractSlugFromSrc(src) {
if (typeof src !== 'string') return null;
const m = MEDIA_FILE_URL_RE.exec(src);
return m ? m[1] : null;
}
function collectImageBlocks(blocks) {
const out = [];
if (!Array.isArray(blocks)) return out;
for (const b of blocks) {
if (b && b.type === 'image') out.push(b);
}
return out;
}
function collectSlugs(blocks) {
const slugs = new Set();
for (const b of collectImageBlocks(blocks)) {
const slug = b.mediaSlug || extractSlugFromSrc(b.src);
if (slug) slugs.add(slug);
}
return [...slugs];
}
/**
* Charge `id`, `slug`, `alt_text`, `caption` pour un lot de slugs en une seule
* requête. Retourne une `Map<slug, row>`.
*/
async function fetchMediaBySlugs(slugs) {
if (slugs.length === 0) return new Map();
const result = await query(
`SELECT id, slug, alt_text, caption FROM zen_media WHERE slug = ANY($1::text[])`,
[slugs]
);
const map = new Map();
for (const row of result.rows) map.set(row.slug, row);
return map;
}
/**
* Normalise les blocs image avant persistance.
*
* - Si `mediaSlug` est absent mais que `src` matche `/zen/api/media/file/<slug>`,
* extrait et persiste le slug (lazy upgrade des blocs legacy).
* - Si le bloc est lié à un média existant et que ce média n'a pas encore
* d'`alt_text` / `caption` mais que le bloc en porte (legacy ou saisie
* utilisateur), back-fill côté média (la valeur la plus récente vit
* côté bloc en cas de migration).
* - Quand un `mediaSlug` est résolu (existant en BD), supprime `alt` et
* `caption` du bloc en mode strict.
* - Quand `mediaSlug` est posé mais ne correspond à aucun média en BD
* (média supprimé ou slug invalide), on retire `mediaSlug` et on garde
* `alt`/`caption` côté bloc — l'image devient une URL externe.
* - Les champs internes préfixés `_` (snapshots d'édition) sont toujours
* strippés.
*
* @param {object[]} blocks
* @returns {Promise<object[]>} nouveau tableau de blocs (input non muté)
*/
export async function normalizeImageBlocks(blocks) {
if (!Array.isArray(blocks) || blocks.length === 0) return blocks ?? [];
const slugs = collectSlugs(blocks);
const mediaBySlug = await fetchMediaBySlugs(slugs);
// Back-fill : pour chaque média sans alt/caption où le bloc en porte, on
// remonte la valeur. Une seule UPDATE par champ manquant ; on agrège les
// candidats par slug pour ne back-filler qu'une fois (premier bloc gagne).
const altBackfill = new Map(); // slug → alt_text
const captionBackfill = new Map(); // slug → caption
for (const b of collectImageBlocks(blocks)) {
const slug = b.mediaSlug || extractSlugFromSrc(b.src);
if (!slug) continue;
const media = mediaBySlug.get(slug);
if (!media) continue;
if ((!media.alt_text || media.alt_text === '') && b.alt && !altBackfill.has(slug)) {
altBackfill.set(slug, b.alt);
}
if ((!media.caption || media.caption === '') && b.caption && !captionBackfill.has(slug)) {
captionBackfill.set(slug, b.caption);
}
}
for (const [slug, altText] of altBackfill) {
await query(
`UPDATE zen_media SET alt_text = $1, updated_at = CURRENT_TIMESTAMP
WHERE slug = $2 AND (alt_text IS NULL OR alt_text = '')`,
[altText, slug]
);
const media = mediaBySlug.get(slug);
if (media) media.alt_text = altText;
}
for (const [slug, caption] of captionBackfill) {
await query(
`UPDATE zen_media SET caption = $1, updated_at = CURRENT_TIMESTAMP
WHERE slug = $2 AND (caption IS NULL OR caption = '')`,
[caption, slug]
);
const media = mediaBySlug.get(slug);
if (media) media.caption = caption;
}
return blocks.map(b => {
if (!b || b.type !== 'image') return b;
const slug = b.mediaSlug || extractSlugFromSrc(b.src);
const media = slug ? mediaBySlug.get(slug) : null;
// Champs internes toujours strippés.
const {
_mediaAlt: _ma, _mediaCaption: _mc,
_resolvedAlt: _ra, _resolvedCaption: _rc,
...rest
} = b;
void _ma; void _mc; void _ra; void _rc;
if (media) {
// Mode strict : alt/caption vivent côté média.
return { ...rest, mediaSlug: slug, alt: '', caption: '' };
}
// Pas de média correspondant → URL externe ou média supprimé. On
// nettoie `mediaSlug` pour ne pas créer de référence orpheline.
return { ...rest, mediaSlug: '' };
});
}
/**
* Attache `_resolvedAlt` et `_resolvedCaption` aux blocs image liés à un
* média. Le composant de rendu lit ces champs en priorité (cf. helpers
* `resolvedAlt` / `resolvedCaption` dans `Image.client.js`). Les blocs URL
* libres ne sont pas modifiés.
*
* @param {object[]} blocks
* @returns {Promise<object[]>}
*/
export async function enrichBlocksWithMedia(blocks) {
if (!Array.isArray(blocks) || blocks.length === 0) return blocks ?? [];
const slugs = collectSlugs(blocks);
if (slugs.length === 0) return blocks;
const mediaBySlug = await fetchMediaBySlugs(slugs);
return blocks.map(b => {
if (!b || b.type !== 'image') return b;
const slug = b.mediaSlug || extractSlugFromSrc(b.src);
if (!slug) return b;
const media = mediaBySlug.get(slug);
if (!media) return b;
return {
...b,
_resolvedAlt: media.alt_text ?? '',
_resolvedCaption: media.caption ?? '',
};
});
}
/**
* Synchronise `zen_media_references` pour les blocs image d'un document.
*
* Utilise le `field = 'block_image'` pour distinguer ces références des
* autres usages (`featured_image`, `gallery`, …). À chaque save :
* supprime les anciennes références `block_image` du document, puis
* insère celles correspondant aux blocs courants.
*
* À appeler dans la même transaction que le save du contenu hôte. La
* contrainte FK `ON DELETE RESTRICT` empêche ensuite la suppression d'un
* média référencé via la médiathèque tant que le document existe.
*
* @param {object} args
* @param {string} args.sourceType Ex: 'post', 'page', '@zen/module-shop:product'
* @param {string} args.sourceId ID du document hôte
* @param {object[]} args.blocks Blocs (peuvent être déjà normalisés ou non)
*/
export async function syncBlockImageReferences({ sourceType, sourceId, blocks }) {
if (!sourceType || !sourceId) {
return { success: false, error: 'sourceType and sourceId are required' };
}
const slugs = collectSlugs(blocks);
// Résoudre les slugs en `media_id` (la table de jointure indexe l'id, pas le slug).
const mediaBySlug = await fetchMediaBySlugs(slugs);
const mediaIds = [...new Set(slugs.map(s => mediaBySlug.get(s)?.id).filter(Boolean))];
// Supprime toutes les anciennes références block_image du document. Cette
// approche « replace » est plus simple qu'un diff et reste efficace : le
// nombre d'images par document est petit, et la clé primaire compose
// déjà (media_id, source_type, source_id, field).
await query(
`DELETE FROM zen_media_references
WHERE source_type = $1 AND source_id = $2 AND field = $3`,
[sourceType, sourceId, REFERENCE_FIELD]
);
if (mediaIds.length > 0) {
// Insère en une seule requête multi-rows.
const values = [];
const params = [sourceType, sourceId, REFERENCE_FIELD];
for (const mediaId of mediaIds) {
params.push(mediaId);
values.push(`($${params.length}, $1, $2, $3)`);
}
await query(
`INSERT INTO zen_media_references (media_id, source_type, source_id, field)
VALUES ${values.join(', ')}
ON CONFLICT DO NOTHING`,
params
);
}
return { success: true, count: mediaIds.length };
}