Compare commits

..

3 Commits

Author SHA1 Message Date
517eed915d opti2 2026-04-28 14:10:05 +02:00
d423372251 extract_picture 2026-04-28 12:22:24 +02:00
13b4b6971c begin_opti 2026-04-28 10:33:07 +02:00
31 changed files with 2149 additions and 184 deletions

3
.gitignore vendored
View File

@ -55,3 +55,6 @@ llm-api/*.pyc
# Legacy RAG index (ChromaDB) — obsolete depuis bascule graph+BM25 # Legacy RAG index (ChromaDB) — obsolete depuis bascule graph+BM25
/chroma-index/ /chroma-index/
# Téléchargements + WebP générés par strapi_extraction/media-sync/ (poids important)
strapi_extraction/extract/media-sync-work/

View File

@ -2,8 +2,10 @@
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { getApiUrl } from "../../utils/getApiUrl"; import { getApiUrl } from "../../utils/getApiUrl";
import { pickStrapiImage, type StrapiMediaLike } from "../../utils/strapiImage";
import VignetteCarousel from "../../components/VignetteCarousel"; import VignetteCarousel from "../../components/VignetteCarousel";
import ContentSectionCompetencesContainer from "../../components/ContentSectionCompetencesContainer"; import ContentSectionCompetencesContainer from "../../components/ContentSectionCompetencesContainer";
@ -38,7 +40,7 @@ type Realisation = {
description?: string; description?: string;
link?: string; link?: string;
order?: number; order?: number;
picture?: Array<{ url?: string; name?: string }>; picture?: Array<StrapiMediaLike & { name?: string }>;
}; };
type Competence = { type Competence = {
@ -200,10 +202,17 @@ export default function CompetencePage() {
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 md:grid-cols-6"> <div className="grid grid-cols-1 gap-5 sm:grid-cols-2 md:grid-cols-6">
{realisations.map((realisation, idx) => { {realisations.map((realisation, idx) => {
const pictures = realisation.picture ?? []; const pictures = realisation.picture ?? [];
const images = pictures.map((img) => ({ const images = pictures
url: img?.url ? `${apiUrl}${img.url}` : "/placeholder.jpg", .map((img) => {
alt: img?.name || `Visuel de la réalisation ${realisation.name}`, const picked = pickStrapiImage(apiUrl, img, "card");
})); const url = picked?.src ?? (img?.url ? `${apiUrl}${img.url}` : null);
if (!url) return null;
return {
url,
alt: img?.name || `Visuel de la réalisation ${realisation.name}`,
};
})
.filter(Boolean);
const firstImage = images[0]; const firstImage = images[0];
// Comportement voulu : la vignette renvoie TOUJOURS vers la fiche // Comportement voulu : la vignette renvoie TOUJOURS vers la fiche
@ -226,11 +235,12 @@ export default function CompetencePage() {
{images.length > 1 ? ( {images.length > 1 ? (
<VignetteCarousel images={images} /> <VignetteCarousel images={images} />
) : firstImage ? ( ) : firstImage ? (
<img <Image
src={firstImage.url} src={firstImage.url}
alt={firstImage.alt} alt={firstImage.alt}
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-[1.03]" fill
loading="lazy" className="object-cover transition-transform duration-500 group-hover:scale-[1.03]"
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 42vw"
/> />
) : ( ) : (
<div className="flex h-full w-full items-center justify-center text-sm text-on-surface-variant"> <div className="flex h-full w-full items-center justify-center text-sm text-on-surface-variant">

View File

@ -2,7 +2,9 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import Image from "next/image";
import { getApiUrl } from "../utils/getApiUrl"; import { getApiUrl } from "../utils/getApiUrl";
import { pickStrapiImage } from "../utils/strapiImage";
import VignetteCarousel from "../components/VignetteCarousel"; import VignetteCarousel from "../components/VignetteCarousel";
import "../globals.css"; import "../globals.css";
import "../assets/main.css"; import "../assets/main.css";
@ -108,10 +110,18 @@ export default function Page() {
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 md:grid-cols-6"> <div className="grid grid-cols-1 gap-5 sm:grid-cols-2 md:grid-cols-6">
{competences.map((competence, idx) => { {competences.map((competence, idx) => {
const pictures = competence.picture ?? []; const pictures = competence.picture ?? [];
const images = pictures.map((img) => ({ const images = pictures
url: img.url ? `${apiUrl}${img.url}` : "/placeholder.jpg", .map((img) => {
alt: img.name || `Visuel de la compétence ${competence.name}`, const picked = pickStrapiImage(apiUrl, img, "card");
})); const url =
picked?.src ?? (img.url ? `${apiUrl}${img.url}` : null);
if (!url) return null;
return {
url,
alt: img.name || `Visuel de la compétence ${competence.name}`,
};
})
.filter(Boolean);
const firstImage = images[0]; const firstImage = images[0];
return ( return (
@ -124,11 +134,12 @@ export default function Page() {
{images.length > 1 ? ( {images.length > 1 ? (
<VignetteCarousel images={images} /> <VignetteCarousel images={images} />
) : firstImage ? ( ) : firstImage ? (
<img <Image
src={firstImage.url} src={firstImage.url}
alt={firstImage.alt} alt={firstImage.alt}
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-[1.03]" fill
loading="lazy" className="object-cover transition-transform duration-500 group-hover:scale-[1.03]"
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 42vw"
/> />
) : ( ) : (
<div className="flex h-full w-full items-center justify-center text-sm text-on-surface-variant"> <div className="flex h-full w-full items-center justify-center text-sm text-on-surface-variant">

View File

@ -1,5 +1,6 @@
"use client"; "use client";
import Image from "next/image";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { Swiper, SwiperSlide } from "swiper/react"; import { Swiper, SwiperSlide } from "swiper/react";
@ -77,13 +78,17 @@ export default function Carousel({ images, className }: CarouselProps) {
} }
> >
{images.map((img, index) => ( {images.map((img, index) => (
<SwiperSlide key={index} className="flex h-full items-center justify-center"> <SwiperSlide
<img key={index}
className="relative flex h-full min-h-0 w-full items-center justify-center"
>
<Image
src={img.url} src={img.url}
alt={img.alt} alt={img.alt}
className="h-full w-full cursor-zoom-in object-cover transition-transform duration-300 hover:scale-[1.02]" fill
className="cursor-zoom-in object-cover transition-transform duration-300 hover:scale-[1.02]"
sizes="(max-width: 768px) 100vw, min(48rem, 100vw)"
onClick={() => setSelectedImage(img.url)} onClick={() => setSelectedImage(img.url)}
loading="lazy"
/> />
</SwiperSlide> </SwiperSlide>
))} ))}

View File

@ -1,5 +1,6 @@
"use client"; "use client";
import Image from "next/image";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { Swiper, SwiperSlide } from "swiper/react"; import { Swiper, SwiperSlide } from "swiper/react";
@ -65,13 +66,17 @@ export default function CarouselCompetences({ images, className }: CarouselProps
} }
> >
{images.map((img, index) => ( {images.map((img, index) => (
<SwiperSlide key={index} className="flex h-full items-center justify-center"> <SwiperSlide
<img key={index}
className="relative flex h-full min-h-0 w-full items-center justify-center"
>
<Image
src={img.url} src={img.url}
alt={img.alt} alt={img.alt}
className="h-full w-full cursor-zoom-in object-cover transition-transform duration-300 hover:scale-[1.02]" fill
className="cursor-zoom-in object-cover transition-transform duration-300 hover:scale-[1.02]"
sizes="(max-width: 768px) 100vw, min(48rem, 100vw)"
onClick={() => setSelectedImage(img.url)} onClick={() => setSelectedImage(img.url)}
loading="lazy"
/> />
</SwiperSlide> </SwiperSlide>
))} ))}

View File

@ -4,16 +4,12 @@ import Link from "next/link";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { fetchData } from "../utils/fetchData"; import { fetchData } from "../utils/fetchData";
import { getApiUrl } from "../utils/getApiUrl"; import { getApiUrl } from "../utils/getApiUrl";
import { pickStrapiImage, type StrapiMediaLike } from "../utils/strapiImage";
import Carousel from "./Carousel"; import Carousel from "./Carousel";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
interface ImageData { interface ImageData extends StrapiMediaLike {
url: string;
formats?: {
large?: {
url: string;
};
};
name?: string; name?: string;
} }
@ -156,10 +152,21 @@ export default function ContentSection({
const richText = data.Resum ?? data.resum ?? ""; const richText = data.Resum ?? data.resum ?? "";
const images = const images =
picture?.map((img: ImageData) => ({ picture
url: `${apiUrl}${img.formats?.large?.url || img.url}`, ?.map((img: ImageData) => {
alt: img.name || `Visuel du projet ${name}`, const picked = pickStrapiImage(apiUrl, img, "full");
})) || []; const url =
picked?.src ??
(img.url || img.formats?.large?.url
? `${apiUrl}${img.formats?.large?.url ?? img.url}`
: null);
if (!url) return null;
return {
url,
alt: img.name || `Visuel du projet ${name}`,
};
})
.filter((item): item is { url: string; alt: string } => item != null) || [];
return ( return (
<div className="mx-auto flex w-full min-w-0 max-w-3xl flex-col gap-5 px-4 pb-10 sm:px-6"> <div className="mx-auto flex w-full min-w-0 max-w-3xl flex-col gap-5 px-4 pb-10 sm:px-6">
@ -211,7 +218,7 @@ export default function ContentSection({
prose-li:marker:text-primary prose-li:marker:text-primary
prose-hr:border-0 prose-hr:w-16 prose-hr:mx-auto prose-hr:bg-primary/30 prose-hr:h-0.5 prose-hr:rounded-full prose-hr:my-6" prose-hr:border-0 prose-hr:w-16 prose-hr:mx-auto prose-hr:bg-primary/30 prose-hr:h-0.5 prose-hr:rounded-full prose-hr:my-6"
> >
<ReactMarkdown>{richText}</ReactMarkdown> <ReactMarkdown remarkPlugins={[remarkGfm]}>{richText}</ReactMarkdown>
</div> </div>
)} )}

View File

@ -3,16 +3,13 @@
import Link from "next/link"; import Link from "next/link";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { getApiUrl } from "../utils/getApiUrl"; import { getApiUrl } from "../utils/getApiUrl";
import { pickStrapiImage, type StrapiMediaLike } from "../utils/strapiImage";
import CarouselCompetences from "./CarouselCompetences"; import CarouselCompetences from "./CarouselCompetences";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import rehypeRaw from "rehype-raw"; import rehypeRaw from "rehype-raw";
import ModalGlossaire from "./ModalGlossaire"; import ModalGlossaire from "./ModalGlossaire";
interface ImageData { interface ImageData extends StrapiMediaLike {
url: string;
formats?: {
large?: { url: string };
};
name?: string; name?: string;
} }
@ -132,10 +129,21 @@ export default function ContentSectionCompetences({
const { name, content, picture } = competenceData; const { name, content, picture } = competenceData;
const images = const images =
picture?.map((img) => ({ picture
url: `${apiUrl}${img.formats?.large?.url || img.url}`, ?.map((img: ImageData) => {
alt: img.name || `Visuel de la compétence ${name}`, const picked = pickStrapiImage(apiUrl, img, "full");
})) || []; const url =
picked?.src ??
(img.url || img.formats?.large?.url
? `${apiUrl}${img.formats?.large?.url ?? img.url}`
: null);
if (!url) return null;
return {
url,
alt: img.name || `Visuel de la compétence ${name}`,
};
})
.filter((item): item is { url: string; alt: string } => item != null) || [];
/** /**
* Transforme le Markdown en injectant des spans `.glossary-keyword` / `.chatbot-keyword` * Transforme le Markdown en injectant des spans `.glossary-keyword` / `.chatbot-keyword`

View File

@ -4,15 +4,10 @@ import { useEffect } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import CarouselCompetences from "./CarouselCompetences"; import CarouselCompetences from "./CarouselCompetences";
import { getApiUrl } from "../utils/getApiUrl"; import { getApiUrl } from "../utils/getApiUrl";
import { pickStrapiImage, type StrapiMediaLike } from "../utils/strapiImage";
interface ImageData { interface ImageData extends StrapiMediaLike {
url: string;
name?: string; name?: string;
formats?: {
large?: {
url: string;
};
};
} }
interface GlossaireMot { interface GlossaireMot {
@ -43,10 +38,21 @@ export default function ModalGlossaire({ mot, onClose }: ModalGlossaireProps) {
}, [onClose]); }, [onClose]);
const images = const images =
mot.images?.map((img) => ({ mot.images
url: `${apiUrl}${img.formats?.large?.url || img.url}`, ?.map((img: ImageData) => {
alt: img.name || "Illustration", const picked = pickStrapiImage(apiUrl, img, "full");
})) || []; const url =
picked?.src ??
(img.url || img.formats?.large?.url
? `${apiUrl}${img.formats?.large?.url ?? img.url}`
: null);
if (!url) return null;
return {
url,
alt: img.name || "Illustration",
};
})
.filter((item): item is { url: string; alt: string } => item != null) || [];
return createPortal( return createPortal(
<div <div

View File

@ -1,5 +1,6 @@
"use client"; "use client";
import Image from "next/image";
import { Swiper, SwiperSlide } from "swiper/react"; import { Swiper, SwiperSlide } from "swiper/react";
import { Autoplay, Pagination } from "swiper/modules"; import { Autoplay, Pagination } from "swiper/modules";
import "swiper/css"; import "swiper/css";
@ -54,12 +55,16 @@ export default function VignetteCarousel({
} }
> >
{images.map((img, index) => ( {images.map((img, index) => (
<SwiperSlide key={index} className="flex h-full items-center justify-center"> <SwiperSlide
<img key={index}
className="relative flex h-full min-h-0 w-full items-center justify-center"
>
<Image
src={img.url} src={img.url}
alt={img.alt} alt={img.alt}
className="h-full w-full object-cover" fill
loading="lazy" className="object-cover"
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 42vw"
/> />
</SwiperSlide> </SwiperSlide>
))} ))}

View File

@ -89,6 +89,12 @@ export default function RootLayout({ children }: { children: React.ReactNode })
href="https://fonts.gstatic.com" href="https://fonts.gstatic.com"
crossOrigin="" crossOrigin=""
/> />
{/* API Strapi (médias JSON + images) — origine alignée sur NEXT_PUBLIC_API_URL. */}
<link
rel="preconnect"
href={process.env.NEXT_PUBLIC_API_URL || "https://api.fernandgrascalvet.com"}
crossOrigin=""
/>
<link <link
rel="stylesheet" rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap"

