@clauzier/astro-kit (0.26.0)
Installation
@clauzier:registry=npm install @clauzier/astro-kit@0.26.0"@clauzier/astro-kit": "0.26.0"About this package
@clauzier/astro-kit
Couche front partagée de la plateforme multi-clients : client typé vers cms-core, couche contenu, réglages et libellés, gabarit de site, composants Astro, styles socle et modules verticaux opt-in (esport, twitch, giveaways).
Modules
| Entrypoint | Contenu |
|---|---|
client |
createCmsClient : client typé minimal vers l'API cms-core (dont getSiteSettings) |
entities |
createContentSource, createSiteSettings, normalisation, libellés de schémas |
labels |
createLabels, interpolate : textes d'interface surchargeables depuis les réglages |
layout |
buildSiteHead, pageTitle, footerLine : tête de page depuis les réglages |
theme |
themeStyle, accentInk : couleur d'identité des réglages posée en tokens sur <html> |
pages |
cmsPagePaths, cmsPageSeo, renderCmsPage, notFoundSeo : route fourre-tout et 404 |
blocks |
Registre de renderers + rendu HTML sûr (escapeHtml, sanitizeHtml, safeUrl) |
seo |
Balises meta/OG (dont og:locale et hreflang), URLs absolues, robotsTxt |
i18n |
Langues du site : préfixes et chemins, alternatives hreflang, résolution des traductions |
esport |
Opt-in. withEsport, createEsportBlockRegistry, helpers de fiche équipe |
twitch |
Opt-in. renderWebTv, createWebTvRenderer, statut Twitch ; twitch/island (îlot) |
members |
Opt-in. Espace membres : session par cookie, routes d'auth, page réservée, annuaire public ; members/island |
giveaways |
Opt-in. withGiveaways, createGiveawayBlockRegistry : tirages, lots et gagnants |
preview |
Aperçu du site pour le back-office : protocole postMessage, PreviewMount.astro ; preview/island (îlot) |
components/* |
Composants Astro : SiteLayout, Header, Footer, NotFound, TeamHero, … |
styles/* |
core.css (mécanique, obligatoire), base.css (design de référence), esport.css, twitch.css, members.css, giveaways.css, preview.css : tout en tokens |
Le barrel racine et ./entities/./blocks ne contiennent rien d'esport, de
twitch, de members ni de giveaways : un site qui n'active pas ces presets n'en
embarque rien.
Le kit fournit la mécanique, la coquille possède le design
Deux sites bâtis sur le même kit ne doivent pas se ressembler. Le kit garantit
ce qui fait fonctionner un site — source de contenu, registre de briques,
réglages de section, aperçu du back-office, tête SEO, couleur d'identité,
espace membres, îlots, lien d'évitement — et propose un design de
référence (Header.astro, Footer.astro, base.css, renderers par
défaut) qu'une coquille prend tel quel ou laisse de côté.
Une coquille qui porte son propre design :
- charge
theme.css(sa palette),core.css(la mécanique) puis sa propre feuille, sansbase.css; - passe son en-tête et son pied de page dans les slots
headeretfooterdeSiteLayout(un fragment vide les supprime) ; - remplace le rendu d'une brique du kit par
registry.replace(renderer): le schéma reste commun, le balisage lui appartient ; - écrit une route de code pour toute page qui n'est pas une pile de sections.
Un nouveau client part d'un brief design, jamais du Base.astro d'une autre
coquille.
Couche contenu
Une coquille cliente ne fournit que sa configuration et ses fixtures ; lecture API, normalisation snake_case → camelCase, tris et garde anti-repli viennent du paquet.
import { createContentSource, createSiteSettings } from '@clauzier/astro-kit/entities';
import { withEsport } from '@clauzier/astro-kit/esport';
const source = withEsport(
createContentSource({
baseUrl: import.meta.env.CMS_BASE_URL,
fixtures: { site: createSiteSettings({ name: 'Mon club' }), pages, staff, socials, menus },
avatarColors: ['#1226aa', '#e63946'],
}),
{ teams, players, matches, achievements },
);
export const { getSiteSettings, getLabels, listPageEntries, listTeams, listPalmares } = source;
Sans baseUrl, les fixtures sont servies (CI, previews) et fixtures.site est
obligatoire. Avec une API configurée, toute erreur de lecture fait échouer le
build — y compris une fiche Site absente (« créez et publiez la fiche Site en
back-office ») : un repli silencieux publierait un site plausible mais faux.
Les libellés de schéma (rôles, plateformes…) viennent de /api/content-schemas
via schemaLabels() — ils ne sont jamais recopiés côté front.
listMenu(location) sert les menus résolus par l'API (GET /api/menus, une
seule requête pour tous les emplacements) : URL calculée depuis la page liée
(/ pour l'accueil, /{slug}/ sinon, #ancre suffixée), pages absentes ou
non publiées retirées avec leurs enfants. Une API qui ne les résout pas encore
(404, et capabilities.menus absent de GET /api/block-schemas) fait relire
les entrées telles que stockées ; une entrée sans URL exploitable est sautée.
Un 404 alors que l'API annonce résoudre les menus remonte en erreur : le build
ne publie pas un site sans navigation pendant un redémarrage de l'API. Chaque MenuEntry peut porter kind
(page | url | anchor) et target: '_blank', que Header et Footer
rendent avec rel="noopener".
Réglages et libellés
GET /api/site sert la fiche singleton site : identité, contact, SEO et
surcharges de textes. getSiteSettings() la normalise (SiteSettings, champs
absents à null, champs hors contrat dans extra, ex. twitch_channel) et la
lit une fois par build.
Chaque texte humain a une clé de libellé, avec un défaut dans le module qui le rend et une surcharge possible depuis les réglages :
const labels = await getLabels(); // surcharges API > défauts TS > clé
labels.t('footer.copyright', { year: 2030, name: 'Mon club' }); // « © 2030 Mon club »
labels.t('header.menu'); // « Menu »
const en = await getLabels('en'); // défauts anglais du socle, surcharges anglaises du site
t() rend du texte brut, toujours échappé à l'affichage : une surcharge
<b>…</b> s'affiche telle quelle. Les défauts génériques vivent dans
labels/defaults.ts, ceux des modules dans esport/labels.ts et
twitch/labels.ts ; withEsport étend getLabels() avec les siens, et chaque
renderer garantit les siens quoi qu'on lui passe. La liste des clés est figée
par tests/labelsCatalog.test.ts, miroir du LabelCatalog de cms-core :
ajouter une clé se fait des deux côtés, jamais dans une coquille — et dans
les deux langues : chaque jeu expose X_LABEL_DEFAULTS (français) et
X_LABELS_EN, et le type refuse un jeu anglais incomplet.
Les libellés s'injectent par closure : createCoreBlockRegistry(source, { labels }),
createEsportBlockRegistry(source, { labels }), createWebTvRenderer({ labels }), createAwesomeFormsRenderer({ labels }),
prop labels des composants, PartContext.labels des renderers de fragments.
Langues
cms-core.locales déclare les langues, la première servie à la racine, les
suivantes sous leur préfixe (/en/). Le site les lit par
await source.getLocales() ; une seule langue rend toute la mécanique
inerte et le rendu identique à celui d'avant.
Chaque accesseur de la source prend la langue en dernier argument, et le contenu est lu une fois par build quel que soit le nombre de langues :
const locales = await source.getLocales();
const [settings, labels, menu] = await Promise.all([
getSiteSettings(locale),
getLabels(locale),
listMenu('header', locale),
]);
Routage : cmsPagePaths({ source, locales }) émet les chemins préfixés, donc
la route fourre-tout [...slug].astro existante couvre /en/… sans nouveau
fichier. Une route de code passe sous un reste vide en tête —
[...locale]/serveurs/[slug].astro — et localeParam(locales, locale) rend
undefined pour la langue de la racine, ce qui efface le segment. Les chemins
privés se déclinent par localePaths(locales, '/prive/'), pour robots.txt et
le filtre du plan de site.
Tête SEO : SiteLayout prend locale (qui pose lang et og:locale) et
alternates, construites par
hreflangLinks(locales, cmsPageAlternates(entry, locales), Astro.site) —
x-default désigne la langue de la racine. LocaleSwitcher.astro rend un lien
par langue vers le même contenu, sans une couleur : la coquille l'habille.
Aperçu du back-office : <PreviewMount locale={locale} /> suffit — le montage
lit la langue dans le DOM, résout le brouillon dedans et rend l'anglais comme
le fera le site. La page d'aperçu se décline donc par langue
([...locale]/[apercu].astro).
Traductions du contenu : la clé réservée translations des charges utiles de
cms-core est résolue dans la couche de normalisation, seul endroit du front qui
la connaisse. resolveTranslations descend dans les éléments de liste, un
champ vide laissant voir l'original. Hors API, les fixtures peuvent porter la
même clé, plus fixtures.locales et fixtures.labels (surcharges par langue).
Registres de briques : défauts + core + modules
Une coquille compose son registre par familles : les briques par défaut
(heading, paragraph, hero, section, image, form, et les sections
génériques text_image, columns, cta, gallery, faq, quote,
divider), celles du preset core (staff_grid, socials_grid, depuis
listStaff/listClubSocials), puis les modules qu'elle active.
registry.replace(renderer) substitue le rendu d'un type déjà enregistré
(un type inconnu est une erreur, comme un doublon pour register) : une
coquille garde le schéma du kit et rend la brique dans son propre balisage.
staff_grid groupe les fiches par section quand le schéma en déclare, dans
l'ordre du contrat et sous les libellés qu'il porte ; sans section déclarée la
grille reste plate. teams_grid fait de même avec le champ « Jeu » d'une
équipe, et affiche le libellé du contrat plutôt que la valeur stockée.
Aucun chemin du site n'est supposé par le socle : la coquille fournit sa base d'URL d'équipe et ses liens de repli, et sans lien il n'y a pas de bouton.
import { createCoreBlockRegistry, createDefaultBlockRegistry } from '@clauzier/astro-kit/blocks';
import { createEsportBlockRegistry } from '@clauzier/astro-kit/esport';
import { createWebTvRenderer } from '@clauzier/astro-kit/twitch';
const registry = createDefaultBlockRegistry({ layout: 'section', form });
createCoreBlockRegistry(source, { registry, labels });
createEsportBlockRegistry(source, {
registry,
labels,
clubName: settings.name,
teamBase: '/sections/',
links: { calendar: '/calendrier/', tv: '/direct/' },
});
registry.register(
createWebTvRenderer({
apiBaseUrl: source.apiBaseUrl,
channel: String(settings.extra.twitch_channel ?? ''),
labels,
}),
);
Briques esport : teams_grid, matches (mode: results, display: rows pour
la liste détaillée plutôt que la bande de pastilles, filter_by_game),
palmares (mode: tournaments | all, category, group_by_year,
filter_by_game, filter_by_category), next_match. Les filtres à puces se
passent de JavaScript, restent scopés à l'instance du bloc et se cumulent :
jeu et catégorie ont chacun leur rangée. Sur les matchs, la puce emprunte son
logo à l'équipe qui joue le jeu, un match n'en portant pas.
Une entrée de palmarès porteuse d'un classement ouvre sa fiche dès que le site
donne une achievementBase à createEsportBlockRegistry — sans elle, aucune
ligne n'est cliquable. getAchievement(slug), AchievementHero et Standings
composent cette page ; renderStandings ordonne par place et repousse les non
classés.
Fiche équipe : splitRoster(roster, staffRoles) et teamNote(team, staff, labels).
L'encadrement se reconnaît à la case « Encadrement » de la fiche joueur ; staffRoles
(['coach'] par défaut, comparé à la casse et aux espaces près) ne sert que de repli
pour les fiches antérieures à cette case.
Les trois visuels d'une équipe ont chacun leur place : banner en fond de
carte et de bandeau, logo en pastille, game_logo en filigrane de carte —
et en pastille de repli tant qu'aucun logo d'équipe n'est renseigné.
Bloc giveaways (group_by_year, limit, empty_text) : une carte par
tirage, ses lots appariés à leurs gagnants, chacun annoncé par « Gagnant ». Un
gagnant vide rend « Tirage à venir » plutôt qu'une case blanche. La feuille giveaways.css s'importe après
base.css.
Bloc webtv : coquille statique (jamais de faux LIVE), hydratée côté client
par import '@clauzier/astro-kit/twitch/island' dans le gabarit — l'îlot lit
ses textes en data-label-* et hydrate aussi les comptes à rebours. Sans canal
ni origine d'API, seule la coquille est rendue. La pastille flottante est
twitch/components/LivePill.astro. Les feuilles esport.css et twitch.css
sont importées explicitement par la coquille ; twitch.css attend le token
--live.
Briques génériques
createDefaultBlockRegistry enregistre aussi les sections à cellules déclarées
par cms-core (registerSectionBlocks(registry, { layout, media })), avec les
noms de champs du contrat de blocs :
| Type | Champs (data) |
Rendu (gabarit section) |
|---|---|---|
text_image |
kicker, title, text (richtext), image, image_alt, image_side (left|right, right par défaut), cta_label, cta_url |
.block-text-image(-left) > .wrap.split > .split-body (kicker, h2, .prose, .actions) + .split-media > img.split-image |
columns |
title, intro (richtext), cells : liste de {title, text, image, cta_label, cta_url} |
.block-columns > .sec-head + .cols.cols-{2..4} (borné) > article.col-card (img.col-image, .col-body : h3, .prose, .actions > .btn-ghost) |
cta |
kicker, title, text (richtext), button_label, button_url, secondary_label, secondary_url |
.block-cta > .wrap.cta-box (kicker, h2, .sub, .actions > .btn-primary + .btn-ghost) ; sans URL sûre, pas de bouton |
gallery |
title, images : liste de {image, alt, caption}, columns (2|3|4, 3 par défaut) |
.block-gallery > .gallery.gallery-cols-N > figure.gallery-item (img, figcaption) ; URL refusée sautée, vide ⇒ rien |
faq |
title, items : liste de {question, answer} (richtext) |
.block-faq > .faq > details.faq-item > summary.faq-question > span.faq-question-label + .faq-answer.prose ; sans JavaScript |
quote |
text (richtext), author, role, image |
.block-quote > figure.quote (img.quote-portrait, blockquote.quote-text, figcaption.quote-author > span.quote-role) |
divider |
style (line|space), size (small|medium|large) |
.block-divider.block-divider-{style}.block-divider-{size} > .wrap > <hr> (aucun pour space) |
Les listes (cells, images, items) acceptent un tableau ou son JSON encore
sérialisé (listOf). En gabarit bare, seule la grille intérieure est rendue.
Le design de référence de ces sections est dans base.css (réactif à 900 px et
600 px), sans nouveau token ; une coquille qui n'en veut pas stylise ces
classes elle-même, ou remplace le renderer (registry.replace).
Réglages de section
Chaque bloc peut porter des settings, dans le vocabulaire que cms-core
valide : background (default|panel|accent|image), background_image (media,
avec background: image), spacing (compact|normal|large), align
(left|center), anchor (slugifié), hidden. normalizeBlockSettings les lit
défensivement (valeur inconnue ⇒ défaut, image via safeUrl, non-objet ⇒
undefined) ; getPageBlocks et le client membres les normalisent déjà.
renderBlocksToHtml les applique dans la balise ouvrante de chaque brique
(withSettings, jamais d'enveloppe : le rythme main > .block en dépend) :
classes block-bg-panel|accent|image, block-spacing-compact|large,
block-align-center, id = anchor (prime sur l'id du renderer), et pour un
fond image un <img class="block-bg-media"> préposé, optimisé par media
(astroMedia par défaut, passthroughResolver pour un rendu à la requête).
Sans réglage, ou au défaut, le rendu est byte-identique. Un bloc hidden est
sauté en production ; includeHidden: true (l'aperçu) le rend avec
block-hidden, et cmsPageSeo l'ignore. renderCmsPage accepte media.
Gabarit de site et pages CMS
SiteLayout.astro rend la tête SEO (titre suffixé, favicon, OG de repli),
l'en-tête (logo et CTA des réglages), le lien d'évitement, <main id="contenu">
et le pied de page (« © année nom · mention légale », ou footer_text, plus le
lien des mentions légales quand legal_url est renseigné). Il
n'importe aucune feuille de style : la coquille charge sa palette, puis
base.css si elle prend le design de référence, puis core.css — toujours
après base.css, jamais avant — dans son propre gabarit.
Les slots header et footer remplacent l'en-tête et le pied du kit
(<SiteHeader slot="header" />) ; un <Fragment slot="header" /> vide les
supprime, pour une page immersive. header-end reste le point d'insertion
dans l'en-tête du kit (widget de compte).
---
import '@/styles/theme.css';
import '@clauzier/astro-kit/styles/base.css';
import '@clauzier/astro-kit/styles/core.css';
import SiteLayout from '@clauzier/astro-kit/components/SiteLayout.astro';
import { getLabels, getSiteSettings, listClubSocials, listMenu } from '@/lib/content.js';
const [settings, labels, header, footer, socials] = await Promise.all([
getSiteSettings(),
getLabels(),
listMenu('header'),
listMenu('footer'),
listClubSocials(),
]);
---
<SiteLayout
{settings}
{labels}
{socials}
menus={{ header, footer }}
brandSplit={3}
{...Astro.props}
>
<slot />
</SiteLayout>
Route fourre-tout des pages composées en back-office, en une quinzaine de lignes :
---
import { cmsPagePaths, cmsPageSeo, renderCmsPage } from '@clauzier/astro-kit/pages';
export async function getStaticPaths() {
return cmsPagePaths({ source, homeSlug: 'accueil', reserved: ['404', 'sections'] });
}
const { entry } = Astro.props;
const seo = cmsPageSeo(entry);
const html = await renderCmsPage(entry, { registry, path: Astro.url.pathname });
---
<Base title={seo.title} description={seo.description} ogImage={seo.ogImage}>
<Fragment set:html={html} />
</Base>
reserved accepte des préfixes ou un prédicat asynchrone ; un slug réservé ou
un accueil absent (avec API) cassent le build avec un message explicite.
NotFound.astro (settings, labels?, links?) et notFoundSeo() couvrent
la 404 ; robotsTxt({ siteUrl }) sert robots.txt.
Espace membres (opt-in)
Le module members rend les pages réservées à la requête : le site passe
en hybride (@astrojs/node, output: 'static'), les pages publiques restent
pré-rendues et le serveur Node ne sert que l'espace membres. La session est un
cookie httpOnly (cms_member) qui porte le token Sanctum émis par cms-core ;
aucun store côté site, un rebuild ne déconnecte personne.
Côté coquille, tout tient dans un module et quatre fichiers de route :
// src/lib/members.ts
import { createMemberRoutes, createMembersClient } from '@clauzier/astro-kit/members';
// import.meta.env est figé au build : l'espace membres lit l'API à la requête.
const baseUrl = process.env.CMS_BASE_URL ?? import.meta.env.CMS_BASE_URL;
export const membersClient = createMembersClient({ baseUrl });
export const memberRoutes = createMemberRoutes({ client: membersClient, accountPath: '/compte/' });
// src/pages/session/login.ts — idem callback.ts (GET), link.ts (GET),
// logout.ts (POST), unlink.ts (POST), me.ts (GET)
export const prerender = false;
export const GET = memberRoutes.login;
prerender = false doit être écrit littéralement dans chaque fichier de
route : Astro le détecte par lecture du source, pas via un réexport. Les
chemins (/compte/, /session/login…) appartiennent à la coquille ; le socle
ne les connaît que par ses props. Le préfixe des pages réservées et celui des
routes d'auth s'ajoutent au reserved de cmsPagePaths, qui ignore de toute
façon les pages visibility: 'members'.
Parcours : login pose un nonce (cookie sous le dossier des routes) et part
vers /api/auth/{provider}/redirect (?provider=, discord par défaut) ;
l'API revient sur callback avec un code à usage unique, échangé contre le
token seulement si le nonce correspond ; logout (POST uniquement) révoque
côté API et efface le cookie ; me rend { user } en JSON, jamais mis en
cache, sans appeler l'API quand il n'y a pas de cookie. safeReturnTo
n'accepte qu'un chemin relatif au site.
Comptes liés
Un membre entre par Discord ou par Steam et rattache l'autre ensuite.
user.identities porte ses comptes de fournisseur, avec la charge propre à
chacun (data : heures de jeu Steam, bannissements VAC…).
membersClient.providers() liste ce que l'API sait servir (GET /auth/methods) :
la coquille n'affiche que ces boutons-là, sinon un fournisseur non configuré mènerait à
un 503. link (?provider=) échange le token du membre contre un ticket à usage
unique côté API — un Bearer ne survit pas à une redirection de navigateur —
puis redirige ; unlink (POST, ?provider=) détache et rafraîchit le cookie
d'affichage. Le retour passe par le même callback, qui reconnaît
?linked=<provider> et ?error=already_linked en plus du code de connexion et
ramène au return_to avec ?lie=<provider> ou ?erreur=deja-lie. Le nonce
est vérifié dans tous les cas.
Page réservée :
---
export const prerender = false;
import { loadMemberPage, memberHeaders } from '@clauzier/astro-kit/members';
import MemberGate from '@clauzier/astro-kit/members/components/MemberGate.astro';
import { passthroughResolver } from '@clauzier/astro-kit/blocks';
memberHeaders(Astro.response.headers);
const result = await loadMemberPage(Astro, { client: membersClient, slug });
if (result.state === 'missing') return new Response(null, { status: 404 });
if (result.state === 'forbidden') Astro.response.status = 403;
const html = result.state === 'ok' ? await renderCmsPage(result.entry, { registry, path }) : '';
---
MemberGate (state: anonymous | forbidden | unavailable, loginHref?,
locale?) rend l'état bloquant ; sans CMS_BASE_URL (fixtures) le client n'appelle rien et la
coquille rend unavailable. Les médias d'une page réservée passent par
passthroughResolver (media du registre) : pas d'optimisation à la requête.
MemberAccount (user, roles du catalogue via memberSettings(settings),
inviteUrl, logoutAction, providers, linkAction, unlinkAction,
linkError, locale?) rend la page de compte, avec l'invitation Discord quand
guildMember === false et la liste des comptes liés quand linkAction est
posé. Son slot extra accueille ce que la coquille sait en plus — une fiche de
jeu, par exemple : le socle n'a pas à connaître le domaine du client.
Ces trois composants reposent les défauts members sur les libellés reçus : sans
locale, ils les reposent en français et écrasent une traduction venue des
défauts. Un site multilingue passe donc locale partout où il les monte.
Bloc members_directory : annuaire public des membres, une section par rôle
marqué public dans le catalogue, dans l'ordre du catalogue. La coquille est
rendue au build (titres des sections) et l'îlot members/island la remplit
depuis GET /api/members — une connexion Discord ne déclenchant aucun rebuild,
une liste figée au build vieillirait jusqu'au déploiement suivant. Sans origine
d'API ni rôle public, seule la coquille est rendue, sans îlot. Un annuaire
injoignable annonce son indisponibilité, jamais une liste vide.
En-tête : SiteLayout expose le slot header-end ; la coquille y place
MemberWidget (loginHref, accountHref, logoutAction, meHref,
locale?), rendu anonyme puis mis à jour par
import '@clauzier/astro-kit/members/island'. La
feuille members.css s'importe après core.css. Le cookie cms_member_ui
(nom, avatar ; lisible par le navigateur, sans secret) évite le flash de
« Se connecter » : le widget le rend avant toute requête, puis me confirme.
Derrière un proxy, poser security.allowedDomains dans astro.config.mjs
(depuis SITE_URL) : sans lui Astro.url reste en http://localhost et le
contrôle d'origine refuse le POST de déconnexion. Le drapeau Secure des
cookies se déduit d'Astro.site, jamais d'Astro.url.
Aperçu du site (back-office)
L'éditeur de pages du back-office montre le brouillon dans le vrai site : la
coquille expose une page /_apercu/ — thème, en-tête, pied de page et menus
réels — que l'admin charge dans une iframe et pilote par postMessage. Le
rendu se fait dans le navigateur avec les renderers du site ; rien n'est
dupliqué côté admin, qui ne dépend pas d'astro-kit.
Côté coquille, une page et un module :
---
// src/pages/[apercu].astro
import Base from '@/layouts/Base.astro';
import { previewStaticPaths } from '@clauzier/astro-kit/preview';
import PreviewMount from '@clauzier/astro-kit/preview/components/PreviewMount.astro';
import { apiBaseUrl, env, getLabels, getSiteSettings } from '@/lib/content.js';
export function getStaticPaths() {
return previewStaticPaths('apercu');
}
const [settings, labels] = await Promise.all([getSiteSettings(), getLabels()]);
---
<Base title="Aperçu" noindex>
<PreviewMount
{settings}
{labels}
{apiBaseUrl}
adminUrl={env('CMS_ADMIN_URL')}
formsBaseUrl={env('FORMS_BASE_URL')}
/>
</Base>
<script>
import { mountPreview } from '@clauzier/astro-kit/preview/island';
import { createPreviewRegistry, hydratePreview } from '@/lib/preview.js';
mountPreview({ createRegistry: createPreviewRegistry, hydrate: hydratePreview });
</script>
// src/lib/preview.ts
import type { PreviewHydrator, PreviewRegistryFactory } from '@clauzier/astro-kit/preview';
import { hydrateCountdowns } from '@clauzier/astro-kit/islands';
import { hydrateMemberDirectory } from '@clauzier/astro-kit/members';
import { hydrateTwitch, twitchLabels } from '@clauzier/astro-kit/twitch';
export const createPreviewRegistry: PreviewRegistryFactory = ({
settings,
labels,
apiBaseUrl,
formsBaseUrl,
fetchImpl,
media,
}) =>
createSiteBlockRegistry({
settings,
labels: twitchLabels(labels),
media,
apiBaseUrl,
formsBaseUrl,
source: createSiteSource({ baseUrl: apiBaseUrl, fetchImpl }),
});
export const hydratePreview: PreviewHydrator = (root) => {
const stopCountdowns = hydrateCountdowns(root);
const stopTwitch = hydrateTwitch(root);
hydrateMemberDirectory(root);
return () => {
stopCountdowns();
stopTwitch();
};
};
PreviewMount (settings, labels?, apiBaseUrl, adminUrl, formsBaseUrl?)
se place dans le <main> du gabarit : il porte les réglages normalisés du site
(l'aperçu fonctionne donc aussi en mode fixtures), l'origine admise et le
message de veille. mountPreview remplace le contenu du <main> à chaque
rendu ; createRegistry est appelée à chaque rendu (des renderers portent
des compteurs) avec media: passthroughResolver, un fetchImpl qui dédoublonne
les lectures de données 30 s durant, et labels déjà munis des défauts
génériques et de l'aperçu. hydrate reçoit le fragment rendu et rend un
disposer : hydrateTwitch, hydrateCountdowns et hydrateAll en rendent un,
sinon les sondages de l'aperçu précédent continueraient. Le module ne doit rien
importer de @clauzier/astro-kit/media à l'exécution : c'est un bundle client.
Les renderers reçoivent context.preview === true ; une section dont
settings.hidden vaut true est rendue grisée (.block-hidden) au lieu d'être
sautée. Chaque section porte data-cms-key dans sa balise <section>, un
rendu vide devient « Section vide », un renderer qui échoue un repère
cms-preview-error (la clé est signalée dans errors), un type inconnu du site
« Section inconnue de ce site ». La feuille preview.css (survol, sélection,
repères) s'importe après core.css, dans le gabarit ou dans la page d'aperçu.
Protocole (protocol: 1) :
| Sens | Message |
|---|---|
| site → admin | { type: 'cms:ready', protocol } au montage, réémis peu après le load si l'admin n'a rien dit |
| admin → site | { type: 'cms:preview', protocol, page: { id, slug, title }, blocks: [{ key, type, data, settings }], selected } |
| admin → site | { type: 'cms:select', key } — sélection seule, null pour l'ôter |
| site → admin | { type: 'cms:select', key } au clic sur une section ; { type: 'cms:rendered', keys, errors } après rendu |
L'origine admin est connue au build (CMS_ADMIN_URL, la même valeur que côté
API) : l'îlot n'accepte un message que si event.origin est cette origine
et event.source la fenêtre qui a ouvert la page (parent ou opener), et
ne répond jamais à '*'. Sans CMS_ADMIN_URL, ou ouverte directement dans un
onglet, la page reste sur son message de veille. Dans l'aperçu, liens et
formulaires ne naviguent pas : un clic sélectionne la section.
L'API sert preview_url (site_url + '/_apercu/') dans GET /api/site ;
c'est ce que l'admin charge. Le chemin _apercu (PREVIEW_PATH) est réservé :
l'ajouter au reserved de cmsPagePaths, l'exclure du sitemap
(sitemap({ filter })) et l'interdire dans robotsTxt({ disallow }) ; la page
elle-même se déclare noindex.
Composants et styles
Le Header ne charge aucun script : le burger mobile et les sous-menus sont
des <details>/<summary>, dont l'état d'ouverture est exposé nativement aux
technologies d'assistance. Au-dessus de 900 px, [open] est neutralisé par la
feuille socle et les sous-menus reviennent au survol. L'entrée parente d'un
sous-menu n'est plus un lien — ajouter un « Tout voir » en tête de ses enfants
si elle porte une page propre.
core.css porte la seule mécanique que l'éditeur promet à toute coquille :
lien d'évitement, .visually-hidden, réglages de section (block-bg-*,
block-spacing-*, block-align-center, block-bg-media). base.css est le
design de référence : reset, typographie, en-tête, boutons, cartes, sections
génériques. Aucune des deux n'écrit de couleur en dur : tout passe par des
tokens (--bg, --panel, --accent, --muted, --font-display…) que la
coquille définit dans sa propre feuille de palette (tokens.css en donne le
contrat et un point de départ neutre). core.css se charge après base.css :
ses règles d'espacement l'emportent sur le rythme du design de référence.
Le réglage « Couleur d'identité » de la fiche Site (theme_accent) surcharge
--accent et son encre : SiteLayout pose les deux tokens en style en ligne
sur <html>, ce qui l'emporte sur la feuille de palette sans dépendre de
l'ordre d'injection. Une valeur absente ou hors format ne pose rien — le site
garde la palette de sa coquille.
Développement
bun install
bun run lint && bun run format:check && bun run typecheck && bun run test
Dependencies
Dependencies
| ID | Version |
|---|---|
| sanitize-html | ^2.17.7 |
Development dependencies
| ID | Version |
|---|---|
| @eslint/js | 10.0.1 |
| @types/node | 26.4.1 |
| @types/sanitize-html | 2.16.1 |
| astro | 7.3.1 |
| eslint | 10.10.0 |
| eslint-plugin-astro | 3.1.0 |
| jsdom | 30.0.1 |
| prettier | 3.9.6 |
| prettier-plugin-astro | 0.14.1 |
| typescript | 6.0.3 |
| typescript-eslint | 8.69.0 |
| vitest | 5.0.0 |
Peer dependencies
| ID | Version |
|---|---|
| astro | ^7.0.0 |