Transformez un dossier de photos en galerie complète — routes, pagination, lightbox et thème — sans écrire une seule page Astro.
Plugin d'intégration Astro 7 pour sites de photographie. Ajoute en une ligne de config un système complet de séries photo : content collection, routes automatiques, composants, helpers TypeScript et thème CSS configurable.
- Zéro config requise — une ligne dans
astro.config.mjssuffit - CLI
init— crée ou met à joursrc/content.config.tsautomatiquement - Routes injectées —
/series/,/series/[slug]/, pagination native - 9 composants prêts —
SeriesCard,SeriesList,SeriesGallery,SeriesLightbox,SeriesAttachments,SeriesEmbeds,SeriesFilter,SeriesMap,SeriesMasonry - Contenus embarqués — Vimeo, YouTube, SoundCloud… chargés en façade, l'iframe n'arrive qu'au clic
- Lightbox native — navigation clavier ←/→/Esc, aucune dépendance externe
- Thème configurable — CSS custom properties
--hf-*surchargeables - Schéma extensible — ajoutez vos champs via
.extend()Zod - Images optimisées — WebP, srcset et dimensions générés au build par Astro
- TypeScript first — types complets, 0 erreur de compilation garantie
npm install @regrets/hyperfocaleAucun registre à configurer, aucun jeton : le paquet est publié sur le npm public. (Il vivait sur GitHub Packages, qui exige une authentification même pour un paquet public — donc un jeton en local, en CI et au déploiement de chaque site consommateur.)
npx hyperfocale initLe CLI crée ou met à jour src/content.config.ts pour enregistrer la collection series. Idempotent — relancez-le sans risque si le fichier existe déjà.
// astro.config.mjs
import { defineConfig } from 'astro/config';
import hyperfocale from '@regrets/hyperfocale';
export default defineConfig({
integrations: [
hyperfocale({
prefix: '/series', // préfixe des routes (défaut)
pageSize: 12, // images par page (défaut)
theme: 'auto', // 'light' | 'dark' | 'auto' | 'none' (défaut 'auto')
collectionName: 'series', // nom de la collection (défaut)
dateRequired: true, // `date` obligatoire (défaut)
}),
],
});C'est tout — votre site génère maintenant des routes /series/ automatiquement.
| Option | Type | Défaut | Description |
|---|---|---|---|
preset |
PresetName |
— | Profil de domaine : pré-remplit prefix, collectionName et dateRequired (voir ci-dessous). Toute option explicite l'emporte. |
prefix |
string |
'/series' |
Préfixe des routes injectées. Doit commencer par /. |
pageSize |
number |
12 |
Nombre d'images par page dans la galerie paginée (≥ 1). |
theme |
'light' | 'dark' | 'auto' | 'none' |
'auto' |
Thème CSS injecté. 'auto' suit prefers-color-scheme. 'none' n'injecte aucune feuille — voir Couche data seule. |
collectionName |
string |
'series' |
Nom de la content collection à enregistrer. Les helpers lisent cette collection et ses media/ — y compris sous un autre nom (projects avec le preset portfolio). |
dateRequired |
boolean |
true |
Si false, le champ date devient optionnel (collections non temporelles : marques, produits). |
imageOptimization |
'auto' | 'disabled' |
'auto' |
'disabled' sert les fichiers d'origine sans passer par astro:assets (voir Déploiement). |
Le plugin ne sert pas que des galeries photo. Un preset pré-remplit les trois options structurantes d'un domaine — ces profils sont standardisés en Annexe G de la spec :
hyperfocale({ preset: 'portfolio' }) // → collection `projects`, routes sous /projets| Preset | Collection | Préfixe | dateRequired |
L'atome |
|---|---|---|---|---|
series |
series |
/series |
true |
Une série photo |
portfolio |
projects |
/projets |
false |
Un projet |
music |
albums |
/discographie |
false |
Une sortie (album, EP, single) |
catalog |
items |
/catalogue |
false |
Une pièce du catalogue |
press |
articles |
/presse |
true |
Un article |
recipe |
recipes |
/recettes |
false |
Une recette |
event |
events |
/evenements |
true |
Un événement |
app |
apps |
/applications |
false |
Une application |
book |
books |
/livres |
false |
Un livre lu |
place |
places |
/lieux |
false |
Un lieu |
screen |
screens |
/ecrans |
false |
Un écran |
Les onze profils de l'Annexe G sont couverts. Seul series, press et event exigent une date : ailleurs le contenu est intemporel — une recette, un lieu, une démo non datée restent valides.
Toute option explicite l'emporte sur le preset : hyperfocale({ preset: 'music', prefix: '/albums' }) garde la collection albums mais sert /albums.
Les préfixes sont localisés en français. La spec les donne en anglais, mais sa colonne prefix est une recommandation — §2.0.1 autorise un preset à fixer le sien.
Chaque profil se dote de son propre bloc d'extension au frontmatter — music:, book:, place:… Le schéma les laisse passer tels quels (z.looseObject) : leur forme est décrite par l'Annexe G, pas validée par le plugin.
photoest déprécié. C'était le nom du profil canonique avant que l'Annexe G ne le standardise sous le nomseries. Il continue de fonctionner à l'identique, avec un avertissement au build, et sera retiré en 1.0.
Créez un dossier dans src/content/series/ :
src/content/series/bretagne-2024/
├── index.md
└── media/
├── 01.jpg
├── 02.jpg
└── 03.webp
# src/content/series/bretagne-2024/index.md
---
title: "Bretagne 2024"
date: 2024-06-15
description: "Côtes sauvages du Finistère"
cover: "./media/01.jpg"
location: "Finistère, France"
---
Texte libre affiché avant la galerie de photos.La série apparaît automatiquement sur /series/. Formats acceptés : .jpg .jpeg .png .webp .avif
Le schéma Zod complet (seriesSchema) accepte 19 champs. Seul title est toujours requis ; date l'est sauf si dateRequired: false ou type: section. Le schéma est en mode looseObject — vos champs custom passent sans configuration.
| Champ | Type | Défaut | Description |
|---|---|---|---|
title |
string |
— (requis) | Titre de la série |
date |
date |
— (requis¹) | Date ISO. Tri décroissant sur la liste |
type |
'series' | 'section' |
'series' |
section → page d'index de rubrique, pas une série (voir ci-dessous) |
description |
string |
— | Description courte affichée sur la card |
cover |
image |
— | Couverture. Première image si absent (getSeriesCover) |
location |
string |
— | Lieu associé à la série |
lang |
string |
— | Code langue (ex. fr, en) |
published |
boolean |
true |
Déprécié — false → masquée en production. Faites draft: true (voir ci-dessous) |
draft |
boolean |
false |
true → masquée en production (visible en dev) |
featured |
boolean |
false |
Mise en avant (querySeries({ featured })) |
tags |
string[] |
[] |
Tags libres (getAllTags, filtre querySeries) |
lineup_order |
number |
— | Ordre d'une sous-série dans le line-up de son conteneur (§1.8) |
alt_description |
string |
— | Texte alternatif de la série |
private |
boolean |
false |
Marque la série comme privée |
download |
boolean |
false |
Autorise le téléchargement des originaux |
iptc |
object |
— | Métadonnées IPTC (voir ci-dessous) |
images |
ImageEntry[] |
— | Liste d'images curée — trois formes acceptées (voir ci-dessous) |
attachments |
AttachmentMeta[] |
— | Métadonnées des documents joints locaux |
files |
RemoteFile[] |
— | Documents joints en mode distant |
embeds |
Embed[] |
— | Médias hébergés chez un tiers et joués dans la page — Vimeo, YouTube, SoundCloud… (§1.11, voir ci-dessous) |
¹ Optionnel si l'intégration est configurée avec dateRequired: false, ou si l'entrée déclare type: section.
publishedest déprécié.published: falsefait exactement ce que faitdraft: true, en logique inverse — deux façons d'écrire la même chose, dont une seule est standardisée par la spec (§1.3). C'estdraftqui reste.Le champ continue de fonctionner à l'identique et sera retiré en 1.0. Un build qui rencontre une série
published: falsel'annonce une fois, en nommant les séries concernées. La migration : remplacerpublished: falsepardraft: true, et supprimer lespublished: true— ils ne faisaient rien.
querySeries({ published })suit le même sort : préférezquerySeries({ draft }).
Bloc iptc (tous optionnels, mode looseObject) : creator, credit, copyright, keywords[], city, province, country, country_code, camera, lens, film, headline, instructions, source, gps: { lat, lng }.
Le champ iptc.gps alimente <SeriesMap>.
Un corpus un peu grand range ses séries par rubriques. Un index.md posé sur un dossier de rangement n'est pas une série : il n'a pas de date, et il n'a rien à faire dans la liste des séries. Il se déclare type: section (spec §1.10) :
---
type: section
title: "Archives"
description: "Séries anciennes, rangées par époque."
---
Texte affiché en tête de la page de rubrique.date n'est alors pas requise, et l'entrée est écartée de getSeriesList(), querySeries(), getAllTags(), getAllCollections() et des routes générées. Les séries rangées dans le dossier restent, elles, des séries à part entière.
Deux helpers pour construire la page de rubrique :
const sections = await getSections(); // → les entrées `type: section`, triées par slug
if (isSection(entry)) { /* … */ } // → discriminant expliciteLa distinction se lit uniquement dans type : une série sans date reste une série invalide, jamais une section devinée.
Une série conteneur regroupe des sous-séries liées éditorialement — un festival et ses concerts, un mariage et ses moments (spec §1.8). Elle reste une série à part entière : son propre index.md daté, sa galerie éventuelle, et en fin de page le line-up de ses sous-séries.
src/content/series/festival-2024/
├── index.md ← le conteneur (title + date requis)
├── media/ ← optionnel : ses photos propres
├── set-aurore/
│ ├── index.md
│ └── media/
└── set-crepuscule/…
Les deux niveaux d'URL sont générés automatiquement : /series/festival-2024/ et /series/festival-2024/set-aurore/. Le line-up s'affiche sur la page du conteneur, sans configuration.
Ne pas confondre avec le rangement. archives/music/concerts/<slug>/ est une série rangée en profondeur, pas une sous-série : aucun dossier traversé ne porte d'index.md. L'imbrication commence quand un dossier porteur d'un index.md en contient un autre — et elle est limitée à un niveau.
Le line-up est trié par date décroissante. Pour un ordre éditorial, lineup_order prime :
# festival-2024/set-crepuscule/index.md
lineup_order: 1 # passe devant, quelle que soit sa dateLes sous-séries sans lineup_order suivent celles qui en ont, entre elles par date décroissante. Le helper est exposé pour composer vos propres pages :
const lineup = await getSubSeries('festival-2024'); // Series[]Les slugs peuvent être imbriqués : un dossier voyages/asie/tokyo-2024/ produit le slug voyages/asie/tokyo-2024, servi par les routes catch-all. Le premier segment est la collection parente, exploitable via les helpers :
getParentCollection('voyages/asie/tokyo-2024'); // → 'voyages'
const cols = await getAllCollections(); // → [{ slug: 'voyages', count: 12 }, …]
const asie = await querySeries({ collection: 'voyages' });Par défaut, les images sont lues dans media/ et optimisées par Astro. Vous pouvez à la place référencer des URL distantes dans le frontmatter — elles priment sur media/ :
# Images hébergées ailleurs (CDN, S3…)
images:
- url: "https://cdn.exemple.com/tokyo/01.jpg"
alt: "Shibuya de nuit"
width: 1600
height: 1067
# Documents joints distants
files:
- url: "https://cdn.exemple.com/tokyo/carnet.pdf"
title: "Carnet de voyage"
kind: document # video | audio | document | file (auto-détecté si absent)Le tableau images: accepte trois formes, mélangeables — la première est celle de la spec §1.5, les deux autres sont des extensions du plugin pour curer l'ordre et les alt d'images locales :
images:
- url: "https://cdn.exemple.com/01.jpg" # distante (§1.5)
alt: "Shibuya de nuit"
- file: "02.jpg" # fichier de media/, par nom
alt: "Ruelle de Golden Gai"
- src: "./media/03.jpg" # asset traité par image() du siteLes documents joints locaux (tout fichier non-image dans media/) sont détectés automatiquement ; le bloc attachments: permet d'y attacher un titre/description :
attachments:
- file: "interview.mp3"
title: "Entretien avec l'artiste"
description: "12 min, français"Le tableau images: du frontmatter suppose une liste écrite à la main. Dès qu'elle est générée — synchronisation CDN, pipeline d'optimisation, export depuis un catalogue — l'inscrire dans le frontmatter mélange donnée dérivée et donnée éditoriale : chaque resynchronisation réécrit index.md et pollue son historique Git.
Un fichier images.json posé à côté d'index.md isole cette liste (spec §1.5.1) :
bretagne-2024/
├── index.md
├── images.json
└── media/
{
"images": [
{ "url": "./media/03.jpg", "alt": "Phare de la Pointe Saint-Mathieu" },
"./media/01.jpg",
{ "url": "https://cdn.exemple.com/bretagne/final.jpg", "width": 3000, "height": 2000 }
],
"files": [
{ "url": "https://cdn.exemple.com/bretagne/carnet.pdf", "title": "Carnet" }
]
}Les deux formes sont acceptées : une chaîne équivaut à { "url": <chaîne> }.
| Règle | Comportement |
|---|---|
| Priorité | images: du frontmatter > images.json > scan de media/ |
| Ordre | L'ordre du tableau fait foi — aucun tri alphabétique |
| Résolution | URL absolue (https://…), chemin absolu au site (/…), ou relatif à index.md (./media/01.jpg). Seul le relatif désigne un asset local : il est résolu par Astro, donc optimisé, avec ses dimensions réelles |
| Couverture | cover du frontmatter, sinon la première entrée du tableau |
| Robustesse | JSON illisible, clé images absente ou non-tableau : repli silencieux sur media/, avec un avertissement en console. Jamais d'échec de build |
Les trois modes sont exclusifs par série. Une série portant à la fois un images: et un images.json déclenche un avertissement — le frontmatter l'emporte.
Par défaut, astro:assets traite les images au build : conversion WebP, dimensions, et srcset haute densité pour les écrans Retina.
Le srcset est omis en développement. Quand un site délègue l'optimisation à son hébergeur — @astrojs/vercel, @astrojs/netlify, Cloudflare Images — les URLs générées pointent vers un endpoint (/_vercel/image?…) qui n'existe pas en local : chaque variante du srcset répondrait 404. Le rendu de production est inchangé.
Si vos images sont déjà optimisées en amont, ou servies par un CDN qui s'en charge, court-circuitez le traitement :
hyperfocale({ imageOptimization: 'disabled' })Les fichiers de media/ sont alors servis tels quels, sans conversion ni redimensionnement. Les dimensions déclarées restent transmises au HTML, ce qui préserve la réservation d'espace et évite les décalages de mise en page.
| Route | Description |
|---|---|
/series/ |
Liste de toutes les séries (date décroissante) |
/series/[slug]/ |
Page d'une série — body + galerie paginée |
/series/[slug]/[page]/ |
Pages suivantes de la galerie |
Le préfixe /series est configurable via l'option prefix. Les slugs hiérarchiques (voyages/asie/tokyo-2024) sont gérés par des routes catch-all.
import {
SeriesCard,
SeriesList,
SeriesGallery,
SeriesLightbox,
SeriesAttachments,
SeriesEmbeds,
SeriesFilter,
SeriesMap,
SeriesMasonry,
} from '@regrets/hyperfocale/components';Card d'aperçu : cover, titre, date, description.
| Prop | Type | Requis |
|---|---|---|
series |
Series |
oui |
Grille responsive de SeriesCard.
| Prop | Type | Défaut | Description |
|---|---|---|---|
series |
Series[] |
— | Tableau de séries |
columns |
number |
3 |
Nombre de colonnes |
Galerie paginée avec navigation entre les pages.
| Prop | Type | Description |
|---|---|---|
images |
Image[] |
Images de la page courante |
page |
number |
Numéro de page courant |
totalPages |
number |
Nombre total de pages |
baseUrl |
string |
URL de base pour la pagination |
Visionneuse plein écran. S'ouvre au clic sur une image, navigation ←/→/Esc.
| Prop | Type | Description |
|---|---|---|
images |
Image[] |
Toutes les images de la série |
Galerie en maçonnerie (colonnes de hauteurs variables), alternative à SeriesGallery.
| Prop | Type | Défaut | Description |
|---|---|---|---|
images |
ImageMetadata[] |
— | Images à afficher |
columns |
number |
3 |
Nombre de colonnes |
Liste les documents joints non-image d'une série (vidéo, audio, PDF, fichiers). Alimenté par getSeriesAttachments().
| Prop | Type | Défaut | Description |
|---|---|---|---|
attachments |
Attachment[] |
— | Documents joints résolus |
heading |
string |
« Documents » |
Titre de la section |
Rend les contenus embarqués d'une série (§1.11) en façade : le poster s'affiche, l'iframe n'est insérée qu'au clic. Alimenté par getSeriesEmbeds().
| Prop | Type | Défaut | Description |
|---|---|---|---|
embeds |
Embed[] |
— | Contenus embarqués résolus |
heading |
string |
« Vidéos » |
Titre de la section |
Deux raisons à la façade : onze lecteurs montés d'emblée plombent la page, et chacun dépose ses cookies avant même qu'on ait demandé à voir la vidéo. La façade est un <a href> vers la page de l'hébergeur — sans JavaScript elle reste un lien fonctionnel, il n'y a jamais de bouton mort.
Un embed dont platform n'est pas reconnue, ou dont l'id manque, se rend en lien : c'est la dégradation prévue par la spec, et elle vaut pour tout hébergeur que le plugin ne connaît pas.
Filtres interactifs (par tags, année, lieu) sur une liste de séries.
| Prop | Type | Défaut | Description |
|---|---|---|---|
series |
Series[] |
— | Séries à filtrer |
filters |
('tags' | 'date' | 'location')[] |
tous | Filtres actifs |
prefix |
string |
/series |
Préfixe des liens générés |
Carte des séries géolocalisées (coordonnées lues dans iptc.gps).
| Prop | Type | Défaut | Description |
|---|---|---|---|
series |
Series[] |
— | Séries à placer sur la carte |
height |
string |
— | Hauteur CSS de la carte |
prefix |
string |
/series |
Préfixe des liens générés |
import {
getSeriesList,
getSeriesBySlug,
getSeriesImages,
paginateImages,
} from '@regrets/hyperfocale/helpers';Tous les helpers qui lisent la collection acceptent un nom de collection Astro en
dernier argument. Sans argument, ils lisent celle qu'a configurée l'intégration
(collectionName), sinon series — le comportement historique.
C'est ce qu'attend un site multilingue qui tient une collection par locale : sans cet argument, les helpers servaient la première collection interrogée à toutes les requêtes suivantes du même build, silencieusement.
const en = await getSeriesList(); // collection configurée
const fr = await getSeriesList('series_fr'); // une autre collection du même buildConcerne getSeriesList, getSeriesBySlug, getSections, getSubSeries, getAllTags,
getAllCollections, et querySeries via l'option collectionName. Le cache est indexé
par collection : une lecture par collection et par build, pas une pour toutes.
Toutes les séries triées par date décroissante. Écarte les pages d'index de section (type: section).
const series = await getSeriesList();
// Series[]La collection brute, telle que la rend Astro : aucun filtre, aucun tri. Sections, brouillons et dépubliés compris.
Pour le consommateur qui a ses propres règles de visibilité, de tri ou de pagination et ne peut pas hériter de celles du plugin. Sans lui, il devrait appeler getCollection() en direct et réimplémenter le cache que ce module tient déjà.
const brut = await getAllSeries('series_fr');
const visibles = brut.filter((s) => !s.data.private); // règle propre au siteLe tableau rendu est celui du cache — ne pas le muter. Trier ou filtrer se fait sur une copie :
[...await getAllSeries()].
Les pages d'index de section (spec §1.10) — ce que getSeriesList() écarte.
const sections = await getSections(); // Series[], triées par slug
isSection(entry); // booleanLes sous-séries d'une série conteneur (§1.8), triées par lineup_order puis par date décroissante. Vide pour une série ordinaire.
const lineup = await getSubSeries('festival-2024');
// Series[] — uniquement les entrées un segment plus basUne série par son slug. Lève une erreur si introuvable.
const serie = await getSeriesBySlug('bretagne-2024');
// SeriesImages d'une série, triées alphabétiquement.
const images = await getSeriesImages('bretagne-2024');
// ImageMetadata[]Découpe un tableau d'images en pages.
const { items, totalPages, currentPage, pageSize } = paginateImages(images, 12, 1);API de requête flexible — remplace getSeriesList() dès qu'il faut filtrer, trier ou paginer. Retourne { items, pagination }.
const { items, pagination } = await querySeries({
collectionName: 'series_fr', // collection Astro à lire (défaut : celle configurée)
collection: 'voyages', // premier segment du slug — à ne pas confondre
tags: ['argentique'], // ET-logique (tous les tags requis)
featured: 'first', // true = seulement featured · 'first' = remontées en tête
exclude: ['voyages/asie/tokyo-2024'],
draft: false, // défaut false — `published` est déprécié, cf. frontmatter
sort: 'date', // 'date' (défaut) | 'title' | 'random'
limit: 12,
offset: 0,
});
// pagination: { currentPage, totalPages, totalItems, hasNext, hasPrev }Documents joints non-image d'une série (Attachment[]), triés alphabétiquement. Mode distant (files[]) prioritaire, sinon détection des non-images de media/. Les métadonnées du bloc attachments: sont fusionnées par nom de fichier.
const docs = await getSeriesAttachments('bretagne-2024', serie);
// Attachment[] : { src, kind: 'video'|'audio'|'document'|'file', title, description?, size? }Contenus embarqués d'une série (Embed[]), dans l'ordre du tableau — aucun tri. Chaque entrée reçoit un playable calculé : platform reconnue et id présent.
const embeds = await getSeriesEmbeds('documentaire-2024', serie);
// Embed[] : { url, playable, platform?, id?, title?, description?, poster?, width?, height? }embeds:
- url: "https://vimeo.com/123831041"
platform: vimeo # vimeo · youtube · dailymotion · soundcloud · bandcamp · spotify
id: "123831041" # l'identifiant chez l'hébergeur, pas l'URL
title: "Le film"
description: "74 min"
poster: "./media/poster.jpg"
width: 1920
height: 1080url est le seul champ requis, et c'est délibéré : elle suffit à un rendu valide. La liste des plateformes est ouverte — une valeur inconnue reste licite, l'embed dégrade simplement en lien.
⚠️ Un poster n'est pas une photo de la série. Une image demedia/référencée parembeds[].posterest exclue du scan de galerie. C'est la seule exception au principe « toute image demedia/alimente la galerie » : sans elle, une série de trois vidéos afficherait trois vignettes parasites. L'exclusion ne porte que sur le scan —images:etimages.jsonsont des listes écrites, ce qu'elles nomment est voulu.
Frontière avec getSeriesAttachments() : c'est où vit l'octet, pas la nature du média.
| Document joint §1.9 | Contenu embarqué §1.11 | |
|---|---|---|
Un .mp4 dans media/ |
✅ | ✗ |
Un .mp4 sur son propre CDN (files:) |
✅ | ✗ |
| Une vidéo Vimeo | ✗ | ✅ |
On sert l'octet d'un attachment et une balise native le lit ; l'embed commence là où c'est le lecteur de quelqu'un d'autre qui rend le média.
Première image de la série comme cover de fallback. undefined si aucune image.
const cover = await getSeriesCover('bretagne-2024');
// ImageMetadata | undefinedAgrégations sur toute la collection, triées par fréquence décroissante.
const tags = await getAllTags(); // [{ name: 'argentique', count: 8 }, …]
const cols = await getAllCollections(); // [{ slug: 'voyages', name: 'voyages', count: 12 }, …]Premier segment d'un slug hiérarchique, ou null pour un slug plat. Synchrone.
getParentCollection('voyages/asie/tokyo-2024'); // → 'voyages'
getParentCollection('bretagne-2024'); // → nullClasse un fichier selon son extension : 'video' | 'audio' | 'document' | 'file'. Retourne null pour une image ou index.md. Ne lève jamais d'erreur (extension inconnue → 'file'). Synchrone.
classifyAttachment('interview.mp3'); // → 'audio'
classifyAttachment('01.jpg'); // → null (alimente la galerie, pas les pièces jointes)Version JSON-sérialisable d'une série pour les Astro Islands (React, Vue, Svelte…). Les Date deviennent des chaînes ISO ; la méthode render est omise.
const data = serializeSeries(serie); // { id, collection, body?, data: { …, date?: string } }Nombre d'appels réels à getCollection depuis le chargement du module — un par cache miss. Sert à vérifier que le cache tient sur un build donné.
HYPERFOCALE_DEBUG_CACHE=1 astro build
# [hyperfocale] getCollection("series") — appel #1, 126 entréesMesuré sur un build de 126 séries produisant 127 pages : un seul appel. Le cache module-level survit à l'ensemble du build SSG ; aucun préchauffage explicite n'est nécessaire.
Réinitialise le cache module-level. À appeler dans les teardowns de tests pour éviter les fuites entre cas.
afterEach(() => resetSeriesCache());Le plugin injecte un thème CSS via l'option theme ('light', 'dark', 'auto', 'none').
| Valeur | Effet |
|---|---|
'auto' (défaut) |
Suit prefers-color-scheme |
'light' |
Palette claire, quelle que soit la préférence système |
'dark' |
Palette sombre, quelle que soit la préférence système |
'none' |
Aucune feuille injectée — voir Couche data seule |
'light' et 'dark' posent data-hf-theme sur <html> par un script inline
exécuté dans le <head>, donc avant le premier rendu — le thème demandé s'affiche
sans transition visible. Le plugin ne rendant le <html> que sur son layout de
repli, c'est ce qui permet à l'option de valoir aussi sous votre propre layout et
dans vos pages maison.
Sans JavaScript, l'attribut n'est pas posé et le thème retombe sur 'auto'.
Surchargez les variables dans votre CSS global pour personnaliser l'apparence :
:root {
--hf-color-bg: #ffffff;
--hf-color-text: #111111;
--hf-color-accent: #0066ff;
--hf-font-sans: system-ui, sans-serif;
--hf-gallery-gap: 0.5rem;
--hf-card-radius: 4px;
}Le thème part sur toutes les pages du site, pas seulement les routes injectées.
C'est voulu : un site qui rend SeriesGallery ou SeriesLightbox dans ses propres
pages en a besoin. injectRoutes: false ne le coupe donc pas — les deux options sont
indépendantes, et c'est l'usage des composants qui commande, pas celui des routes.
Un site qui n'utilise ni les routes ni les composants — schéma et helpers seulement,
pages entièrement maison — ne lit aucune des 30 custom properties --hf-*. Elles
partaient malgré tout sur chacune de ses pages : mesuré sur un site réel à 1 643 octets,
29 % de son bundle CSS, entièrement mort. Coupez-les :
hyperfocale({
injectRoutes: false, // je câble mes propres pages
theme: 'none', // …et je n'affiche aucun composant du plugin
})Gardez theme: 'auto' dès que vous rendez un seul composant du plugin, faute de quoi il
s'affichera sans styles.
Pour ajouter des champs custom au frontmatter, utilisez .extend() sur le schéma de base :
// src/content.config.ts
import { defineCollection } from 'astro:content';
import { seriesSchema } from '@regrets/hyperfocale';
import { z } from 'zod';
export const collections = {
series: defineCollection({
type: 'content',
schema: (ctx) =>
seriesSchema(ctx).extend({
tags: z.array(z.string()).optional(),
draft: z.boolean().default(false),
camera: z.string().optional(),
externalUrl: z.string().url().optional(),
}),
}),
};Voir
docs/schema-extensibility.mdpour la documentation complète et les exemples de typage TypeScript.
Les valeurs licites des champs contraints sont exportées par l'entrée racine — utiles pour construire un <select>, écrire un lint de contenu ou valider un import depuis un CMS :
import { CONTENT_TYPES, ATTACHMENT_KINDS, EMBED_PLATFORMS } from '@regrets/hyperfocale';
CONTENT_TYPES // ['series', 'section']
ATTACHMENT_KINDS // ['video', 'audio', 'document', 'file']
EMBED_PLATFORMS // ['vimeo', 'youtube', 'dailymotion', 'soundcloud', 'bandcamp', 'spotify']Les types correspondants suivent le même chemin : ContentType, AttachmentKind, EmbedPlatform, ainsi que Attachment, Embed, SeriesData et SectionData.
Importez-les depuis la racine, jamais depuis @regrets/hyperfocale/helpers : ce sous-chemin importe astro:content et n'est pas chargeable hors d'un runtime Astro — un script Node, un formulaire d'admin ou un test unitaire échouerait à l'import.
EMBED_PLATFORMSest une liste ouverte, pas une contrainte : une plateforme absente reste licite au schéma et dégrade en lien dans<SeriesEmbeds>. La liste sert à reconnaître ce que le plugin sait jouer en façade, pas à refuser le reste.
npx hyperfocale initCrée ou met à jour src/content.config.ts dans le projet consommateur. Trois comportements :
- Fichier absent → crée le fichier avec le template minimal
- Fichier existant sans
series→ injecte l'import et l'entrée dans l'objetcollections - Collection déjà présente → no-op (idempotent)
# Depuis la racine du repo
npm run build # tsup → dist/ (ESM + types)
npm run dev # tsup --watch
npm run typecheck # tsc --noEmit
npm run test:unit # tests unitaires (~1s)
npm test # tous les tests, build Astro inclus (~60s)
npm run pack:dry # vérifier le contenu du packageVoir examples/demo-site/ pour un site consommateur complet.
MIT — © 2026 Mathieu Drouet