View File

@ -1,10 +1,12 @@
"use client"; "use client";
import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import "./assets/main.css"; import "./assets/main.css";
import { getApiUrl } from "./utils/getApiUrl"; import { getApiUrl } from "./utils/getApiUrl";
import { pickStrapiImage } from "./utils/strapiImage";
async function getHomepageData() { async function getHomepageData() {
const apiUrl = getApiUrl(); const apiUrl = getApiUrl();
@ -106,7 +108,12 @@ export default function HomePage() {
const title = homepage.title ?? "Titre par défaut"; const title = homepage.title ?? "Titre par défaut";
const cv: string = homepage.cv ?? ""; const cv: string = homepage.cv ?? "";
const imageUrl = homepage.photo?.url ? `${apiUrl}${homepage.photo.url}` : null; const portraitPick = homepage.photo
? pickStrapiImage(apiUrl, homepage.photo, "hero")
: null;
const imageUrl =
portraitPick?.src ??
(homepage.photo?.url ? `${apiUrl}${homepage.photo.url}` : null);
return ( return (
<div className="mx-auto flex w-full min-w-0 max-w-5xl flex-col gap-3 px-4 pb-10 sm:px-6"> <div className="mx-auto flex w-full min-w-0 max-w-5xl flex-col gap-3 px-4 pb-10 sm:px-6">
@ -120,11 +127,14 @@ export default function HomePage() {
<div className="mx-auto md:mx-0"> <div className="mx-auto md:mx-0">
{imageUrl ? ( {imageUrl ? (
<div className="rounded-sheet bg-primary p-1 shadow-ambient-sm"> <div className="rounded-sheet bg-primary p-1 shadow-ambient-sm">
<div className="overflow-hidden rounded-[1.25rem]"> <div className="relative h-48 w-48 overflow-hidden rounded-[1.25rem] sm:h-56 sm:w-56 md:h-64 md:w-64">
<img <Image
src={imageUrl} src={imageUrl}
alt={`Portrait de ${title}`} alt={`Portrait de ${title}`}
className="h-48 w-48 object-cover object-center sm:h-56 sm:w-56 md:h-64 md:w-64" fill
className="object-cover object-center"
sizes="(max-width: 768px) 12rem, 16rem"
priority
/> />
</div> </div>
</div> </div>

View File

@ -2,7 +2,9 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import Image from "next/image";
import { getApiUrl } from "../utils/getApiUrl"; import { getApiUrl } from "../utils/getApiUrl";
import { pickStrapiImage } from "../utils/strapiImage";
import VignetteCarousel from "../components/VignetteCarousel"; import VignetteCarousel from "../components/VignetteCarousel";
import "../assets/main.css"; import "../assets/main.css";
import "../globals.css"; import "../globals.css";
@ -113,10 +115,16 @@ export default function Page() {
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 md:grid-cols-6"> <div className="grid grid-cols-1 gap-5 sm:grid-cols-2 md:grid-cols-6">
{projects.map((project, idx) => { {projects.map((project, idx) => {
const pictures = project.picture ?? []; const pictures = project.picture ?? [];
const images = pictures.map((img) => ({ const images = pictures
url: `${apiUrl}${img.url}`, .map((img) => {
alt: img.name || `Visuel du projet ${project.name}`, const picked = pickStrapiImage(apiUrl, img, "card");
})); if (!picked) return null;
return {
url: picked.src,
alt: img.name || `Visuel du projet ${project.name}`,
};
})
.filter(Boolean);
const firstImage = images[0]; const firstImage = images[0];
return ( return (
@ -129,11 +137,12 @@ export default function Page() {
{images.length > 1 ? ( {images.length > 1 ? (
<VignetteCarousel images={images} /> <VignetteCarousel images={images} />
) : firstImage ? ( ) : firstImage ? (
<img <Image
src={firstImage.url} src={firstImage.url}
alt={firstImage.alt} alt={firstImage.alt}
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-[1.03]" fill
loading="lazy" className="object-cover transition-transform duration-500 group-hover:scale-[1.03]"
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 42vw"
/> />
) : ( ) : (
<div className="flex h-full w-full items-center justify-center text-sm text-on-surface-variant"> <div className="flex h-full w-full items-center justify-center text-sm text-on-surface-variant">

69
app/utils/strapiImage.ts Normal file
View File

@ -0,0 +1,69 @@
/**
* Sélection d'une variante Strapi (thumbnail / small / medium / large) pour
* limiter le poids transféré sans sacrifier la qualité affichée.
*/
export type StrapiImagePreset = "card" | "thumbnail" | "hero" | "full";
export interface StrapiFormatEntry {
url?: string | null;
width?: number | null;
height?: number | null;
}
export interface StrapiMediaLike {
url?: string | null;
width?: number | null;
height?: number | null;
formats?: {
thumbnail?: StrapiFormatEntry | null;
small?: StrapiFormatEntry | null;
medium?: StrapiFormatEntry | null;
large?: StrapiFormatEntry | null;
} | null;
}
function absUrl(apiUrl: string, path: string | null | undefined): string | null {
if (path == null || path === "") return null;
if (path.startsWith("http://") || path.startsWith("https://")) return path;
return `${apiUrl.replace(/\/$/, "")}${path.startsWith("/") ? "" : "/"}${path}`;
}
/**
* Choisit lURL + dimensions dune image média Strapi selon le contexte daffichage.
* Retourne null si aucune URL exploitable.
*/
export function pickStrapiImage(
apiUrl: string,
media: StrapiMediaLike | null | undefined,
preset: StrapiImagePreset
): { src: string; width: number; height: number } | null {
if (!media?.url) return null;
const f = media.formats;
let chosen: StrapiFormatEntry | undefined;
switch (preset) {
case "thumbnail":
chosen = f?.thumbnail ?? f?.small ?? undefined;
break;
case "card":
chosen = f?.medium ?? f?.small ?? f?.thumbnail ?? undefined;
break;
case "hero":
case "full":
chosen = f?.large ?? f?.medium ?? f?.small ?? undefined;
break;
default:
chosen = f?.medium ?? f?.small;
}
const relPath = chosen?.url ?? media.url;
const src = absUrl(apiUrl, relPath);
if (!src) return null;
const width = Number(chosen?.width ?? media.width) || 800;
const height = Number(chosen?.height ?? media.height) || 600;
return { src, width, height };
}

1
delete Normal file
View File

@ -0,0 +1 @@
LiteLLMProxy

View File

@ -1,6 +1,6 @@
# Frontend Next.js # Frontend Next.js
**Dernière mise à jour :** 2026-04-24 **Dernière mise à jour :** 2026-04-28
## Stack ## Stack
@ -43,7 +43,9 @@
## Configuration Next ## Configuration Next
- `next.config.ts` : `rewrites` de `/api/:path*` vers `${API_URL}/api/:path*``API_URL` vient de `NEXT_PUBLIC_API_URL` ou défaut production. - `next.config.ts` : `rewrites` de `/api/:path*` vers `${API_URL}/api/:path*``API_URL` vient de `NEXT_PUBLIC_API_URL` ou défaut production.
- `images.domains` : `localhost`, `api.fernandgrascalvet.com`. - **`next/image`** : `images.remotePatterns` vers les chemins **`/uploads/**`** de lAPI Strapi (HTTPS prod + `localhost` / `127.0.0.1:1337` en dev) ; `formats` AVIF/WebP ; **`compress: false`** (compatibilité reverse proxy IIS — voir [`09-performances-images.md`](./09-performances-images.md) §4.1).
- **Médias Strapi** : utilitaire **`pickStrapiImage`** (`app/utils/strapiImage.ts`) pour préférer les variantes `medium` / `large` selon le contexte (liste, hero, galerie).
- Plan **Server Components** (données Strapi), non implémenté : [`10-plan-server-components.md`](./10-plan-server-components.md).
## Composants notables ## Composants notables
@ -59,6 +61,7 @@
app/layout.tsx app/layout.tsx
app/page.tsx app/page.tsx
app/utils/getApiUrl.ts app/utils/getApiUrl.ts
app/utils/strapiImage.ts
app/utils/fetchData.ts app/utils/fetchData.ts
app/api/contact/route.ts app/api/contact/route.ts
next.config.ts next.config.ts

View File

@ -1,6 +1,6 @@
# Outils `strapi_extraction/` # Outils `strapi_extraction/`
**Dernière mise à jour :** 2026-04-22 **Dernière mise à jour :** 2026-04-28
Dossier de **scripts Node + Python** pour extraire, nettoyer et convertir les Dossier de **scripts Node + Python** pour extraire, nettoyer et convertir les
données issues de l'API Strapi en base de connaissance chatbot (hors runtime données issues de l'API Strapi en base de connaissance chatbot (hors runtime
@ -84,8 +84,18 @@ comme source de vérité sans comparer au CMS.
Ces points seront corrigés en même temps que l'enrichissement du vault Ces points seront corrigés en même temps que l'enrichissement du vault
(glossaire + homepage Strapi → notes `40-Glossaire/` et `30-Parcours/`). (glossaire + homepage Strapi → notes `40-Glossaire/` et `30-Parcours/`).
## Sync médias WebP (hors pipeline GrasBot)
Dossier **`strapi_extraction/media-sync/`** — inventaire des fichiers image liés aux
content-types (`projects`, `competences`, `homepages`, `realisation-ias`, `glossaires`),
téléchargement classé par rubrique, conversion WebP (sharp), puis ré-upload optionnel.
Documentation : voir `strapi_extraction/media-sync/README.md`.
Sortie lourde (ignorée Git) : `strapi_extraction/extract/media-sync-work/`.
## Liens complémentaires ## Liens complémentaires
- Vault + retrieval : [`08-vault-obsidian-retrieval.md`](./08-vault-obsidian-retrieval.md) - Vault + retrieval : [`08-vault-obsidian-retrieval.md`](./08-vault-obsidian-retrieval.md)
- API LLM : [`04-api-llm-et-chatbot.md`](./04-api-llm-et-chatbot.md) - API LLM : [`04-api-llm-et-chatbot.md`](./04-api-llm-et-chatbot.md)
- Schémas Strapi : [`03-cms-strapi.md`](./03-cms-strapi.md) - Schémas Strapi : [`03-cms-strapi.md`](./03-cms-strapi.md)
- Performances images (audit) : [`09-performances-images.md`](./09-performances-images.md)

View File

@ -0,0 +1,363 @@
# Audit performances images & dev mode
**Dernière mise à jour :** 2026-04-28 (révision compression IIS/Next + lien plan SC)
**Statut :** lots **A, B, C** et socle du lot **F** réalisés dans le code Next ; inventaire médias (Lot **D**) et autres lots **non** faits sauf mention.
> Document hybridé : conserve laudit historique (§2 inventaire médias inchangé
> tant quon na pas re-mesuré `cmsbackend/public/uploads/`). Les §34 reflètent
> limplémentation actuelle après litération perf front.
## 1. Contexte du problème
Sur certains navigateurs (notamment ceux qui ne sont pas Chromium ou en
connexion limitée), les pages `portfolio/`, `competences/` et la home mettent
plusieurs secondes à afficher leurs visuels. L'hypothèse de départ était :
- **« les images devraient déjà être en WebP »** ;
- **« Strapi en dev ralentit la livraison des images »** ;
- **« Next + Strapi tournent en `dev`, ce qui dégrade les perfs »**.
Le diagnostic ci-dessous valide partiellement ces hypothèses et en révèle
d'autres, plus structurelles, qui pèsent davantage que le mode `dev`.
## 2. Inventaire des médias Strapi (mesure)
Mesures faites sur `cmsbackend/public/uploads/` (28/04/2026).
### 2.1 Vue globale
| Catégorie | Fichiers | Poids |
|---|---:|---:|
| **Total uploads** | 2 603 | **1 034,6 MB** |
| Originaux (sans variantes responsive) | 523 | 569,6 MB |
| Variantes responsive Strapi (`thumbnail_/small_/medium_/large_`) | 2 079 | 465,0 MB |
### 2.2 Originaux par format
| Extension | Fichiers | Poids | Statut |
|---|---:|---:|---|
| `.webp` | 252 | 92,5 MB | ✅ converti |
| `.jpg` | 59 | 39,4 MB | ⚠️ à convertir |
| `.png` | 212 | **437,6 MB** | 🔴 à convertir en priorité |
**190 PNG dépassent 1 MB** (424 MB cumulés), avec un top 10 entre 3 et 4 MB
par fichier (ex. `vase_*.png` à 4,25 MB, illustrations Midjourney à 3,5 MB).
> **Constat n° 1 :** l'hypothèse « tout est déjà en WebP » est fausse. **271
> originaux non-WebP** représentent **84 %** du poids des originaux. La
> conversion ciblée des PNG > 1 MB suffirait à diviser le total par 3 ou 4.
### 2.3 Variantes responsive Strapi
| Variante | Fichiers | Poids |
|---|---:|---:|
| `large_*` | 518 | 242,9 MB |
| `medium_*` | 518 | 139,6 MB |
| `small_*` | 520 | 65,7 MB |
| `thumbnail_*` | 523 | 16,9 MB |
Strapi génère bien ces variantes — mais **dans le format de l'original**. Donc
un `.png` de 4 MB produit un `large_…png` de ~1 MB, alors qu'une version
WebP serait à ~150-300 KB pour la même qualité perçue.
## 3. Comment les images sont consommées par Next (état courant)
### 3.1 Utilitaire `pickStrapiImage` (`app/utils/strapiImage.ts`)
Toute lecture dun média Strapi passant par le front doit préférer une **variante**
à loriginal :
| Preset | Usage typique | Ordre de préférence |
|--------|----------------|---------------------|
| `card` | Grilles liste portfolio / compétences / vignettes `realisation-ia` | `medium``small``thumbnail` → original |
| `hero` | Portrait hero home | `large``medium``small` → original |
| `full` | Galeries fiche projet, compétence, glossaire (carousel détail) | `large``medium``small` → original |
Les pages et composants listés au §3.2 appellent cet utilitaire puis
construisent une URL absolue avec `getApiUrl()`. Si Strapi ne renvoie pas de
bloc `formats` (upload incomplet ou vieux contenu), on retombe sur
`formats.large ?? url` comme avant pour les zones **déjà** codées ainsi.
### 3.2 `next/image` + `sizes`
**Import** `next/image` utilisé pour les flux suivants :
- **`app/page.tsx`** — portrait hero : `fill` dans un bloc dimensionné +
**`priority`** (LCP).
- **`app/portfolio/page.jsx`**, **`app/competences/page.jsx`**,
**`app/competences/[slug]/page.tsx`** — vignettes : `fill` +
`sizes` adaptatif.
- **`VignetteCarousel.tsx`**, **`Carousel.tsx`**, **`CarouselCompetences.tsx`**
— slides Swiper en `fill` avec `sizes` ; la **lightbox** reste une **`<img>`**
native (zoom plein cadre sans contraintes de dimensions Next).
**Non couvert dans ce lot** : `placeholder="blur"` avec `blurDataURL` dérivé
de `formats.thumbnail` (restait dans le périmètre « idéal » du lot F
historique).
### 3.3 Configuration `next.config.ts`
- **`images.remotePatterns`** vers `uploads/**` pour
**`https://api.fernandgrascalvet.com`**, **`http://localhost:1337`** et
**`http://127.0.0.1:1337`** (aligné sur `getApiUrl()` en dev).
- **`images.formats`** : `image/avif`, `image/webp`.
- Ancienne clé **`images.domains`** : retirée (dépréciée depuis Next 14).
Référence code : fichier `next.config.ts` à la racine du dépôt Next.
---
### Ancien diagnostic (archivé pour mémoire)
Avant cette itération, les listes chargeaient surtout **`img.url`** (original)
sans variantes Strapi ni `next/image`. Les métriques de linventaire §2 restent
utiles tant que les **originaux** côté disque sont lourds (PNG) : même avec des
variantes bien choisies, Strapi régénère souvent ces variantes **dans le même
format** que loriginal — une conversion fichier (Lot **E**) reste pertinente
pour réduire le stockage total.
### 3.4 Toutes les pages sont `"use client"` + `useEffect`-fetch
`app/page.tsx`, `app/portfolio/page.jsx`, `app/competences/page.jsx`,
`app/competences/[slug]/page.tsx` : toutes commencent par `"use client"` et
font `fetch()` côté navigateur dans `useEffect`. Conséquences :
1. **First paint sans données** → on voit toujours le spinner ou les
squelettes, même quand Strapi répond vite. Le ressenti « ça rame » vient
en partie de là, indépendamment du poids des images.
2. Le HTML initial **ne contient aucune `<img>`** → le navigateur ne peut pas
préfetcher les visuels pendant le parsing HTML.
3. Pas de cache Next (`fetch` côté client = cache navigateur uniquement,
pas le data cache de Next).
> **Constat n° 3 :** migrer ces pages en **Server Components** (fetch dans le
> composant async + `revalidate: 60`) résoudrait à la fois le ressenti de
> lenteur **et** la latence images, puisque les `<img>` seraient dans le HTML
> initial — le navigateur lance les requêtes images en parallèle du JS. Plan détaillé :
> [`10-plan-server-components.md`](./10-plan-server-components.md).
## 4. Mode `dev` : impact réel
L'utilisateur souhaite **rester en dev pour le moment** — c'est noté. Voici
ce que ça coûte vraiment, du plus marquant au moins marquant.
### 4.1 Compression HTTP (Next) et reverse proxy IIS
**Décision effective (diagnostic avril 2026, exposition HTTPS derrière IIS) :** **`compress: false`**
dans `next.config.ts` (état actuel du dépôt).
- Un passage à **`compress: true`** a provoqué des **HTTP 500** côté
**navigateur** alors que Next loguait **`GET / 200`** : la réponse **gzip**
générée par Next n'était **pas** correctement gérée par la chaîne
**IIS + URL Rewrite + ARR** vers `http://localhost:3000` (buffer / en-têtes /
double traitement).
- La **compression dynamique IIS**, une fois son module installé, **na pas été
retenue** comme substitution fiable sur ce périmètre non plus tant que la combinaison
tunnel + Next na pas été retestée de façon isolée.
> Tant que le site est exposé **derrière IIS de cette façon**, ne pas réactiver
> **`compress: true`** côté Next sans **test** sur **`https://fernandgrascalvet.com`**.
> Le léger surplus de transfert brut **localhost ⇄ IIS** est acceptable en dev/serveur.
### 4.1b IIS — compression (référence)
- **Compression statique** : peut rester active sur les sites IIS (fichiers
servis directement par IIS) ; hors scope du corps HTML proxifié vers Next.
- **Compression dynamique** : module séparé (rôle serveur Web) ; **ne remplace pas**
le problème **`Content-Encoding`** venant de Next si un jour on réactive gzip côté app.
Le mode `next dev` reste sans minification agressive du JS (voir §4.2).
### 4.2 `next dev --turbopack`
Coût : pas de minification, pas de tree-shaking, sourcemaps, modules
instrumentés HMR. **Impact net :** bundle JS ~3-5× plus lourd qu'en prod,
mais ça ne touche pas les images. Visible surtout au premier chargement de
l'app.
### 4.3 `strapi develop`
- watcher tsc + reload admin sur chaque écriture ;
- logs verbeux ;
- pas de cache compilé — chaque requête ré-exécute le pipeline middlewares
complet.
**Impact sur les images :** marginal (sharp redimensionne et met en cache
les variantes au premier upload, pas à chaque requête). Strapi dev est
**lent à démarrer**, pas lent à servir un fichier statique.
### 4.4 Aucun cache HTTP côté Strapi
Le middleware `strapi::public` sert `cmsbackend/public/uploads/` sans
`Cache-Control` long terme. Chaque revisite re-télécharge potentiellement
l'image (selon ETag). C'est aggravé en dev car les builds suivants
invalidant tout. **Voir `cmsbackend/config/middlewares.ts`** :
```19:26:cmsbackend/config/middlewares.ts
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'],
credentials: true,
},
},
'strapi::poweredBy',
'strapi::query',
'strapi::body',
'strapi::session',
'strapi::favicon',
'strapi::public',
];
```
> **Constat n° 4 (révisé encore) :** on garde **`compress: false`** côté Next
> pour éviter les **500 IIS** derrière reverse proxy ; le surplus de transfert HTML/JSON non gzip
> entre IIS et localhost est préféré à une page publique cassée.
> Le reste du mode `dev` reste plus lourd quune **build prod** (bundle JS, pas de data cache
> Next sur les pages entièrement client).
## 5. Autres pistes détectées en cours d'audit
### 5.1 `app/assets/images/` pèse 992 MB dans le repo
```
339 fichiers — 992,4 MB
.png : 137 fichiers — 871,7 MB (médianes ~3-10 MB par fichier)
.webp: 168 fichiers — 73,9 MB
.jpg : 33 fichiers — 46,8 MB
```
Dossier hérité de l'époque où les images étaient bundlées dans le code
Next. Aujourd'hui les pages tirent depuis Strapi, donc **ces PNG sont
probablement morts** mais alourdissent : `git clone`, sauvegardes, indexation
IDE, et potentiellement `next build` s'ils sont importés depuis un fichier
encore référencé. À auditer (`grep` sur les imports) puis purger ou archiver.
### 5.2 Portrait hero et LCP
**Partiellement traité (2026-04-28) :** le portrait utilise **`next/image`**
avec **`priority`** dans `app/page.tsx` (pas de `rel=preload` séparé). Le fetch
home reste **client** (`useEffect`) — le gain LCP complet viendra surtout avec
le lot **G** (Server Components).
### 5.3 Preconnect vers lAPI Strapi
**Fait (2026-04-28) :** `app/layout.tsx` injecte
`<link rel="preconnect" href={process.env.NEXT_PUBLIC_API_URL || URL prod par défaut} crossOrigin="" />`
pour lorigine API (médias + JSON). En local, si `.env` pointe vers
`http://localhost:1337`, le preconnect cible ce host.
### 5.4 Wallpaper OK, alternatives mortes
`app/assets/images/wallpapersite_resultat.webp`**620 KB** — déjà WebP,
correct pour un fond plein écran. Le `wallpapersite.png` à côté pèse
6,8 MB et n'est plus utilisé : à supprimer.
## 6. Plan d'action proposé (du plus rentable au plus structurel)
Tri par ratio gain / effort. À discuter avant exécution.
### Quick wins (≤ 1 h, gros gains)
- [x] **Lot A — Lire les variantes Strapi côté Next.**
Implémenté via **`pickStrapiImage`** (`app/utils/strapiImage.ts`) et branchements
listes + carousels + fiches (voir §3). **À re-mesurer** sur `/portfolio` et
`/competences` (Network → Img) après redémarrage des services.
- [ ] **Lot B — Compression Next (`compress`) — annulé dans cette forme.**
Tentative **`compress: true`** puis **rétablissement à `false`** : conflit avec
reverse proxy IIS (**500** public). Voir §4.1.
- [x] **Lot C — `preconnect` API Strapi.**
Lien dans `app/layout.tsx`, origine pilotée par **`NEXT_PUBLIC_API_URL`**
avec repli sur lAPI de production.
### Lots moyens (1-3 h chacun)
- [ ] **Lot D — Script d'inventaire & conversion WebP.**
Nouveau script `strapi_extraction/audit-images.js` qui :
1. parcourt `cmsbackend/public/uploads/` ;
2. identifie les originaux non-WebP > seuil (1 MB par défaut) ;
3. classe par section (en croisant avec
`extract/raw/projects-raw.json`, `competences-raw.json`,
`homepages-raw.json`) → produit
`strapi_extraction/extract/images-by-section/<section>/<slug>.json` ;
4. exporte un rapport `images-audit.md` (top n par poids, par section,
fichiers orphelins).
**Pas de conversion automatique au premier passage** — juste l'inventaire,
pour que l'utilisateur puisse vérifier la classification avant action.
- [ ] **Lot E — Conversion + ré-upload contrôlé.**
Une fois l'inventaire validé : second script
`strapi_extraction/convert-and-reupload.js` qui :
1. convertit les fichiers ciblés via `sharp` (qualité 80, conserve les
dimensions) → écrit dans `extract/converted/` ;
2. ré-upload via l'API REST Strapi (`POST /api/upload`) avec
`ref`/`refId`/`field` pour rattacher à la bonne entité ;
3. supprime l'ancien original via `DELETE /upload/files/:id` après
vérification.
**Réversibilité :** le script garde les originaux dans `extract/backup/`
jusqu'à validation manuelle.
- [x] **Lot F — Migration `<img>``next/image` (socle).**
Fait pour les flux principaux (listes, carousels, hero) + **`remotePatterns`**
+ **`formats` AVIF/WebP**. **Non fait :** `deviceSizes` explicites (défaut
Next), **`placeholder="blur"`** — laissé en dette optionnelle.
### Lots structurels (½ journée +)
- [ ] **Lot G — Server Components pour `/portfolio`, `/competences` et `/`.**
Convertir les pages en composants async serveur, déplacer le `useEffect`
vers un sous-composant client uniquement pour l'interactivité (carousels,
modal). Bénéfices : HTML initial complet, fetch caché serveur, pas de
spinner au premier paint. Compatible avec Lot F (`next/image priority`).
**Plan rédigé :** [`10-plan-server-components.md`](./10-plan-server-components.md).
- [ ] **Lot H — Plugin Strapi pour conversion WebP à l'upload.**
Configurer `@strapi/provider-upload-local` (ou un plugin custom) pour
forcer la conversion WebP des nouveaux uploads via sharp. Évite la
ré-introduction de PNG bruts à l'avenir. **Hors scope du dev local
immédiat — à faire avant de remettre en prod.**
- [ ] **Lot I — Nettoyage `app/assets/images/`.**
Identifier les fichiers encore importés (`grep` sur les chemins).
Archiver (zip externe) le reste. Gain : ~900 MB sur le repo.
## 7. Métriques avant/après à capturer
À la fin de chaque lot, mesurer :
- Poids total transféré sur `/portfolio` (DevTools → Network → "Img"), en
cache vide et en cache plein ;
- LCP et CLS via Lighthouse en mode mobile/3G simulé ;
- Time-to-interactive sur Firefox + Safari (les navigateurs cibles
signalés comme problématiques).
Stocker les captures dans `docs-site-interne/captures/perf/` avec le
nom `<lot>-<avant|apres>.webp`.
## 8. Suite / dettes
- **Contenu Strapi** : harmonisation des médias et WebP côté CMS faite par lauteur
— linventaire §2 na pas été **re-mesuré** ; relancer un passage sur
`cmsbackend/public/uploads/` si besoin de chiffres à jour.
- **Lot D** — script `strapi_extraction/audit-images.js` : **pas écrit**.
- **Lot E** — conversion + ré-upload : **hors code Next** ; voir pipeline
`strapi_extraction/media-sync` si utilisé pour le remplacement ciblé.
- **Lot G** — Server Components sur `/`, `/portfolio`, `/competences` : **à faire**
(voir [`10-plan-server-components.md`](./10-plan-server-components.md)).
- **Lots H / I** : inchangés (plugin Strapi upload, purge `app/assets/images/`).
Prochaine amélioration doc utile : captures **avant/après** réseau dans
`docs-site-interne/captures/perf/` une fois les services redémarrés et le
parcours manuel validé.
## 9. Liens internes
- Migration Server Components (plan) :
[`10-plan-server-components.md`](./10-plan-server-components.md)
- Pipeline d'extraction Strapi existant :
[`06-strapi-extraction.md`](./06-strapi-extraction.md)
- Architecture globale & ports :
[`01-architecture.md`](./01-architecture.md)
- Doc front Next :
[`02-frontend-next.md`](./02-frontend-next.md)
- État courant du chantier :
[`etat-actuel.md`](./etat-actuel.md)
- Roadmap :
[`feuille-de-route.md`](./feuille-de-route.md)

View File

@ -0,0 +1,116 @@
# Plan — migration vers Server Components (données Strapi)
**Dernière mise à jour :** 2026-04-28
**Statut :** document de planification — **aucun changement de code appliqué** dans ce lot ; mise en œuvre quand vous le déciderez.
**Prérequis lu :** vous restez en **`npm run dev`** (Turbopack) pour le moment ; ce document décrit aussi ce que donne **`next build` / `next start`** en complément.
---
## 1. Objectif
Réduire le pattern actuel: pages marquées **`"use client"`** qui appellent **`fetch()` dans `useEffect`** après hydration.
Vers: **`async`** page (ou composant serveur parent) qui appelle **`fetch`** **côté serveur Next**, avec mise en **`cache`/revalidation** possible, puis rendu HTML **déjà rempli** de contenu texte + balises images.
---
## 2. État actuel (référence doc + code)
| Page / zone | Fichier | Pattern actuel |
|-------------|---------|----------------|
| Accueil | `app/page.tsx` | `"use client"` + `useEffect``getHomepageData()` |
| Liste portfolio | `app/portfolio/page.jsx` | idem → `/api/projects` |
| Liste compétences | `app/competences/page.jsx` | idem → `/api/competences` |
| Compétence (vignettes réalisations) | `app/competences/[slug]/page.tsx` | idem, fetchs multiples |
Conséquences documentées dans [`09-performances-images.md`](./09-performances-images.md) §3.4 : premier paint sans données, pas de data cache Next sur ces flux, images découvertes tard.
---
## 3. Ce que « Server Components » implique (concrètement)
### 3.1 Principe
- Un **Server Component** est un composant **sans** `"use client"` qui peut être **`async`** et utiliser **`await fetch(...)`** directement dans le corps du composant.
- Le rendu sexécute **sur le serveur Node** (processus Next), **une fois par requête** (sauf cache), pas dans le navigateur.
### 3.2 Ce qui reste **obligatoirement** en Client Components
Tout ce qui utilise des **hooks** React hors contexte « serveur » limité : **`useState`**, **`useEffect`**, **`useRef`** pour le menu mobile, événements clavier (**Escape**), **Swiper**, **portal** pour modales, **localStorage**, etc.
**Stratégie classique :** fichier page **sans** `"use client"` (serveur), qui **`await`** les données puis rend:
```txt
<>
<PageHeader /> // peut être serveur
<ListeClientOuMixte /> // sous-arbre `"use client"` uniquement où nécessaire
</>
```
Les **carousels** (`Carousel`, `VignetteCarousel`) restent très probablement en **clients** tant quils dépendent de Swiper avec effets hydratés — on leur **passe en props** les URLs / textes déjà résolus côté serveur.
### 3.3 **Layout root** (`app/layout.tsx`)
Aujourdhui: **`"use client"`** (+ menu burger, état drawer, etc.). **Option A :** garder le layout tel quel et ne migrer **que les pages feuilles** — Next autorise une page serveur même si le layout parent est client (limites: enfants peuvent être serveur sous certaines compositions ; selon Next 1315 il faut vérifier quaucune contrainte n« impose » tout client — en pratique souvent extraction **ServerLayout** léger OU **layouts par segment** `/portfolio/layout.tsx` serveur). **Option B (plus tard) :** scinder en **layout serveur** + **header/footer client** importés dynamiquement ou composants clients enfants.
Cest le point le plus **structurant** ; il sera arbitré au moment de limplémentation (lot par lot).
### 3.4 Appels Strapi : URL
- **Côté serveur Next**, **`getApiUrl()`** sans `window` utilise **`NEXT_PUBLIC_API_URL`** (ou défaut prod). Pour le dev **sur la même machine**, sassurer que cette env pointe soit vers **`http://localhost:1337`**, soit vers lURL publique **si** IIS/Strapi le servent aussi — sinon risque que le SSR appelle **`https://api.fernand…`** depuis le serveur sans route réseau correcte (**variable denv par environnement** recommandée).
- Harmoniser avec **rewrites** existants **`/api/*` → Strapi**: possibilité dutiliser **`fetch(new URL('/api/...', request.url ?? base))`** en SSR si vous préférez passer par Next (même origin).
### 3.5 `fetch` et cache
- **`fetch(url, { next: { revalidate: 60 } })`**: ISR-like, données rafraîchies au plus toutes les **60** secondes selon docs Next 15 (`cache` par défaut a évolué — à relire [`next` doc](https://nextjs.org/docs/app/building-your-application/data-fetching/fetching) au moment du codage).
- Pour du **toujours frais en dev**, **`cache: 'no-store'`** peut rester le comportement désiré jusquà validation.
### 3.6 Mode **`dev`** (Turbopack)
- Les Server Components **fonctionnent** en `next dev` ; le **cache** `fetch` est **moins représentatif** de la prod quavec `next start`.
- Le **gain perçu** (HTML complet, moins de waterfall client) reste **visible** en dev sur le **réseau** / **Elements** (contenu dans le HTML source).
---
## 4. Gains réels attendus
| Dimension | Avant (client fetch) | Après (données en serveur) |
|-----------|----------------------|----------------------------|
| **Premier contenu** | Spinner / squelette jusquà fin de `fetch` + JSON parse côté client | Texte + structure + **images** (`next/image` avec `src` déjà dans le HTML) plus tôt |
| **Waterfall** | HTML minimal → chargement JS → `useEffect` → fetch → re-render | Un aller-retour serveur Strapi lors du SSR Next (possible parallélisation `Promise.all`) |
| **Cache Next** | Aucun data cache pour ces pages | Possibilité **revalidate** / tags plus tard |
| **SEO / réseaux lents** | Contenu peu présent sans exécuter JS | Contenu lisible avec HTML seul (**meilleur** pour indexation ; utile même si votre besoin SEO est modeste) |
| **Lighthouse LCP** | Portrait / hero souvent retardé par la chaîne client | Déjà dans le flux initial SSR (**souvent gain LCP** mesurable après stabilisation)
**Limite honnête :** **`next dev`** conserve un JS **volumeux** (Turbopack, pas minifié comme prod). Le gain **bundle** sera **maximum** après **`next build` + `next start`** (ou équivalent).
---
## 5. Ordre de migration proposé (par risque croissant)
1. **`/portfolio`** (liste seule — une liste, pattern clair).
2. **`/competences`** (liste identique).
3. **`/`** (home — `getHomepageData` + retry ; extraire logique fetch dans `lib/` serveur).
4. **`/competences/[slug]`** (deux modes : vignettes réalisations vs redirect vers container compétences — attention aux branches).
Ensuite: fiches détail **`ContentSection`** / **`fetchData`** peuvent suivre une feuille de route analogue (nombreuses pages déjà partiellement factorisées via `fetchData`).
À chaque étape: **tests manuels** des routes, erreurs réseau Strapi, **images** encore correctes (**`pickStrapiImage`** inchangé côté props).
---
## 6. Hors scope immédiat (rappels)
- **Ne pas** renommer tous les fichiers **`.jsx``.tsx`** en même temps sans besoin ; migrer fichier par fichier.
- **Ne pas** activer **`compress: true`** côté Next tant que derrière **ARR/IIS** le tunnel reste incompatible (voir [`09-performances-images.md`](./09-performances-images.md) §4.6).
- **Évaluation `next build`** : à planifier après stabilisation SSR en dev ou sur une branche.
---
## 7. Liens internes
- Audit perf images & IIS : [`09-performances-images.md`](./09-performances-images.md)
- Front Next synthèse : [`02-frontend-next.md`](./02-frontend-next.md)
- Feuille de route : [`feuille-de-route.md`](./feuille-de-route.md)
- État actuel stack : [`etat-actuel.md`](./etat-actuel.md)

View File

@ -1,6 +1,6 @@
# État actuel du site # État actuel du site
**Dernière mise à jour :** 2026-04-24 (doc compétences / realisation-ia + CONFIGURATION) **Dernière mise à jour :** 2026-04-28 (perf images, compression IIS/Next, plan Server Components)
## Ce qui est en place ## Ce qui est en place
@ -13,6 +13,7 @@
- **Vault de connaissance `vault-grasbot/`** : ~46 notes Markdown, dont 2 fiches projet manuelles (GrasBot, site portfolio) et compétences IA/Web mises à jour (2026-04) — recharger lAPI après déploiement si besoin : `POST /reload-vault` (aliases, answers, priority) — source de vérité du chatbot, régénéré depuis Strapi par `strapi_extraction/build-vault.py`. Inclut une note `bio-fernand` courte (priority 10) dédiée aux questions biographiques et un CV complet complémentaire. - **Vault de connaissance `vault-grasbot/`** : ~46 notes Markdown, dont 2 fiches projet manuelles (GrasBot, site portfolio) et compétences IA/Web mises à jour (2026-04) — recharger lAPI après déploiement si besoin : `POST /reload-vault` (aliases, answers, priority) — source de vérité du chatbot, régénéré depuis Strapi par `strapi_extraction/build-vault.py`. Inclut une note `bio-fernand` courte (priority 10) dédiée aux questions biographiques et un CV complet complémentaire.
- **Observabilité Langfuse** : instance self-hosted `langfuse.fernandgrascalvet.com`, instrumentation Python (`llm-api/observability.py`) traçant chaque requête `/ask` (retrieval / prompt_build / ollama-chat) avec session/user IDs anonymes côté front. Mode no-op automatique si les clés sont absentes. Voir [`langfuse-observability.md`](./langfuse-observability.md). - **Observabilité Langfuse** : instance self-hosted `langfuse.fernandgrascalvet.com`, instrumentation Python (`llm-api/observability.py`) traçant chaque requête `/ask` (retrieval / prompt_build / ollama-chat) avec session/user IDs anonymes côté front. Mode no-op automatique si les clés sont absentes. Voir [`langfuse-observability.md`](./langfuse-observability.md).
- **Scripts** d'extraction et de doc dans `strapi_extraction/`. - **Scripts** d'extraction et de doc dans `strapi_extraction/`.
- **Performances images (front)** : utilitaire **`pickStrapiImage`**, variantes Strapi, **`next/image`** + `remotePatterns`, portrait **`priority`**, **`preconnect`** API dans `layout.tsx`. **`compress: false`** dans `next.config.ts` (obligatoire avec le reverse proxy IIS actuel — **`compress: true`** provoquait des **500** publics). Détail : [`09-performances-images.md`](./09-performances-images.md). Prochaine étape planifiée (sans implémentation engageant pour linstant) : **Server Components** — [`10-plan-server-components.md`](./10-plan-server-components.md).
- Documentation opérationnelle : [`CONFIGURATION_SITE.md`](../CONFIGURATION_SITE.md) à la racine du dépôt (incl. ordre des compétences et routes dédiées, renvoi vers [02-frontend-next.md](./02-frontend-next.md)). - Documentation opérationnelle : [`CONFIGURATION_SITE.md`](../CONFIGURATION_SITE.md) à la racine du dépôt (incl. ordre des compétences et routes dédiées, renvoi vers [02-frontend-next.md](./02-frontend-next.md)).
- **Captures d'écran** de référence (WebP) : [captures/](./captures/) — voir [INDEX.md](./captures/INDEX.md). - **Captures d'écran** de référence (WebP) : [captures/](./captures/) — voir [INDEX.md](./captures/INDEX.md).
- **Décision produit** : une **rubrique homelab / serveur** (souvent évoquée en « phase 3 ») nest **pas retenue** — pas dévolution planifiée sur ce thème ; le parcours public reste portfolio, compétences (dont IA + réalisations) et contact. - **Décision produit** : une **rubrique homelab / serveur** (souvent évoquée en « phase 3 ») nest **pas retenue** — pas dévolution planifiée sur ce thème ; le parcours public reste portfolio, compétences (dont IA + réalisations) et contact.

View File

@ -1,6 +1,6 @@
# Feuille de route # Feuille de route
**Dernière mise à jour :** 2026-04-24 **Dernière mise à jour :** 2026-04-28
Document vivant : ajuster les statuts et dates au fil du travail. Document vivant : ajuster les statuts et dates au fil du travail.
@ -11,7 +11,7 @@ Document vivant : ajuster les statuts et dates au fil du travail.
| R1 | Moderniser lUI (design system, cohérence typo/couleurs) | En cours | Direction "Digital Atelier" inspirée de `stitch_V1/` ; cadrage et plan dans [`REFONTE-VISUELLE.md`](./REFONTE-VISUELLE.md). Étapes 1-7 (tokens + garde-fou + migration typo globale + layout racine + home + listes portfolio/compétences + fiches détail & GrasBot) faites le 2026-04-22. Reste l'étape 8 (contact + footer éditorial). | | R1 | Moderniser lUI (design system, cohérence typo/couleurs) | En cours | Direction "Digital Atelier" inspirée de `stitch_V1/` ; cadrage et plan dans [`REFONTE-VISUELLE.md`](./REFONTE-VISUELLE.md). Étapes 1-7 (tokens + garde-fou + migration typo globale + layout racine + home + listes portfolio/compétences + fiches détail & GrasBot) faites le 2026-04-22. Reste l'étape 8 (contact + footer éditorial). |
| R2 | Homogénéiser TS vs JS dans `app/` | À faire | Migrer progressivement les `.jsx`/`.js` | | R2 | Homogénéiser TS vs JS dans `app/` | À faire | Migrer progressivement les `.jsx`/`.js` |
| R3 | Centraliser config API (Strapi + LLM) via `.env` | À faire | Remplacer URLs en dur où pertinent | | R3 | Centraliser config API (Strapi + LLM) via `.env` | À faire | Remplacer URLs en dur où pertinent |
| R4 | Revoir `layout.tsx` (server vs client, perf SEO) | À faire | Évaluer extraction header/footer | | R4 | Revoir `layout.tsx` (server vs client, perf SEO) | À faire | Lié au plan [`10-plan-server-components.md`](./10-plan-server-components.md) — extraction header/footer client possible |
| R5 | Chatbot GrasBot — retrieval local (Qwen3 + vault Obsidian) | v3 en place | Pipeline **graph + BM25** (`llm-api/search.py`), plus de RAG vectoriel ni de dépendance ChromaDB. Vault `vault-grasbot/` enrichi automatiquement (aliases/answers/priority). Lecture directe depuis `build-vault.py`, plus d'étape d'indexation. Doc : [`08-vault-obsidian-retrieval.md`](./08-vault-obsidian-retrieval.md). Reste : fix `clean-api-data.js` (homepages + glossaires), affichage sources côté front, badge `grounded`, historique conversationnel, streaming. | | R5 | Chatbot GrasBot — retrieval local (Qwen3 + vault Obsidian) | v3 en place | Pipeline **graph + BM25** (`llm-api/search.py`), plus de RAG vectoriel ni de dépendance ChromaDB. Vault `vault-grasbot/` enrichi automatiquement (aliases/answers/priority). Lecture directe depuis `build-vault.py`, plus d'étape d'indexation. Doc : [`08-vault-obsidian-retrieval.md`](./08-vault-obsidian-retrieval.md). Reste : fix `clean-api-data.js` (homepages + glossaires), affichage sources côté front, badge `grounded`, historique conversationnel, streaming. |
## Moyen terme ## Moyen terme
@ -20,7 +20,7 @@ Document vivant : ajuster les statuts et dates au fil du travail.
|----|--------|--------|--------| |----|--------|--------|--------|
| M1 | Tests (e2e ou smoke sur routes critiques) | À faire | | | M1 | Tests (e2e ou smoke sur routes critiques) | À faire | |
| M2 | Accessibilité (navigation, contrastes, focus) | À faire | | | M2 | Accessibilité (navigation, contrastes, focus) | À faire | |
| M3 | Performance (images Next/Image, bundle) | À faire | | | M3 | Performance (images Next/Image, bundle, SSR données) | En cours | Images + `pickStrapiImage` + preconnect : [`09-performances-images.md`](./09-performances-images.md). **`compress`** : maintenu à **`false`** (IIS). **Prochaine étape** : Server Components — plan [`10-plan-server-components.md`](./10-plan-server-components.md) (pas dimplémentation sans go utilisateur). |
## Long terme / idées ## Long terme / idées
@ -59,3 +59,5 @@ Document vivant : ajuster les statuts et dates au fil du travail.
| 2026-04-23 | **GrasBot — tuning pipeline LLM + anti-hallucinations**. Audit des premières traces Langfuse : questions biographiques hallucinées (âge erroné, statut inventé), réponses longues tronquées. Quatre ajustements : (1) `llm-api/search.py` · `generate()``num_ctx=8192` explicite (stoppe la troncature silencieuse du prompt par le défaut Ollama 2048/4096 quand plusieurs notes entières sont injectées), `num_predict` 512 → 1024 (réponses longues complètes), `think: false` top-level (désactive le *thinking mode* de qwen3 qui consommait du budget de sortie). (2) `llm-api/search.py` · `build_prompt()` — troncature conditionnelle des sources rank 2+ via `_truncate_body()` + nouvelles variables `SEARCH_SECONDARY_MAX_CHARS` (1500) / `SEARCH_SECONDARY_KEEP_RATIO` (0.8). Aucune source n'est supprimée, seules celles dont le score est < 0.8 × score(#1) ET dont le body dépasse 1500 chars sont résumées. Loggé dans `prompt_build.metadata.truncation`. (3) Vault nouvelle note `vault-grasbot/30-Parcours/bio-fernand.md` courte et factuelle (priority 10, aliases biographiques courts), canonique pour les questions du type *« qui est Fernand »*. Renvoie vers le CV complet pour le détail. Correction incohérence d'âge dans le CV (46 47 ans dans la section Présentation) qui alimentait les hallucinations. (4) `SYSTEM_PROMPT` nouveau bloc *Règles de fidélité aux sources* : priorité `type=parcours` pour questions bio, interdiction d'inventer des faits factuels, gestion explicite des contradictions, signalement des notes tronquées. **Bascule Langfuse v4 → v3 dans `requirements.txt`** (`langfuse>=3.0,<4`) : le SDK v4 a supprimé `start_as_current_span`, la v3 reste compatible avec l'instrumentation existante. Dépendances Python ajoutées : `langfuse`, `python-dotenv`. Secrets Langfuse déplacés de `.env.local` Next vers `llm-api/.env` (non committé). Doc mise à jour : [`langfuse-observability.md`](./langfuse-observability.md) (nouvelle section *Tuning du pipeline — 2026-04-23*), `CONFIGURATION_SITE.md` (endpoints `/health` + `/reload-vault`), `etat-actuel.md` (42 notes + mention Langfuse). | | 2026-04-23 | **GrasBot — tuning pipeline LLM + anti-hallucinations**. Audit des premières traces Langfuse : questions biographiques hallucinées (âge erroné, statut inventé), réponses longues tronquées. Quatre ajustements : (1) `llm-api/search.py` · `generate()``num_ctx=8192` explicite (stoppe la troncature silencieuse du prompt par le défaut Ollama 2048/4096 quand plusieurs notes entières sont injectées), `num_predict` 512 → 1024 (réponses longues complètes), `think: false` top-level (désactive le *thinking mode* de qwen3 qui consommait du budget de sortie). (2) `llm-api/search.py` · `build_prompt()` — troncature conditionnelle des sources rank 2+ via `_truncate_body()` + nouvelles variables `SEARCH_SECONDARY_MAX_CHARS` (1500) / `SEARCH_SECONDARY_KEEP_RATIO` (0.8). Aucune source n'est supprimée, seules celles dont le score est < 0.8 × score(#1) ET dont le body dépasse 1500 chars sont résumées. Loggé dans `prompt_build.metadata.truncation`. (3) Vault nouvelle note `vault-grasbot/30-Parcours/bio-fernand.md` courte et factuelle (priority 10, aliases biographiques courts), canonique pour les questions du type *« qui est Fernand »*. Renvoie vers le CV complet pour le détail. Correction incohérence d'âge dans le CV (46 47 ans dans la section Présentation) qui alimentait les hallucinations. (4) `SYSTEM_PROMPT` nouveau bloc *Règles de fidélité aux sources* : priorité `type=parcours` pour questions bio, interdiction d'inventer des faits factuels, gestion explicite des contradictions, signalement des notes tronquées. **Bascule Langfuse v4 → v3 dans `requirements.txt`** (`langfuse>=3.0,<4`) : le SDK v4 a supprimé `start_as_current_span`, la v3 reste compatible avec l'instrumentation existante. Dépendances Python ajoutées : `langfuse`, `python-dotenv`. Secrets Langfuse déplacés de `.env.local` Next vers `llm-api/.env` (non committé). Doc mise à jour : [`langfuse-observability.md`](./langfuse-observability.md) (nouvelle section *Tuning du pipeline — 2026-04-23*), `CONFIGURATION_SITE.md` (endpoints `/health` + `/reload-vault`), `etat-actuel.md` (42 notes + mention Langfuse). |
| 2026-04-22 | **GrasBot v3 — bascule RAG vectoriel → retrieval graph + BM25**. Essais d'installation Windows bloqués par `chroma-hnswlib` (compilation C++ requise) et freezes RDP à chaque chargement de `qwen3:8b` + `nomic-embed-text` simultanément. Arbitrage : pour un vault de 40 notes, la RAG vectorielle sur-dimensionne ; on exploite directement la structure Obsidian (frontmatter, wikilinks, MOCs). **Nouveau pipeline** dans `llm-api/search.py` (scoring multi-signaux : aliases / titre-slug / answers / domains / tags / BM25 ; expansion par graphe via `linked`/`related`/wikilinks ; tokenizer FR avec normalisations `c++``cpp`, split `-`/`_`). **Déterministe, traçable (champ `reasons` dans les sources), 50 ms de retrieval**. Scoring calibré sur 12 cas (IA, push-swap, LLMs pluriel, hors-sujet clafoutis → `(aucun)`, etc.). **Dépendances allégées** : fini `chromadb`, `chroma-hnswlib`, `nomic-embed-text`. `requirements.txt` = fastapi + uvicorn + requests + pyyaml uniquement. Fichiers supprimés : `llm-api/rag.py`, `llm-api/index_vault.py`, `chroma-index/` (marqué pour suppression, verrouillé par Cursor au moment du cleanup — sera supprimé au reboot). **Vault enrichi** : `build-vault.py` étendu pour générer automatiquement `aliases` (à partir du slug/titre + `DOMAIN_ALIASES`), `answers` (questions-types adaptées au type de note), `priority` (heuristique CV=10, MOCs=7, compétences=7, projets=5). Note CV curatée (`source: manual`) enrichie manuellement avec 12 aliases et 7 answers. Nouvelle `vault-grasbot/TAXONOMIE.md` qui documente le vocabulaire contrôlé. Réécriture de `vault-grasbot/50-Technique/grasbot-rag.md``grasbot-retrieval.md` (nouveau pipeline), + `architecture-site.md` + `vault-structure.md` + `MOC-Technique.md`. Nouveau endpoint `POST /reload-vault` pour recharger sans redémarrer uvicorn. Documentation interne refaite : [`04-api-llm-et-chatbot.md`](./04-api-llm-et-chatbot.md), [`06-strapi-extraction.md`](./06-strapi-extraction.md), nouveau [`08-vault-obsidian-retrieval.md`](./08-vault-obsidian-retrieval.md) (remplace `08-vault-obsidian-rag.md`). | | 2026-04-22 | **GrasBot v3 — bascule RAG vectoriel → retrieval graph + BM25**. Essais d'installation Windows bloqués par `chroma-hnswlib` (compilation C++ requise) et freezes RDP à chaque chargement de `qwen3:8b` + `nomic-embed-text` simultanément. Arbitrage : pour un vault de 40 notes, la RAG vectorielle sur-dimensionne ; on exploite directement la structure Obsidian (frontmatter, wikilinks, MOCs). **Nouveau pipeline** dans `llm-api/search.py` (scoring multi-signaux : aliases / titre-slug / answers / domains / tags / BM25 ; expansion par graphe via `linked`/`related`/wikilinks ; tokenizer FR avec normalisations `c++``cpp`, split `-`/`_`). **Déterministe, traçable (champ `reasons` dans les sources), 50 ms de retrieval**. Scoring calibré sur 12 cas (IA, push-swap, LLMs pluriel, hors-sujet clafoutis → `(aucun)`, etc.). **Dépendances allégées** : fini `chromadb`, `chroma-hnswlib`, `nomic-embed-text`. `requirements.txt` = fastapi + uvicorn + requests + pyyaml uniquement. Fichiers supprimés : `llm-api/rag.py`, `llm-api/index_vault.py`, `chroma-index/` (marqué pour suppression, verrouillé par Cursor au moment du cleanup — sera supprimé au reboot). **Vault enrichi** : `build-vault.py` étendu pour générer automatiquement `aliases` (à partir du slug/titre + `DOMAIN_ALIASES`), `answers` (questions-types adaptées au type de note), `priority` (heuristique CV=10, MOCs=7, compétences=7, projets=5). Note CV curatée (`source: manual`) enrichie manuellement avec 12 aliases et 7 answers. Nouvelle `vault-grasbot/TAXONOMIE.md` qui documente le vocabulaire contrôlé. Réécriture de `vault-grasbot/50-Technique/grasbot-rag.md``grasbot-retrieval.md` (nouveau pipeline), + `architecture-site.md` + `vault-structure.md` + `MOC-Technique.md`. Nouveau endpoint `POST /reload-vault` pour recharger sans redémarrer uvicorn. Documentation interne refaite : [`04-api-llm-et-chatbot.md`](./04-api-llm-et-chatbot.md), [`06-strapi-extraction.md`](./06-strapi-extraction.md), nouveau [`08-vault-obsidian-retrieval.md`](./08-vault-obsidian-retrieval.md) (remplace `08-vault-obsidian-rag.md`). |
| 2026-04-24 | **Doc + configuration** : routes `/competences/[slug]/[realisation]`, entité Strapi `realisation-ia`, tri `order` et comportement vignettes/richtext documentés dans `02-frontend-next.md`, `04-api-llm-et-chatbot.md` (parcours public), `etat-actuel.md` ; `CONFIGURATION_SITE.md` : section *Contenu : compétences, réalisations IA et ordre daffichage*. Décision : **pas de « phase 3 » homelab** sur le site (consignée dans létat actuel). | | 2026-04-24 | **Doc + configuration** : routes `/competences/[slug]/[realisation]`, entité Strapi `realisation-ia`, tri `order` et comportement vignettes/richtext documentés dans `02-frontend-next.md`, `04-api-llm-et-chatbot.md` (parcours public), `etat-actuel.md` ; `CONFIGURATION_SITE.md` : section *Contenu : compétences, réalisations IA et ordre daffichage*. Décision : **pas de « phase 3 » homelab** sur le site (consignée dans létat actuel). |
| 2026-04-28 | **Perf images Next** : lots A/B/C + socle F documentés dans [`09-performances-images.md`](./09-performances-images.md) — `pickStrapiImage`, `next/image` (listes, carousels, hero), `remotePatterns`, preconnect API. Lot B (`compress: true`) **annulé** : conflit IIS reverse proxy (500). |
| 2026-04-28 | **Plan Server Components** : nouveau doc [`10-plan-server-components.md`](./10-plan-server-components.md) (périmètre, gains, ordre de migration, contraintes `dev` / layout). **Aucun code** modifié dans ce lot. |

View File

@ -22,7 +22,26 @@ const nextConfig = {
}, },
images: { images: {
domains: ["localhost", "api.fernandgrascalvet.com"], formats: ["image/avif", "image/webp"],
remotePatterns: [
{
protocol: "https",
hostname: "api.fernandgrascalvet.com",
pathname: "/uploads/**",
},
{
protocol: "http",
hostname: "localhost",
port: "1337",
pathname: "/uploads/**",
},
{
protocol: "http",
hostname: "127.0.0.1",
port: "1337",
pathname: "/uploads/**",
},
],
}, },
}; };

673
package-lock.json generated
View File

@ -30,6 +30,7 @@
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"postcss": "^8", "postcss": "^8",
"sharp": "^0.33.5",
"tailwindcss": "^3.4.1", "tailwindcss": "^3.4.1",
"typescript": "^5" "typescript": "^5"
} }
@ -47,9 +48,9 @@
} }
}, },
"node_modules/@emnapi/runtime": { "node_modules/@emnapi/runtime": {
"version": "1.8.1", "version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
"integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
@ -57,9 +58,9 @@
} }
}, },
"node_modules/@img/colour": { "node_modules/@img/colour": {
"version": "1.0.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
"integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==", "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"engines": { "engines": {
@ -67,12 +68,13 @@
} }
}, },
"node_modules/@img/sharp-darwin-arm64": { "node_modules/@img/sharp-darwin-arm64": {
"version": "0.34.5", "version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz",
"integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -85,16 +87,17 @@
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.2.4" "@img/sharp-libvips-darwin-arm64": "1.0.4"
} }
}, },
"node_modules/@img/sharp-darwin-x64": { "node_modules/@img/sharp-darwin-x64": {
"version": "0.34.5", "version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz",
"integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -107,16 +110,17 @@
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.2.4" "@img/sharp-libvips-darwin-x64": "1.0.4"
} }
}, },
"node_modules/@img/sharp-libvips-darwin-arm64": { "node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.2.4", "version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz",
"integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@ -127,12 +131,13 @@
} }
}, },
"node_modules/@img/sharp-libvips-darwin-x64": { "node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.2.4", "version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz",
"integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@ -143,12 +148,13 @@
} }
}, },
"node_modules/@img/sharp-libvips-linux-arm": { "node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.2.4", "version": "1.0.5",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz",
"integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true,
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@ -159,12 +165,13 @@
} }
}, },
"node_modules/@img/sharp-libvips-linux-arm64": { "node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.2.4", "version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz",
"integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@ -207,12 +214,13 @@
} }
}, },
"node_modules/@img/sharp-libvips-linux-s390x": { "node_modules/@img/sharp-libvips-linux-s390x": {
"version": "1.2.4", "version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz",
"integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==",
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
"dev": true,
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@ -223,12 +231,13 @@
} }
}, },
"node_modules/@img/sharp-libvips-linux-x64": { "node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.2.4", "version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz",
"integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@ -239,12 +248,13 @@
} }
}, },
"node_modules/@img/sharp-libvips-linuxmusl-arm64": { "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.2.4", "version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz",
"integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@ -255,12 +265,13 @@
} }
}, },
"node_modules/@img/sharp-libvips-linuxmusl-x64": { "node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.2.4", "version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz",
"integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@ -271,12 +282,13 @@
} }
}, },
"node_modules/@img/sharp-linux-arm": { "node_modules/@img/sharp-linux-arm": {
"version": "0.34.5", "version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz",
"integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -289,16 +301,17 @@
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.2.4" "@img/sharp-libvips-linux-arm": "1.0.5"
} }
}, },
"node_modules/@img/sharp-linux-arm64": { "node_modules/@img/sharp-linux-arm64": {
"version": "0.34.5", "version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz",
"integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -311,7 +324,7 @@
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.2.4" "@img/sharp-libvips-linux-arm64": "1.0.4"
} }
}, },
"node_modules/@img/sharp-linux-ppc64": { "node_modules/@img/sharp-linux-ppc64": {
@ -359,12 +372,13 @@
} }
}, },
"node_modules/@img/sharp-linux-s390x": { "node_modules/@img/sharp-linux-s390x": {
"version": "0.34.5", "version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz",
"integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==",
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
"dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -377,16 +391,17 @@
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-libvips-linux-s390x": "1.2.4" "@img/sharp-libvips-linux-s390x": "1.0.4"
} }
}, },
"node_modules/@img/sharp-linux-x64": { "node_modules/@img/sharp-linux-x64": {
"version": "0.34.5", "version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz",
"integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -399,16 +414,17 @@
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.2.4" "@img/sharp-libvips-linux-x64": "1.0.4"
} }
}, },
"node_modules/@img/sharp-linuxmusl-arm64": { "node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.34.5", "version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz",
"integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -421,16 +437,17 @@
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4" "@img/sharp-libvips-linuxmusl-arm64": "1.0.4"
} }
}, },
"node_modules/@img/sharp-linuxmusl-x64": { "node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.34.5", "version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz",
"integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -443,20 +460,21 @@
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.2.4" "@img/sharp-libvips-linuxmusl-x64": "1.0.4"
} }
}, },
"node_modules/@img/sharp-wasm32": { "node_modules/@img/sharp-wasm32": {
"version": "0.34.5", "version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz",
"integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==",
"cpu": [ "cpu": [
"wasm32" "wasm32"
], ],
"dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
"@emnapi/runtime": "^1.7.0" "@emnapi/runtime": "^1.2.0"
}, },
"engines": { "engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0" "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
@ -485,12 +503,13 @@
} }
}, },
"node_modules/@img/sharp-win32-ia32": { "node_modules/@img/sharp-win32-ia32": {
"version": "0.34.5", "version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz",
"integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
"dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later", "license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@ -504,12 +523,13 @@
} }
}, },
"node_modules/@img/sharp-win32-x64": { "node_modules/@img/sharp-win32-x64": {
"version": "0.34.5", "version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz",
"integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later", "license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@ -1164,6 +1184,20 @@
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/color": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
"integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1",
"color-string": "^1.9.0"
},
"engines": {
"node": ">=12.5.0"
}
},
"node_modules/color-convert": { "node_modules/color-convert": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@ -1182,6 +1216,17 @@
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/color-string": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz",
"integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-name": "^1.0.0",
"simple-swizzle": "^0.2.2"
}
},
"node_modules/comma-separated-tokens": { "node_modules/comma-separated-tokens": {
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz",
@ -1276,8 +1321,8 @@
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true,
"engines": { "engines": {
"node": ">=8" "node": ">=8"
} }
@ -1860,6 +1905,13 @@
"url": "https://github.com/sponsors/wooorm" "url": "https://github.com/sponsors/wooorm"
} }
}, },
"node_modules/is-arrayish": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz",
"integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==",
"dev": true,
"license": "MIT"
},
"node_modules/is-binary-path": { "node_modules/is-binary-path": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
@ -3058,6 +3110,367 @@
} }
} }
}, },
"node_modules/next/node_modules/@img/sharp-darwin-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
"integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.2.4"
}
},
"node_modules/next/node_modules/@img/sharp-darwin-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
"integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.2.4"
}
},
"node_modules/next/node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
"integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/next/node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
"integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/next/node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
"integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
"cpu": [
"arm"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/next/node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
"integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/next/node_modules/@img/sharp-libvips-linux-s390x": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
"integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
"cpu": [
"s390x"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/next/node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
"integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/next/node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
"integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/next/node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
"integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/next/node_modules/@img/sharp-linux-arm": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
"integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
"cpu": [
"arm"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.2.4"
}
},
"node_modules/next/node_modules/@img/sharp-linux-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
"integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.2.4"
}
},
"node_modules/next/node_modules/@img/sharp-linux-s390x": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
"integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
"cpu": [
"s390x"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-s390x": "1.2.4"
}
},
"node_modules/next/node_modules/@img/sharp-linux-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
"integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.2.4"
}
},
"node_modules/next/node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
"integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
}
},
"node_modules/next/node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
"integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.2.4"
}
},
"node_modules/next/node_modules/@img/sharp-wasm32": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
"integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
"cpu": [
"wasm32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"dependencies": {
"@emnapi/runtime": "^1.7.0"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/next/node_modules/@img/sharp-win32-ia32": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
"integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
"cpu": [
"ia32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/next/node_modules/@img/sharp-win32-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
"integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
"cpu": [
"x64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/next/node_modules/postcss": { "node_modules/next/node_modules/postcss": {
"version": "8.4.31", "version": "8.4.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
@ -3086,6 +3499,51 @@
"node": "^10 || ^12 || >=14" "node": "^10 || ^12 || >=14"
} }
}, },
"node_modules/next/node_modules/sharp": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
"hasInstallScript": true,
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"@img/colour": "^1.0.0",
"detect-libc": "^2.1.2",
"semver": "^7.7.3"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-darwin-arm64": "0.34.5",
"@img/sharp-darwin-x64": "0.34.5",
"@img/sharp-libvips-darwin-arm64": "1.2.4",
"@img/sharp-libvips-darwin-x64": "1.2.4",
"@img/sharp-libvips-linux-arm": "1.2.4",
"@img/sharp-libvips-linux-arm64": "1.2.4",
"@img/sharp-libvips-linux-ppc64": "1.2.4",
"@img/sharp-libvips-linux-riscv64": "1.2.4",
"@img/sharp-libvips-linux-s390x": "1.2.4",
"@img/sharp-libvips-linux-x64": "1.2.4",
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
"@img/sharp-libvips-linuxmusl-x64": "1.2.4",
"@img/sharp-linux-arm": "0.34.5",
"@img/sharp-linux-arm64": "0.34.5",
"@img/sharp-linux-ppc64": "0.34.5",
"@img/sharp-linux-riscv64": "0.34.5",
"@img/sharp-linux-s390x": "0.34.5",
"@img/sharp-linux-x64": "0.34.5",
"@img/sharp-linuxmusl-arm64": "0.34.5",
"@img/sharp-linuxmusl-x64": "0.34.5",
"@img/sharp-wasm32": "0.34.5",
"@img/sharp-win32-arm64": "0.34.5",
"@img/sharp-win32-ia32": "0.34.5",
"@img/sharp-win32-x64": "0.34.5"
}
},
"node_modules/normalize-path": { "node_modules/normalize-path": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
@ -3713,8 +4171,8 @@
"version": "7.7.3", "version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"devOptional": true,
"license": "ISC", "license": "ISC",
"optional": true,
"bin": { "bin": {
"semver": "bin/semver.js" "semver": "bin/semver.js"
}, },
@ -3723,16 +4181,16 @@
} }
}, },
"node_modules/sharp": { "node_modules/sharp": {
"version": "0.34.5", "version": "0.33.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz",
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==",
"dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true,
"dependencies": { "dependencies": {
"@img/colour": "^1.0.0", "color": "^4.2.3",
"detect-libc": "^2.1.2", "detect-libc": "^2.0.3",
"semver": "^7.7.3" "semver": "^7.6.3"
}, },
"engines": { "engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0" "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
@ -3741,30 +4199,25 @@
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-arm64": "0.33.5",
"@img/sharp-darwin-x64": "0.34.5", "@img/sharp-darwin-x64": "0.33.5",
"@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-arm64": "1.0.4",
"@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.0.4",
"@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm": "1.0.5",
"@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.0.4",
"@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.0.4",
"@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-x64": "1.0.4",
"@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.0.4",
"@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.0.4",
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-linux-arm": "0.33.5",
"@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm64": "0.33.5",
"@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-s390x": "0.33.5",
"@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-x64": "0.33.5",
"@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.33.5",
"@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.33.5",
"@img/sharp-linux-s390x": "0.34.5", "@img/sharp-wasm32": "0.33.5",
"@img/sharp-linux-x64": "0.34.5", "@img/sharp-win32-ia32": "0.33.5",
"@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-win32-x64": "0.33.5"
"@img/sharp-linuxmusl-x64": "0.34.5",
"@img/sharp-wasm32": "0.34.5",
"@img/sharp-win32-arm64": "0.34.5",
"@img/sharp-win32-ia32": "0.34.5",
"@img/sharp-win32-x64": "0.34.5"
} }
}, },
"node_modules/shebang-command": { "node_modules/shebang-command": {
@ -3872,6 +4325,16 @@
"url": "https://github.com/sponsors/isaacs" "url": "https://github.com/sponsors/isaacs"
} }
}, },
"node_modules/simple-swizzle": {
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz",
"integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-arrayish": "^0.3.1"
}
},
"node_modules/source-map-js": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",

