@clauzier/astro-kit (0.15.1)
Installation
@clauzier:registry=npm install @clauzier/astro-kit@0.15.1"@clauzier/astro-kit": "0.15.1"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).
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 |
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, URLs absolues, robotsTxt |
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 ; members/island |
components/* |
Composants Astro : SiteLayout, Header, Footer, NotFound, TeamHero, … |
styles/* |
base.css (socle), esport.css, twitch.css, members.css : tout en tokens |
Le barrel racine et ./entities/./blocks ne contiennent rien d'esport, de
twitch ni de members : un site qui n'active pas ces presets n'en embarque rien.
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.
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 »
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.
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.
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), celles du preset
core (staff_grid, socials_grid, depuis listStaff/listClubSocials),
puis les modules qu'elle active.
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, group_by_year, filter_by_game),
next_match. Le filtre par jeu se passe de JavaScript et reste scopé à
l'instance du bloc ; sur les matchs, la puce emprunte son logo à l'équipe qui
joue le jeu, un match n'en portant pas.
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 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.
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). Il
n'importe aucune feuille de style : la coquille charge sa palette puis
base.css dans son propre gabarit.
---
import '@/styles/theme.css';
import '@clauzier/astro-kit/styles/base.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), logout.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/discord/redirect ; 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.
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?) 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) rend la page de compte, avec l'invitation Discord
quand guildMember === false.
En-tête : SiteLayout expose le slot header-end ; la coquille y place
MemberWidget (loginHref, accountHref, logoutAction, meHref), rendu
anonyme puis mis à jour par import '@clauzier/astro-kit/members/island'. La
feuille members.css s'importe après base.css.
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.
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.
base.css porte le reset global du document et n'écrit aucune 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).
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.0 |
| @types/node | ^26.0.0 |
| @types/sanitize-html | ^2.16.1 |
| astro | ^7.2.4 |
| eslint | ^10.0.0 |
| eslint-plugin-astro | ^1.3.0 |
| jsdom | ^30.0.1 |
| prettier | ^3.9.0 |
| prettier-plugin-astro | ^0.14.0 |
| typescript | ~5.9.0 |
| typescript-eslint | ^8.40.0 |
| vitest | ^4.0.0 |
Peer dependencies
| ID | Version |
|---|---|
| astro | ^7.0.0 |