diff --git a/package.json b/package.json index 8ae2aa7..888d31d 100644 --- a/package.json +++ b/package.json @@ -151,6 +151,9 @@ "./shared/components": { "import": "./dist/shared/components/index.js" }, + "./shared/components/BlockEditor/mediaLink": { + "import": "./dist/shared/components/BlockEditor/mediaLink.server.js" + }, "./shared/icons": { "import": "./dist/shared/icons/index.js" }, diff --git a/src/features/media/README.md b/src/features/media/README.md index 4428aef..e5fcee7 100644 --- a/src/features/media/README.md +++ b/src/features/media/README.md @@ -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. +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 Tous les uploads sont stockés sous `media///` 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. diff --git a/src/shared/components/BlockEditor/README.md b/src/shared/components/BlockEditor/README.md index 259a0af..6ea9b0e 100644 --- a/src/shared/components/BlockEditor/README.md +++ b/src/shared/components/BlockEditor/README.md @@ -30,7 +30,7 @@ Chaque bloc a un `id` (UUID) et un `type`. Selon le type : | `quote` | `content` | citation | | `code` | `content` | bloc de code (monospace) | | `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. @@ -291,10 +291,75 @@ Le formulaire d'insertion accepte deux sources : - **URL externe** : champ libre + bouton « Insérer ». - **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 - `block.src = "/zen/api/media/file/"`. Les nouveaux uploads passent par - ce picker en visibilité `public` (les blocs image vivent dans du contenu - publié — un média privé serait illisible côté frontal). + de `@zen/core/features/media`. À la sélection, le bloc reçoit + `block.src = "/zen/api/media/file/"` **et** `block.mediaSlug = ""`. + Les nouveaux uploads passent par ce picker en visibilité `public` (les blocs + 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 ; +``` + +`normalizeImageBlocks` : +- Lazy upgrade : extrait `mediaSlug` depuis `src` quand l'URL matche + `/zen/api/media/file/`. +- 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** (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. Les inputs *Légende* et *Texte alternatif* sous l'image sont des `` -HTML standards. Le clic dans ces inputs ne déclenche pas la sélection -multi-blocs (cf. garde dans `handleContainerMouseDown` : tout `input`, -`textarea`, `select` ou `[contenteditable="true"]` reçoit son focus normal -et n'entre pas en mode sélection). +HTML standards (uniquement pour les images sans `mediaSlug` — cf. +[Liaison avec la médiathèque](#liaison-avec-la-médiathèque-mediaslug)). +Le clic dans ces inputs ne déclenche pas la sélection multi-blocs (cf. garde +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 : `
`. Le parser inverse (`htmlToBlocks`) lit `data-align`, et descend dans `` -quand l'`` y est imbriqué pour récupérer `href` / `target`. +quand l'`` y est imbriqué pour récupérer `href` / `target`. Si le `src` +de l'image matche `/zen/api/media/file/`, il reconstitue `mediaSlug` +pour préserver la liaison média lors d'un copier-coller interne. ## Limitations connues diff --git a/src/shared/components/BlockEditor/blockTypes/Image.client.js b/src/shared/components/BlockEditor/blockTypes/Image.client.js index c2c2af4..b34c987 100644 --- a/src/shared/components/BlockEditor/blockTypes/Image.client.js +++ b/src/shared/components/BlockEditor/blockTypes/Image.client.js @@ -23,9 +23,42 @@ import { newBlockId } from '../utils/ids.js'; import MediaPicker from '../../../../features/media/components/MediaPicker.client.js'; // Bloc image. État vide = formulaire d'insertion d'URL. État rempli = image -// rendue + toolbar flottante (alignement, lien, remplacer, supprimer) + -// inputs caption / alt en dessous. Champs persistés : `src`, `alt`, -// `caption`, `align` (`'left' | 'center' | 'right' | 'full'`), `href`. +// rendue + toolbar flottante (alignement, lien, remplacer, supprimer). +// +// Champs persistés : +// - `src` : URL (interne `/zen/api/media/file/` 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 = [ { value: 'left', label: 'Aligner à gauche', Icon: AlignLeftIcon }, @@ -114,7 +147,14 @@ function ImageUrlForm({ initialUrl, onSubmit, onCancel, disabled }) { isOpen={pickerOpen} onClose={() => setPickerOpen(false)} 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/*" visibility="any" @@ -270,13 +310,36 @@ function ImageBlock({ block, onChange, disabled }) { // `block.src` est non vide. const [replacing, setReplacing] = useState(false); const showForm = !block.src || replacing; + const linkedSlug = block.mediaSlug || extractMediaSlug(block.src); - function handleSetSrc(src) { - onChange?.({ src }); + // Le payload reçu peut être : + // - 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); } 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 }); } @@ -286,7 +349,17 @@ function ImageBlock({ block, onChange, disabled }) { function handleRemove() { // 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); } @@ -307,6 +380,9 @@ function ImageBlock({ block, onChange, disabled }) { ? 'rounded-lg 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 . // En édition on n'ajoute jamais le — il piégerait les clics dans // 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 */ {block.alt @@ -351,22 +427,54 @@ function ImageBlock({ block, onChange, disabled }) { @@ -384,6 +492,7 @@ const Image = { id: newBlockId(), type: 'image', src: '', + mediaSlug: '', alt: '', caption: '', align: 'center', diff --git a/src/shared/components/BlockEditor/inline/clipboard.js b/src/shared/components/BlockEditor/inline/clipboard.js index 0c826a6..42889c4 100644 --- a/src/shared/components/BlockEditor/inline/clipboard.js +++ b/src/shared/components/BlockEditor/inline/clipboard.js @@ -15,6 +15,16 @@ import { inlineFromText, inlineToPlainText, normalize } from './types.js'; const HEADING_RE = /^heading_([1-6])$/; 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 //
    /
      . Les blocs inconnus deviennent un

      au texte aplati. @@ -98,9 +108,15 @@ function blockToElement(block) { : align === 'left' ? 'text-align:left' : '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'); 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%'); let imgHost = img; if (block.href) { @@ -114,9 +130,9 @@ function blockToElement(block) { imgHost = a; } fig.appendChild(imgHost); - if (block.caption) { + if (captionText) { const cap = document.createElement('figcaption'); - cap.textContent = block.caption; + cap.textContent = captionText; fig.appendChild(cap); } return fig; @@ -133,7 +149,11 @@ export function blocksToPlainText(blocks) { return blocks .map(b => { 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 ?? []); }) .join('\n'); @@ -296,15 +316,21 @@ function parseChildren(node, out) { const align = ['left', 'center', 'right', 'full'].includes(dataAlign) ? dataAlign : 'center'; const href = linkEl?.getAttribute('href') || ''; 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({ id: newBlockId(), type: 'image', - src: img.getAttribute('src') || '', - alt: img.getAttribute('alt') || '', - caption: cap?.textContent?.trim() || '', + src, + mediaSlug: slug || '', + alt: slug ? '' : altText, + caption: slug ? '' : captionText, align, href, newTab, + ...(slug ? { _mediaAlt: altText, _mediaCaption: captionText } : null), }); } continue; @@ -312,15 +338,20 @@ function parseChildren(node, out) { if (tag === 'IMG') { flush(); + const src = child.getAttribute('src') || ''; + const slug = imageSlugFromSrc(src); + const altText = child.getAttribute('alt') || ''; out.push({ id: newBlockId(), type: 'image', - src: child.getAttribute('src') || '', - alt: child.getAttribute('alt') || '', + src, + mediaSlug: slug || '', + alt: slug ? '' : altText, caption: '', align: 'center', href: '', newTab: false, + ...(slug ? { _mediaAlt: altText } : null), }); continue; } diff --git a/src/shared/components/BlockEditor/mediaLink.server.js b/src/shared/components/BlockEditor/mediaLink.server.js new file mode 100644 index 0000000..22b67a5 --- /dev/null +++ b/src/shared/components/BlockEditor/mediaLink.server.js @@ -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`. + */ +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/`, + * 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} 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} + */ +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 }; +}