Hooks
Der eine Build-Hook, den duxt aussendet, was er übergibt und warum es nur einen gibt.
duxt sendet einen Hook aus. Er ist ein Nuxt-Build-Hook, feuert einmal pro Build und übergibt jede Seite der Website als flache Suchdatensätze.
import { defineNuxtModule } from '@nuxt/kit';
import type { DuxtSearchRecord } from '@kirchdev/duxt/search-records';
export default defineNuxtModule({
setup(_options, nuxt) {
nuxt.hook('duxt:search:records', async (records) => {
await upload(records);
});
}
});
Ein durchgearbeitetes Beispiel dafür — das Collection-Schema, das Upsert, das Entfernen dessen, was fort ist, und der Schlüssel, der niemals eine Seite erreichen darf — steht in Deine Seiten an Typesense senden.
duxt:search:records
(records: readonly DuxtSearchRecord[]) => void | Promise<void>
Wann er feuert. Nachdem Content jede Quelle, jede Version, jede Sprache und
jede generierte Sektion geparst hat — die heruntergeladenen entfernten
Repositories eingeschlossen — und bevor Nitro irgendetwas prerendert oder
bündelt. Ein Listener darf also noch eine Datei nach public/ schreiben, und
sie landet in der Ausgabe.
Was ein Datensatz ist. Eine Seite, plus einer je Überschrift darauf.
| Feld | Typ | Was es enthält |
|---|---|---|
id | string | Die Identität des Datensatzes, und ein gültiger Primärschlüssel überall |
url | string | Wo ein Leser landet — inklusive Sprachsegment und Anker |
title | string | Die Überschrift dieses Datensatzes, beim ersten der Seitentitel |
content | string | Die Wörter darunter, ohne Markup und mit normalisierten Leerzeichen |
source | string | Aus welcher Quelle er stammt, als URL-Segmente ohne die Version |
version | string | Das Versionslabel, wo die Quelle Versionen hat — sonst nicht vorhanden |
locale | string | Die Sprache, in der der Inhalt verfasst ist |
id ist ein Hex-Digest statt der URL, weil Meilisearch als Dokument-ID nur
A-Za-z0-9_- akzeptiert. Sie wird aus url abgeleitet, ist also auf jeder
Maschine dieselbe und bleibt gleich, solange die Seite bleibt, wo sie ist — genau
das erlaubt einem Provider ein Upsert statt eines Neuaufbaus.
source ist eine Identität, keine Adresse: / für die Dokumentation an der
Wurzel, /harbour für ein zweites Repository, /harbour/api für eine
generierte Sektion darin. Die Version gehört bewusst nicht dazu, damit eine
Facette auf source ein Release überlebt und version daneben das ist, was auf
eine Version einschränkt.
locale ist die Sprache des Inhalts, nicht die Oberflächensprache, in der
gerade gelesen wird. Eine Website mit unübersetzten Quellen bekommt einen
Satz Datensätze, auch wenn sie sieben Sprachen ausliefert — derselbe Absatz
siebenmal indiziert ist eine wiederholte Antwort.
Bau oder lade deinen eigenen Index daraus. Schreibe keinen Datensatz um und sortiere das Array nicht an Ort und Stelle: Jeder andere Listener bekommt dasselbe Objekt, und eine Änderung im Vorbeigehen würde der nächste erben, ohne dass irgendetwas das sagt.
Was er bewusst nicht trägt
- Keinen Textausschnitt. Ein Snippet ist eine Entscheidung über eine Trefferliste, und die Trefferliste gehört dir.
- Keinen Collection-Namen. Das ist duxts eigene Index-Buchführung, und ein Dokument mit diesem Schlüssel bricht an dem Tag, an dem eine Quelle eine Sprache bekommt.
- Kein Anzeigelabel.
label,nameund die Versionsbeschriftung sind Prosa für Leser, je Sprache übersetzt. Einen Datensatz liest kein Leser.
Was aus dem Index herausfällt
Eine Seite mit search: false erzeugt überhaupt keine
Datensätze — dieselbe Abmeldung, die der Suchdialog, das MCP-Werkzeug
search_docs und beide llms-Indizes bereits beachten. Ebenso eine übersetzte
Quelle in einer Sprache, die duxt.locales nicht ausliefert, denn sie zu
indizieren hieße, einem Leser eine URL anzubieten, die 404 liefert.
Wo eine Website zwei Regionen einer Sprache über einen Baum ausliefert —
pt-PT und pt-BR lesen beide denselben pt/-Ordner —, adressieren die
Datensätze die erste. Beide URLs tragen dieselben Wörter; beide zu indizieren
würde den Index verdoppeln, um zweimal dasselbe zu sagen. Ein Provider, der
einen Leser in dessen Region schicken will, tauscht das Segment aus — duxt kann
das nicht, weil es nicht weiß, wer gerade sucht.
Und wenn niemand zuhört, baut duxt gar keine Nutzlast — eine Website ohne Provider zahlt also nichts dafür, dass es den Hook gibt.
Warum es nur einen gibt
Eine verteilbare duxt-Erweiterung ist ein Nuxt-Layer, kein Plugin-Objekt:
export default defineNuxtConfig({
extends: ['@kirchdev/duxt', 'duxt-typesense']
});
Das trägt bereits Komponenten, app.config-Vorgaben, content.config.ts-
Collections, Server-Routen, MCP-Werkzeuge und ein eigenes Modul — in einem Paket
mit einer eigenen Version. Eine duxt.plugins-API würde die Layer-Auflösung
innerhalb eines Layers nachbauen — und eine Registry, in die ein Plugin beim
Import schreibt, läuft in genau die Wand, die
sectionTypes geformt hat: content.config.ts und
duxts Modul werden von verschiedenen Loadern geladen, eine veränderliche
Registry wäre also zwei Registries. Ein Nuxt-Build-Hook hat dieses Problem
nicht, weil ihn nur die Modulhälfte je sieht.
Die Oberfläche bleibt also mit Absicht klein, und jeder andere Weg hinein ist weiterhin die richtige Antwort auf seine eigene Frage:
| Du willst ändern | Nimm |
|---|---|
| Eine Farbe, eine Schrift, einen Abstand | Ein CSS-Token — Theme überschreiben |
| Was eine Komponente zeichnet | Sie überschatten, oder einen Slot, wo es einen gibt |
| Was die Website über sich selbst sagt | Den duxt-Schlüssel in app.config — Konfiguration |
| Eine neue Art generierter Sektion | sectionTypes — Quellen |
| Was eine Seite um sich herum zeichnet | Frontmatter — Frontmatter |
| Wo der Text der Website indiziert wird | Diesen Hook |
Ein Hook-Name ist öffentliche Oberfläche: Ihn umzubenennen ist ein Breaking Change, und einen ungenutzten wird man nie wieder los. Ein neuer kommt deshalb dann, wenn es einen Konsumenten gibt, der ohne ihn nicht zu schreiben ist — nicht im Voraus, und nicht, weil eine Naht so aussieht, als wolle man sie irgendwann.