View File

@ -6,7 +6,11 @@
"dev": "next dev --turbopack", "dev": "next dev --turbopack",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "next lint" "lint": "next lint",
"media:inventory": "node strapi_extraction/media-sync/01-fetch-inventory.js",
"media:download": "node strapi_extraction/media-sync/02-download.js",
"media:webp": "node strapi_extraction/media-sync/03-convert-webp.js",
"media:upload": "node strapi_extraction/media-sync/04-upload-replace.js"
}, },
"dependencies": { "dependencies": {
"@strapi/blocks-react-renderer": "^1.0.1", "@strapi/blocks-react-renderer": "^1.0.1",
@ -31,6 +35,7 @@
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"postcss": "^8", "postcss": "^8",
"sharp": "^0.33.5",
"tailwindcss": "^3.4.1", "tailwindcss": "^3.4.1",
"typescript": "^5" "typescript": "^5"
} }

View File

@ -0,0 +1,139 @@
/**
* 01 Appelle lAPI Strapi (lecture publique) et produit media-inventory.json
* (liste de tous les fichiers image liés aux content-types configurés).
*
* Usage : node strapi_extraction/media-sync/01-fetch-inventory.js
*/
const fs = require("fs");
const path = require("path");
const {
API_BASE,
WORK_ROOT,
FILE_INVENTORY,
COLLECTIONS,
PAGE_SIZE,
} = require("./config");
const { normalizeField, safeSlug } = require("./lib/collect-media");
function unwrapEntry(entry) {
if (!entry) return null;
if (entry.attributes) {
return {
...entry.attributes,
id: entry.id,
documentId: entry.documentId,
};
}
return entry;
}
async function fetchJson(url) {
const r = await fetch(url);
if (!r.ok) {
const txt = await r.text();
throw new Error(`HTTP ${r.status} ${url}\n${txt.slice(0, 500)}`);
}
return r.json();
}
async function fetchAllEntries(plural) {
const out = [];
let page = 1;
/* eslint-disable no-constant-condition */
while (true) {
const params = new URLSearchParams();
params.set("pagination[page]", String(page));
params.set("pagination[pageSize]", String(PAGE_SIZE));
/**
* Strapi v5 : populate[picture]=* peut provoquer « Invalid key related » selon versions.
* populate=* hydrate les relations premier niveau (y compris les médias).
*/
params.set("populate", "*");
const url = `${API_BASE}/${plural}?${params}`;
const json = await fetchJson(url);
const rows = Array.isArray(json.data) ? json.data : [];
for (const row of rows) {
out.push(unwrapEntry(row));
}
const pageCount = json.meta?.pagination?.pageCount ?? 1;
if (page >= pageCount || rows.length === 0) break;
page += 1;
await new Promise((r) => setTimeout(r, 200));
}
return out;
}
async function main() {
console.log("🔍 STRAPI_URL (origine) →", require("./config").STRAPI_URL);
console.log("🔍 API_BASE →", API_BASE);
if (!fs.existsSync(WORK_ROOT)) {
fs.mkdirSync(WORK_ROOT, { recursive: true });
}
const inventory = {
generatedAt: new Date().toISOString(),
apiBase: API_BASE,
files: [],
};
/** @type {typeof inventory.files} */
const records = [];
for (const col of COLLECTIONS) {
console.log(`\n📂 ${col.plural} (${col.section})…`);
let entries;
try {
entries = await fetchAllEntries(col.plural);
} catch (e) {
console.error(` ⚠️ Endpoint indisponible ou erreur : ${e.message}`);
continue;
}
console.log(` ${entries.length} entrée(s)`);
for (const entry of entries) {
if (!entry) continue;
const slug = safeSlug(entry);
for (const field of col.fields) {
const items = normalizeField(entry[field.name], field.multiple);
for (const { file, index } of items) {
const base = path.basename(file.url.split("?")[0] || "file");
records.push({
collectionPlural: col.plural,
collectionSingular: col.singular,
strapiRef: col.ref,
section: col.section,
entrySlug: slug,
entryId: entry.id,
entryDocumentId: entry.documentId ?? null,
fieldName: field.name,
fieldMultiple: field.multiple,
fieldIndex: index,
fileId: file.id,
fileDocumentId: file.documentId ?? null,
filename: file.name || base,
url: file.url,
mime: file.mime,
size: file.size,
ext: file.ext,
/** chemin relatif WORK_ROOT/downloaded/... rempli par 02 */
relativeDownloadPath: null,
});
}
}
}
}
inventory.files = records.slice().sort((a, b) => a.fileId - b.fileId);
fs.writeFileSync(FILE_INVENTORY, JSON.stringify(inventory, null, 2), "utf8");
console.log(`\n✅ Inventaire : ${inventory.files.length} fichier(s) → ${FILE_INVENTORY}`);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

View File

@ -0,0 +1,108 @@
/**
* 02 Télécharge chaque fichier unique de linventaire vers
* extract/media-sync-work/downloaded/{section}/{slug}/...
* et met à jour relativeDownloadPath dans media-inventory.json
*
* Usage : node strapi_extraction/media-sync/02-download.js
*/
const fs = require("fs");
const path = require("path");
const { STRAPI_URL, FILE_INVENTORY, WORK_ROOT, DIR_DOWNLOADED } = require("./config");
function safeFilePart(name) {
return String(name || "file")
.replace(/[<>:"/\\|?*\x00-\x1f]/g, "_")
.slice(0, 180);
}
function absoluteUrl(relativeOrAbsolute) {
if (relativeOrAbsolute.startsWith("http://") || relativeOrAbsolute.startsWith("https://")) {
return relativeOrAbsolute;
}
return `${STRAPI_URL}${relativeOrAbsolute.startsWith("/") ? "" : "/"}${relativeOrAbsolute}`;
}
async function downloadBuffer(url) {
const r = await fetch(url);
if (!r.ok) {
throw new Error(`HTTP ${r.status} ${url}`);
}
const buf = Buffer.from(await r.arrayBuffer());
return buf;
}
async function main() {
if (!fs.existsSync(FILE_INVENTORY)) {
console.error("Manque linventaire. Lance dabord : 01-fetch-inventory.js");
process.exit(1);
}
const raw = fs.readFileSync(FILE_INVENTORY, "utf8");
const inventory = JSON.parse(raw);
const files = inventory.files;
if (!Array.isArray(files) || files.length === 0) {
console.log("Inventaire vide — rien à télécharger.");
return;
}
if (!fs.existsSync(DIR_DOWNLOADED)) {
fs.mkdirSync(DIR_DOWNLOADED, { recursive: true });
}
const byId = new Map();
let ok = 0;
let skipped = 0;
let fail = 0;
for (const row of files) {
const id = row.fileId;
if (byId.has(id)) {
row.relativeDownloadPath = byId.get(id);
skipped++;
continue;
}
const rel = path.join(
row.section,
row.entrySlug,
`${id}_${safeFilePart(row.filename)}`
);
const abs = path.join(DIR_DOWNLOADED, rel);
try {
const url = absoluteUrl(row.url);
const buf = await downloadBuffer(url);
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, buf);
row.relativeDownloadPath = rel.replace(/\\/g, "/");
byId.set(id, row.relativeDownloadPath);
ok++;
console.log(`${rel} (${(buf.length / 1024).toFixed(1)} KB)`);
} catch (e) {
console.error(`❌ fileId=${id} : ${e.message}`);
row.relativeDownloadPath = null;
row.downloadError = String(e.message);
fail++;
}
}
inventory.downloadedAt = new Date().toISOString();
inventory.stats = {
uniqueFiles: byId.size,
rowsTotal: files.length,
downloadedOk: ok,
dedupSkipped: skipped,
failed: fail,
};
fs.writeFileSync(FILE_INVENTORY, JSON.stringify(inventory, null, 2), "utf8");
console.log(`\n📁 Base téléchargements : ${DIR_DOWNLOADED}`);
console.log(
`Résumé : ${ok} téléchargement(s), ${skipped} lignes réutilisent un fichier déjà pris, ${fail} échec(s).`
);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

View File

@ -0,0 +1,133 @@
/**
* 03 Convertit les téléchargements en WebP (sharp) sous
* extract/media-sync-work/webp/{section}/{slug}/{fileId}_{stem}.webp
* Les SVG sont copiés en .svg (sans conversion raster).
*
* Usage : node strapi_extraction/media-sync/03-convert-webp.js
*/
const fs = require("fs");
const path = require("path");
const sharp = require("sharp");
const { FILE_INVENTORY, DIR_DOWNLOADED, DIR_WEBP, WORK_ROOT } = require("./config");
const MAX_EDGE = 2560;
const WEBP_QUALITY = 82;
function stemFromFilename(filename) {
const b = path.basename(filename);
const i = b.lastIndexOf(".");
return i <= 0 ? b : b.slice(0, i);
}
async function toWebpRaster(srcPath, destPath) {
const meta = await sharp(srcPath).metadata();
const w = meta.width || 0;
const h = meta.height || 0;
let pipeline = sharp(srcPath).rotate();
if (w > MAX_EDGE || h > MAX_EDGE) {
pipeline = pipeline.resize(MAX_EDGE, MAX_EDGE, {
fit: "inside",
withoutEnlargement: true,
});
}
fs.mkdirSync(path.dirname(destPath), { recursive: true });
await pipeline.webp({ quality: WEBP_QUALITY, effort: 4 }).toFile(destPath);
}
async function main() {
if (!fs.existsSync(FILE_INVENTORY)) {
console.error("Manque media-inventory.json — lance 01 puis 02.");
process.exit(1);
}
const inventory = JSON.parse(fs.readFileSync(FILE_INVENTORY, "utf8"));
const files = inventory.files;
if (!Array.isArray(files) || !files.length) {
console.log("Rien à convertir.");
return;
}
if (!fs.existsSync(WORK_ROOT)) fs.mkdirSync(WORK_ROOT, { recursive: true });
if (!fs.existsSync(DIR_WEBP)) fs.mkdirSync(DIR_WEBP, { recursive: true });
const byId = new Map();
let ok = 0;
let err = 0;
for (const row of files) {
const id = row.fileId;
if (!row.relativeDownloadPath) {
row.relativeWebpPath = null;
continue;
}
if (byId.has(id)) {
row.relativeWebpPath = byId.get(id);
continue;
}
const src = path.join(DIR_DOWNLOADED, row.relativeDownloadPath);
if (!fs.existsSync(src)) {
row.relativeWebpPath = null;
row.convertError = "fichier téléchargé manquant";
err++;
continue;
}
const ext = path.extname(src).toLowerCase();
const baseStem = `${id}_${stemFromFilename(row.filename)}`;
const relDir = path.join(row.section, row.entrySlug);
let relOutFile;
let absOut;
try {
if (ext === ".svg") {
relOutFile = path.join(relDir, `${baseStem}.svg`).replace(/\\/g, "/");
absOut = path.join(DIR_WEBP, relOutFile);
fs.mkdirSync(path.dirname(absOut), { recursive: true });
fs.copyFileSync(src, absOut);
console.log(`svg-copy → ${relOutFile}`);
} else if (ext === ".webp") {
relOutFile = path.join(relDir, `${baseStem}.webp`).replace(/\\/g, "/");
absOut = path.join(DIR_WEBP, relOutFile);
fs.mkdirSync(path.dirname(absOut), { recursive: true });
fs.copyFileSync(src, absOut);
console.log(`webp-copy → ${relOutFile}`);
} else if ([".png", ".jpg", ".jpeg", ".tif", ".tiff"].includes(ext)) {
relOutFile = path.join(relDir, `${baseStem}.webp`).replace(/\\/g, "/");
absOut = path.join(DIR_WEBP, relOutFile);
await toWebpRaster(src, absOut);
console.log(`webp-transform → ${relOutFile}`);
} else {
relOutFile = path.join(relDir, path.basename(src)).replace(/\\/g, "/");
absOut = path.join(DIR_WEBP, relOutFile);
fs.mkdirSync(path.dirname(absOut), { recursive: true });
fs.copyFileSync(src, absOut);
console.log(`raw-copy → ${relOutFile}`);
}
row.relativeWebpPath = relOutFile;
byId.set(id, relOutFile);
ok++;
} catch (e) {
row.relativeWebpPath = null;
row.convertError = String(e.message);
err++;
console.error(`❌ fileId ${id}: ${e.message}`);
}
}
inventory.convertedAt = new Date().toISOString();
inventory.convertSettings = {
maxEdgePx: MAX_EDGE,
webpQuality: WEBP_QUALITY,
};
fs.writeFileSync(FILE_INVENTORY, JSON.stringify(inventory, null, 2), "utf8");
console.log(`\n✅ Fichiers uniques traités : ${ok}, erreurs : ${err}`);
console.log(`📁 ${DIR_WEBP}`);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

View File

@ -0,0 +1,270 @@
/**
* 04 -upload les WebP générés vers Strapi et remplace les entrées média dans
* les fiches concernées (API authentifiée).
*
* Requiert dans lenv : STRAPI_API_TOKEN (JWT Strapi avec droits upload + mise à jour
* des collection types utilisés ; typiquement un jeton Full access en local).
*
* Par défaut : **dry-run** (aucune mutation).
* Mutation réelle : node strapi_extraction/media-sync/04-upload-replace.js --execute
*
* DANGER : sauvegarder au préalable votre base Strapi / médias. Tester sur une
* copie locale (Strapi localhost + même base si possible).
*/
const fs = require("fs");
const path = require("path");
require("dotenv").config({
path: path.join(__dirname, "../../cmsbackend/.env"),
});
require("dotenv").config({
path: path.join(__dirname, "../../.env.local"),
});
const {
FILE_INVENTORY,
DIR_WEBP,
API_BASE,
} = require("./config");
const TOKEN = process.env.STRAPI_API_TOKEN;
const EXECUTE = process.argv.includes("--execute");
function mimeForFile(filePath) {
const ext = path.extname(filePath).toLowerCase();
const map = {
".webp": "image/webp",
".svg": "image/svg+xml",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
};
return map[ext] || "application/octet-stream";
}
/** Compte combien de lignes dinventaire pointent le même fileId */
function countRefs(files) {
const c = {};
for (const r of files) {
c[r.fileId] = (c[r.fileId] || 0) + 1;
}
return c;
}
/**
* Nom réservé multipart (ByteString) : les noms avec caractères hors U+00U+FF
* (ex. U+2194 flèches dans des noms générés) provoquent lerreur Node
* « Cannot convert argument to a ByteString » sur form.append(..., filename).
*/
function asciiUploadName(filePath, fileId) {
const ext = path.extname(filePath).toLowerCase();
const extOk = /^\.(webp|svg|png|jpe?g|gif|avif)$/.test(ext) ? ext : ".webp";
return `upload-${fileId}${extOk}`;
}
async function postUpload(filePath, fileId) {
const buf = fs.readFileSync(filePath);
const asciiName = asciiUploadName(filePath, fileId);
const blob = new Blob([buf], { type: mimeForFile(filePath) });
const body = new FormData();
body.append("files", blob, asciiName);
const res = await fetch(`${API_BASE}/upload`, {
method: "POST",
headers: TOKEN ? { Authorization: `Bearer ${TOKEN}` } : {},
body,
});
if (!res.ok) {
const t = await res.text();
throw new Error(`POST /upload ${res.status} ${t.slice(0, 800)}`);
}
const json = await res.json();
const arr = Array.isArray(json) ? json : json?.data;
if (!Array.isArray(arr) || !arr[0]) {
throw new Error(`Réponse upload inattendue : ${JSON.stringify(json).slice(0, 400)}`);
}
return arr[0];
}
function unwrapEntry(raw) {
if (!raw) return null;
if (raw.data) return unwrapEntry(raw.data);
if (raw.attributes) {
return {
...raw.attributes,
id: raw.id,
documentId: raw.documentId,
};
}
return raw;
}
async function getEntryPlural(plural, documentId) {
const qs = new URLSearchParams({ populate: "*" });
const url = `${API_BASE}/${plural}/${documentId}?${qs}`;
const res = await fetch(url, {
headers: TOKEN ? { Authorization: `Bearer ${TOKEN}` } : {},
});
if (!res.ok) {
const t = await res.text();
throw new Error(`GET ${url}${res.status} ${t.slice(0, 400)}`);
}
const json = await res.json();
return unwrapEntry(json);
}
function getMediaIdsFromField(entry, fieldName, multiple) {
const v = entry[fieldName];
if (multiple) {
const arr = Array.isArray(v) ? v : [];
return arr.map((x) => (typeof x === "object" && x !== null ? x.id : x)).filter(Boolean);
}
if (!v) return [];
if (typeof v === "object" && v.id) return [v.id];
if (typeof v === "number") return [v];
return [];
}
async function putEntry(plural, documentId, payloadData) {
const url = `${API_BASE}/${plural}/${documentId}`;
const res = await fetch(url, {
method: "PUT",
headers: {
"Content-Type": "application/json",
...(TOKEN ? { Authorization: `Bearer ${TOKEN}` } : {}),
},
body: JSON.stringify({ data: payloadData }),
});
if (!res.ok) {
const t = await res.text();
throw new Error(`PUT ${url}${res.status} ${t.slice(0, 800)}`);
}
return res.json();
}
async function deleteFile(fileId) {
const url = `${API_BASE}/upload/files/${fileId}`;
const res = await fetch(url, {
method: "DELETE",
headers: TOKEN ? { Authorization: `Bearer ${TOKEN}` } : {},
});
if (!res.ok) {
const t = await res.text();
throw new Error(`DELETE ${url}${res.status} ${t.slice(0, 400)}`);
}
}
async function main() {
if (!fs.existsSync(FILE_INVENTORY)) {
console.error("Manque media-inventory.json");
process.exit(1);
}
if (!TOKEN) {
console.error(
"STRAPI_API_TOKEN manquant. Crée un jeton API dans Strapi (Settings → API Tokens), puis exporte :\n" +
" Windows PowerShell : $env:STRAPI_API_TOKEN=\"…\"\n" +
" ou ajoute STRAPI_API_TOKEN dans cmsbackend/.env (non commité)."
);
process.exit(1);
}
const inventory = JSON.parse(fs.readFileSync(FILE_INVENTORY, "utf8"));
const files = inventory.files || [];
const refCount = countRefs(files);
const candidates = files.filter(
(r) =>
r.relativeWebpPath &&
r.relativeWebpPath.endsWith(".webp") &&
r.entryDocumentId
);
console.log(
`Lignes inventaire : ${files.length} — candidats WebP remplaçables (avec documentId) : ${candidates.length}`
);
console.log(`Mode : ${EXECUTE ? "EXECUTE (mutations réelles)" : "DRY-RUN (aucune mutation)"}\n`);
if (!EXECUTE) {
console.log(
"Exemple dactions qui seraient effectuées avec --execute :\n" +
" 1. POST /api/upload pour chaque fichier .webp sous webp/\n" +
" 2. PUT /api/:collection/:documentId avec le champ média mis à jour (ids)\n" +
" 3. DELETE /api/upload/files/:oldFileId uniquement si lancien id na quune référence dans linventaire\n"
);
for (let i = 0; i < Math.min(5, candidates.length); i++) {
const r = candidates[i];
console.log(
` • fileId=${r.fileId}${r.relativeWebpPath} → entrée ${r.collectionPlural} documentId=${r.entryDocumentId} champ=${r.fieldName}[${r.fieldIndex}]`
);
}
console.log("\nAjoute --execute pour réellement téléverser (après avoir testé la sauvegarde).");
return;
}
/** Par sécurité, traite fichier par fichier ; re-fetch après chaque succès évite désalignement indices */
for (const row of candidates) {
const src = path.join(DIR_WEBP, row.relativeWebpPath.replace(/\//g, path.sep));
if (!fs.existsSync(src)) {
console.warn(`SKIP fileId=${row.fileId} : fichier absent ${src}`);
continue;
}
const docId = row.entryDocumentId;
const plural = row.collectionPlural;
const field = row.fieldName;
const idx = row.fieldIndex;
const oldId = row.fileId;
try {
const uploaded = await postUpload(src, row.fileId);
const newId = uploaded.id;
const entry = await getEntryPlural(plural, docId);
if (!entry) throw new Error("entrée introuvable");
const multiple = row.fieldMultiple;
const currentIds = getMediaIdsFromField(entry, field, multiple);
if (multiple) {
if (idx < 0 || idx >= currentIds.length) {
throw new Error(`index ${idx} hors limites (ids actuels : ${currentIds.join(",")})`);
}
if (currentIds[idx] !== oldId) {
throw new Error(
`id à lindex ${idx} est ${currentIds[idx]}, attendu ${oldId} — arrêt pour éviter corruption`
);
}
const next = currentIds.slice();
next[idx] = newId;
await putEntry(plural, docId, { [field]: next });
} else {
const cur = currentIds[0];
if (cur != null && cur !== oldId) {
throw new Error(`champ simple : attendu ancien id ${oldId}, vu ${cur}`);
}
await putEntry(plural, docId, { [field]: newId });
}
console.log(`OK upload+remplace : ${oldId}${newId} (${plural}/${docId})`);
const canDeleteOld = refCount[oldId] === 1;
if (canDeleteOld && oldId !== newId) {
try {
await deleteFile(oldId);
console.log(` ancien fichier Strapi ${oldId} supprimé`);
} catch (de) {
console.warn(` suppression ancien échouée (non bloquant) : ${de.message}`);
}
}
} catch (e) {
console.error(`ÉCHEC fileId=${row.fileId} : ${e.message}`);
}
}
console.log("\nTerminé. Recharge les fiches dans ladmin et vide le cache navigateur.");
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

View File

@ -0,0 +1,58 @@
# Pipeline médias Strapi → dossiers par section → WebP → ré-upload optionnel
**Répertoire de travail (lourd)** : `strapi_extraction/extract/media-sync-work/` — ignoré par Git (`/.gitignore`).
## Prérequis
- Node 18+
- Dépendance `sharp` installée depuis la racine (`npm install` déjà fait si le dépôt à jour).
## Variables optionnelles
| Variable | Rôle |
|----------|------|
| `STRAPI_URL` | Origine Strapi sans `/api` (défaut depuis `NEXT_PUBLIC_API_URL` ou prod). |
| `STRAPI_API_TOKEN` | **Seulement** pour `04-upload-replace.js --execute`. Jeton créé dans Strapi → Settings → API Tokens (droits lecture + Upload + mise à jour des CT concernés). Ne pas committer ce jeton. |
Chemins `.env.local` à la racine et `cmsbackend/.env` sont chargés par les scripts (`config.js` ou `04`).
## Chaîne normale
```powershell
# depuis la racine du repo J:\my-next-site
npm run media:inventory # 01 — liste tous les médias utilisés → media-inventory.json
npm run media:download # 02 — téléchargement physique par section sous downloaded/
npm run media:webp # 03 — conversion/copies sous webp/
npm run media:upload # 04 — dry-run uniquement (aucune mutation)
# Mutation CMS (**attention**) après lecture cidessous :
$env:STRAPI_API_TOKEN="<jeton>"
node strapi_extraction/media-sync/04-upload-replace.js --execute
```
### Organisation des dossiers
- **`downloaded/{section}/{slug-du-contenu}/{fileId}_{nom-fichier}`**
Sections : `portfolio`, `competences`, `home`, `realisation-ias`, `glossaire`.
- **`webp/...`** même structure, avec `.webp` (ou `.svg` copié en clair).
Les **fichiers média uniques** sont dédupliqués par `fileId` : une seule fois sur disque, plusieurs lignes dans linventaire peuvent référencer le même téléchargement.
### Erreur « Cannot convert argument to a ByteString » (upload)
Les noms de fichiers sur disque peuvent contenir des caractères Unicode (tirets fins, flèches dans des noms AI, etc.). Le `FormData` HTTP naccepte quun nom de fichier **ASCII** dans len-tête multipart ; le script **`04-upload-replace.js`** renomme donc en interne chaque envoi en `upload-{fileId}.webp` (voir `asciiUploadName` dans le fichier). Relance `--execute` après mise à jour du script.
### Avant le `--execute` sur la prod
1. **Tester dabord** contre une instance Strapi locale (ex. importer la base vers `cmsbackend`, `npm run develop`, puis `$env:STRAPI_URL="http://localhost:1337"` et un jeton local).
2. **Sauvegarder** la base et `/public/uploads`.
3. Lire le préambule dans `04-upload-replace.js` ; le script vérifie que lancien id correspond encore avant remplacement pour limiter les corruptions.
## Si létape ré-upload vous suffit après conversion manuelle
Vous pouvez aussi **importer uniquement les WebP** depuis ladmin Strapi puis réassigner les champs à la main ; ce dépôt ne vous oblige pas à utiliser `04`.

View File

@ -0,0 +1,78 @@
/**
* Configuration partagée sync médias Strapi (téléchargement / WebP / -upload).
* Variables d'environnement (optionnelles) :
* STRAPI_URL origine sans /api (ex. https://api.fernandgrascalvet.com ou http://localhost:1337)
* STRAPI_API_TOKEN jeton API Strapi (requis pour 04-upload-replace.js --execute)
*/
require("dotenv").config({ path: require("path").join(__dirname, "../../.env.local") });
require("dotenv").config({ path: require("path").join(__dirname, "../../cmsbackend/.env") });
const path = require("path");
const STRAPI_URL =
process.env.STRAPI_URL ||
process.env.NEXT_PUBLIC_API_URL?.replace(/\/$/, "") ||
"https://api.fernandgrascalvet.com";
const API_BASE = `${STRAPI_URL}/api`;
/** Sortie : mirrors extract/ existant (gitignored lourd) */
const WORK_ROOT = path.join(__dirname, "../extract/media-sync-work");
const DIR_DOWNLOADED = path.join(WORK_ROOT, "downloaded");
const DIR_WEBP = path.join(WORK_ROOT, "webp");
const FILE_INVENTORY = path.join(WORK_ROOT, "media-inventory.json");
/**
* Content-types avec champs média aligné sur cmsbackend/src/api (schema.json par type).
* section = sous-dossier humain (portfolio, competences, )
*/
const COLLECTIONS = [
{
plural: "projects",
singular: "project",
ref: "api::project.project",
section: "portfolio",
fields: [{ name: "picture", multiple: true }],
},
{
plural: "competences",
singular: "competence",
ref: "api::competence.competence",
section: "competences",
fields: [{ name: "picture", multiple: true }],
},
{
plural: "homepages",
singular: "homepage",
ref: "api::homepage.homepage",
section: "home",
fields: [{ name: "photo", multiple: false }],
},
{
plural: "realisation-ias",
singular: "realisation-ia",
ref: "api::realisation-ia.realisation-ia",
section: "realisation-ias",
fields: [{ name: "picture", multiple: true }],
},
{
plural: "glossaires",
singular: "glossaire",
ref: "api::glossaire.glossaire",
section: "glossaire",
fields: [{ name: "images", multiple: true }],
},
];
const PAGE_SIZE = 100;
module.exports = {
STRAPI_URL,
API_BASE,
WORK_ROOT,
DIR_DOWNLOADED,
DIR_WEBP,
FILE_INVENTORY,
COLLECTIONS,
PAGE_SIZE,
};

View File

@ -0,0 +1,41 @@
/**
* Helpers extraire les fichiers média des réponses Strapi v5 (structure plate).
*/
function isUploadedImage(obj) {
if (!obj || typeof obj !== "object" || typeof obj.url !== "string") return false;
if (!obj.url.includes("/uploads/")) return false;
if (obj.mime && !obj.mime.startsWith("image/")) return false;
return true;
}
/** Liste { file, index } pour un champ média Strapi */
function normalizeField(fieldVal, multiple) {
if (!fieldVal) return [];
if (multiple) {
const arr = Array.isArray(fieldVal) ? fieldVal : [fieldVal];
return arr
.filter(isUploadedImage)
.map((file, index) => ({ file, index }));
}
return isUploadedImage(fieldVal)
? [{ file: fieldVal, index: 0 }]
: [];
}
function safeSlug(entry) {
const s =
entry.slug ??
entry.documentId ??
(entry.id != null ? String(entry.id) : "unknown");
return String(s)
.replace(/[^\wÀ-ÖØ-öø-ÿ.-]+/gu, "_")
.slice(0, 96)
.replace(/^_|_$/g, "") || "entry";
}
module.exports = {
normalizeField,
safeSlug,
isUploadedImage,
};

View File

@ -22,5 +22,6 @@
</rewrite> </rewrite>
<directoryBrowse enabled="true" /> <directoryBrowse enabled="true" />
<caching enableKernelCache="true" /> <caching enableKernelCache="true" />
<urlCompression doStaticCompression="true" doDynamicCompression="false" />
</system.webServer> </system.webServer>
</configuration> </configuration>