@clauzier/astro-kit (0.22.0)

Published 2026-09-04 08:35:24 +00:00 by 0x346e3730

Installation

@clauzier:registry=
npm install @clauzier/astro-kit@0.22.0
"@clauzier/astro-kit": "0.22.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, 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 | 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. 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, 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 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 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.

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).

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.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
Details
npm
2026-09-04 08:35:24 +00:00
18
UNLICENSED
100 KiB
Assets (1)
Versions (30) View all
0.26.0 2026-09-09
0.25.0 2026-09-07
0.24.0 2026-09-07
0.23.0 2026-09-05
0.22.0 2026-09-04