duxt émet [un seul hook de build](/reference/hooks), et il remet chaque page de
chaque source, version et langue sous forme d’enregistrements plats. Un site qui
exploite déjà **Typesense** en fait une collection à lui — sans re-explorer le
site rendu et sans réimplémenter la logique de sources, de versions et de
locales de duxt.

::callout{type="info" title="Le service est le vôtre, pas celui de duxt"}
duxt n’embarque aucun SDK Typesense, aucune information d’identification ni configuration de service. La couche complémentaire facultative gère l’intégration ; le service et ses clés restent les vôtres. Sans fournisseur ni écouteur personnalisé, duxt [ne construit aucune charge utile](/reference/hooks).
::

## Utiliser la couche complémentaire

Pour l’intégration prête à l’emploi, installe `@kirchdev/duxt-typesense` et étends-la après duxt. Elle synchronise l’ensemble complet via une collection temporaire et un changement d’alias, supprime la collection précédente gérée et relie le dialogue de recherche existant avec une clé de recherche seule. Les étapes suivantes restent une recette pour une synchronisation personnalisée.

::package-managers{command="add @kirchdev/duxt-typesense"}
::

```ts [nuxt.config.ts]
export default defineNuxtConfig({
  extends: ['@kirchdev/duxt', '@kirchdev/duxt-typesense'],
  duxtTypesense: {
    endpoint: process.env.TYPESENSE_ENDPOINT,
    collection: 'docs',
    adminKey: process.env.TYPESENSE_ADMIN_KEY,
    searchKey: process.env.TYPESENSE_SEARCH_KEY
  }
})
```

Garde la clé d’administration exclusivement dans la configuration de build. `searchKey` doit autoriser uniquement `documents:search` pour cet alias, et Typesense doit accepter les requêtes du navigateur depuis ton site.

## Étapes

::steps

1. **Installez le client dans votre site.** C’est une dépendance de build : la
   synchronisation tourne dans Node pendant `nuxt build`, jamais dans le
   navigateur.

   ::package-managers{command="add -D typesense"}
   ::

2. **Déclarez la collection.** Chaque champ d’un enregistrement duxt correspond
   à un champ Typesense, et les deux champs de provenance sont ceux sur
   lesquels un lecteur filtre :

   ```ts [server/typesense-schema.ts]
   import type { CollectionCreateSchema } from 'typesense';

   export const docs: CollectionCreateSchema = {
     name: 'docs',
     fields: [
       { name: 'url', type: 'string' },
       { name: 'title', type: 'string' },
       { name: 'content', type: 'string' },
       { name: 'source', type: 'string', facet: true },
       { name: 'version', type: 'string', facet: true, optional: true },
       { name: 'locale', type: 'string', facet: true }
     ]
   };
   ```

   Typé en `CollectionCreateSchema`, car c’est cet objet même que l’étape 4
   remet à Typesense pour créer la collection.

   `id` n’est pas dans la liste, à dessein : Typesense traite déjà `id` comme la
   clé primaire du document, et l’`id` de duxt est
   [une empreinte hexadécimale de l’URL](/reference/hooks) — valide comme clé,
   identique sur toutes les machines et inchangée tant que la page reste où elle
   est. C’est ce qui permet à la synchronisation de faire un **upsert** plutôt
   qu’une reconstruction.

   `version` est `optional`, car une source à une seule version n’en porte
   aucune. La version est délibérément retirée de `source`, pour qu’une facette
   bâtie dessus survive à une publication et que `version` à côté soit ce qui
   restreint à une seule.

3. **Écrivez le module qui écoute.** Un module Nuxt dans votre propre site est
   toute l’intégration — `defineNuxtModule`, un hook, un import :

   ```ts [modules/typesense.ts]
   import { defineNuxtModule } from '@nuxt/kit';
   import { Client } from 'typesense';
   import type { DuxtSearchRecord } from '@kirchdev/duxt/search-records';
   import { docs } from '../server/typesense-schema';

   export default defineNuxtModule({
     meta: { name: 'typesense-sync' },
     setup(_options, nuxt) {
       const key = process.env.TYPESENSE_ADMIN_KEY;
       // Pas de clé, c’est un build local, pas un échec : on saute plutôt que
       // de le casser.
       if (!key) return;

       nuxt.hook('duxt:search:records', async (records) => {
         const client = new Client({
           nodes: [{ url: process.env.TYPESENSE_URL! }],
           apiKey: key
         });

         await sync(client, records);
       });
     }
   });
   ```

   La charge utile est **gelée**. Transformez-la en documents à vous — ne
   réécrivez jamais un enregistrement et ne triez pas le tableau sur place, car
   tous les autres auditeurs reçoivent les mêmes objets.

