@clauzier/astro-kit (0.19.1)
Installation
@clauzier:registry=npm install @clauzier/astro-kit@0.19.1"@clauzier/astro-kit": "0.19.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, 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 |
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, 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/* |
base.css (socle), 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.
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 »
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, 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.
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 giveaways (group_by_year, limit, empty_text) : une carte par
tirage, ses lots appariés à leurs gagnants. 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.
Les styles sont dans base.css (réactifs à 900 px et 600 px), sans nouveau
token.
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). 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.
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), rendu
anonyme puis mis à jour par import '@clauzier/astro-kit/members/island'. La
feuille members.css s'importe après base.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 base.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 au 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.
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 |