4. **Synchronisez l’ensemble complet, et retirez ce qui a disparu.** Le hook
   remet l’ensemble *complet* et courant à chaque build : l’ensemble est donc
   lui-même l’instruction — tout ce que la collection contient et qui n’y figure
   pas a été supprimé, déplacé ou renommé :

   ```ts [modules/typesense.ts]
   async function sync(client: Client, records: readonly DuxtSearchRecord[]) {
     const collection = client.collections(docs.name);

     // Typesense ne crée rien implicitement : importer dans une collection
     // qui n’existe pas échoue net, c’est donc le premier build qui la crée.
     if (!(await collection.exists())) await client.collections().create(docs);

     await collection.documents().import(
       records.map((record) => ({ ...record })),
       { action: 'upsert' }
     );

     // Tout ce que le build n’a pas remis n’est plus sur le site.
     const kept = new Set(records.map((record) => record.id));
     const live = await collection.documents().export();

     const stale = live
       .split('\n')
       .filter(Boolean)
       .map((line) => JSON.parse(line).id as string)
       .filter((id) => !kept.has(id));

     // Supprimez par lots : chaque id entre dans la chaîne du filtre, et un
     // seul gros renommage en bâtirait un que Typesense refuse.
     for (let index = 0; index < stale.length; index += 100) {
       const batch = stale.slice(index, index + 100).join(',');
       await collection.documents().delete({ filter_by: `id:[${batch}]` });
     }
   }
   ```

   La collection est créée au premier passage puis laissée telle quelle, et les
   ids périmés sont supprimés **par lots** — Typesense prend le filtre comme une
   seule chaîne, et un renommage déplaçant une source entière en bâtirait
   autrement un qu’il refuse.

   ::callout{type="tip" title="Ou échangez plutôt un alias"}
   Importez dans une collection nommée d’après le build, pointez un **alias**
   Typesense dessus, puis jetez la précédente. Chaque build crée sa propre
   collection : ni la vérification d’existence ni la suppression par lots ne
   sont nécessaires, et les lecteurs ne voient jamais un index à moitié écrit —
   au prix d’une réindexation complète à chaque build plutôt que du seul delta.
   ::

5. **Cherchez depuis le navigateur avec une clé de recherche seule.** La clé
   d’administration ci-dessus signe les écritures et ne doit jamais atteindre
   une page. Émettez une clé **restreinte, en lecture de recherche seule** pour
   le client, et filtrez sur les facettes que le schéma a déclarées :

   ```ts
   // `locale` est la langue du CONTENU, pas la langue de l’interface.
   // `version` est absente d’une source sans versions : la clause ne s’ajoute
   // que s’il y en a une ; figée, elle filtrerait sur `undefined`.
   const filters = [`locale:${locale}`];
   if (version) filters.push(`version:${version}`);

   const results = await client.collections('docs').documents().search({
     q: query,
     query_by: 'title,content',
     filter_by: filters.join(' && ')
   });
   ```

6. **Vérifiez un build de bout en bout.** Lancez un build avec la clé, et
   confirmez que la collection contient un enregistrement pour une page dans
   **chaque** source, version et langue que le site sert — pas seulement la
   langue par défaut. Le premier build crée la collection ; chacun d’après fait
   un upsert dans la même.

::

## Ce que les enregistrements règlent déjà pour vous

Quatre propriétés de la charge utile rendent une synchronisation aussi courte
correcte, et toutes les quatre relèvent du
[contrat du hook](/reference/hooks), non du conseil de ce guide :

| Propriété | Ce qu’elle apporte à la synchronisation |
| :--- | :--- |
| `id` dérive de `url` | Une clé primaire stable : `upsert` suffit, une reconstruction n’est jamais nécessaire |
| La charge utile est l’ensemble **complet** | Supprimer, c’est comparer à un build, sans exploration ni comptabilité |
| duxt ne se déclenche pas sur une analyse vide | Un build qui n’a rien analysé ne peut jamais vous dire de vider l’index |
| L’ordre est déterministe | Deux builds diffèrent exactement là où le site a différé |

Une page qui a écrit `search: false`, et une traduction dans une langue que le
site ne route pas, ne produisent aucun enregistrement — donc ni l’une ni l’autre
n’atteint Typesense.

## En faire un paquet

Le module ci-dessus fait ~40 lignes et appartient à un site. Dès qu’un deuxième
site en a besoin, la façon de le partager est **une couche Nuxt**, pas un
plugin : une couche porte le module, ses valeurs `app.config` par défaut et ses
composants dans un paquet versionné, et un consommateur l’ajoute à côté de duxt.

```ts [nuxt.config.ts]
export default defineNuxtConfig({
  extends: ['@kirchdev/duxt', 'duxt-typesense']
});
```

Cette couche possède les informations d’identification, la configuration du
service, la politique de synchronisation et l’interface de recherche côté
navigateur. duxt n’en gagne aucune —
[c’est toute la raison de la forme du hook](/reference/hooks).

## Liste de contrôle

- [ ] Le client est une dépendance de **votre** site, pas de la couche
- [ ] La clé d’administration vit dans l’environnement de build et n’atteint jamais une page
- [ ] La collection est créée au premier build et facette `source`, `version` et `locale`
- [ ] Les enregistrements qui ne sont plus remis sont retirés — par comparaison **par lots** ou par échange d’alias
- [ ] Un build sans la clé saute la synchronisation au lieu d’échouer
- [ ] Chaque source, version et langue est représentée dans la collection