` |
+| Website-Icons | `www/public/favicon.svg`, `apple-touch-icon.png` (180×180) | Der Browser-Tab |
+| Wortmarke auf der Seite | `www/public/wordmark.svg`, `wordmark-dark.svg` | `duxt.logo` in `www/` |
+
+Die README-Wortmarke verschmälert die Klammerzellen um 120 Font-Einheiten: Plex
+Mono gibt `[` eine 600 Einheiten breite Zelle für 311 Einheiten Farbe, und bei
+voller Monospace-Breite sitzen die Klammern in einem Wortzwischenraum für sich.
+
+Das Favicon trägt seine eigene `prefers-color-scheme`-Regel, damit beide Farben
+gegen einen dunklen Tab-Streifen aufhellen. Das PNG existiert nur, weil iOS
+SVG-Icons ignoriert.
+
+## Die Klammern des Icons sind neu gezeichnet, das `d` nicht
+
+Plex' Klammer ist 311 Einheiten breit gegen ein 486 Einheiten breites `d`, und bei
+16 px schließt diese Aufteilung die Punze zu einem Fleck. Das Icon behält Stamm
+(104) und Arm (88) der Schrift, kürzt den Arm aber auf 185 — das bringt die Marke
+von 1,45∶1 auf 1,13∶1 ins Quadrat und verschafft der Punze die Pixel, die sie
+braucht.
+
+Die Wortmarke hat diese Einschränkung nicht und bleibt reines Plex. Das ist der
+ganze Unterschied zwischen den beiden Dateien und der Grund, warum sie sich nicht
+aus einer Quelle erzeugen lassen.
+
+## Farben
+
+Die Klammern tragen die Farbe; die Buchstaben nehmen die umgebende Textfarbe. Nur
+das Klammerpaar steht fest.
+
+| Rolle | Hex | OKLCH |
+| :----------------- | :-------- | :----------------------- |
+| Klammern (hell) | `#4F46E5` | `oklch(0.511 0.230 277)` |
+| Klammern (dunkel) | `#7D7BF5` | `oklch(0.644 0.178 281)` |
+| Buchstaben (hell) | `#15171E` | `oklch(0.206 0.014 273)` |
+| Buchstaben (dunkel)| `#E8EBF2` | `oklch(0.940 0.010 267)` |
+
+## Lizenzierung
+
+IBM Plex steht unter [SIL OFL 1.1](https://github.com/IBM/plex/blob/master/LICENSE.txt),
+und Klausel 5 klärt diesen Fall ausdrücklich: _„The requirement for fonts to
+remain under this license does not apply to any document created using the Font
+Software."_ Ein SVG mit Konturen ist ein solches Dokument, es wird keine
+Schriftdatei weitergegeben, und der reservierte Name „Plex" taucht in den Assets
+nirgends auf. Nichts muss bezahlt, genannt oder neu lizenziert werden — die
+Nennung oben ist Höflichkeit.
+
+## Nichts davon wird ausgeliefert
+
+::callout{type="warning" title="Die Ebene trägt keine Marke"}
+`duxt.logo` ist standardmäßig nicht gesetzt, und `DuxtBrand` fällt auf den eigenen
+`duxt.title` des Konsumenten neben einem generischen Icon zurück. Eine Website,
+die duxt erweitert, zeigt ihren eigenen Namen in Kopf- und Fußbereich und ihr
+eigenes Icon im Tab, niemals dieses. Jedes Asset oben gehört diesem Repository und
+`www/` — keines liegt im veröffentlichten Paket.
+::
+
+Wie du deine eigene setzt, steht in
+[Deiner Website eine Marke geben](/guides/brand-your-site).
diff --git a/docs/de/6.conventions/index.md b/docs/de/6.conventions/index.md
new file mode 100644
index 00000000..592b490d
--- /dev/null
+++ b/docs/de/6.conventions/index.md
@@ -0,0 +1,18 @@
+---
+title: Konventionen
+description: Projektspezifische Regeln — die, die sich aus dem Code nicht erschließen lassen.
+icon: lucide:ruler
+---
+
+Regeln, an die sich dieses Projekt hält, aufgeschrieben, weil der Code sie nicht
+aussprechen kann. Sie binden die Arbeit in diesem Repository; nichts hier ist eine
+Einschränkung für eine Website, die die Ebene erweitert.
+
+Eine Regel sagt, was getan wird; eine [Architekturentscheidung](/adr) sagt, warum
+sie gegenüber den Alternativen gewählt wurde, und bleibt lesbar, wenn die
+Begründung längst aus allen Köpfen verschwunden ist. Das Entscheidungsprotokoll
+ist append-only — deshalb steht es neben den Seiten hier und nicht zwischen
+ihnen: die werden bearbeitet, wenn sie sich ändern.
+
+::page-cards
+::
diff --git a/docs/de/99.adr/0001-build-duxt-as-a-layer-carrying-a-module.md b/docs/de/99.adr/0001-build-duxt-as-a-layer-carrying-a-module.md
new file mode 100644
index 00000000..d7934216
--- /dev/null
+++ b/docs/de/99.adr/0001-build-duxt-as-a-layer-carrying-a-module.md
@@ -0,0 +1,62 @@
+---
+title: duxt als Ebene bauen, die ein Modul trägt
+description: Das Theme als Nuxt-Ebene ausliefern, deren Wurzel das Paket ist, statt als Starter-Vorlage.
+status: accepted
+date: 2026-09-06
+---
+
+## Kontext
+
+Ein Dokumentations-Theme lässt sich auf zwei Arten ausliefern. Eine
+**Starter-Vorlage** wird in das Repository des Konsumenten kopiert, wo jede
+Datei ihm zum Bearbeiten gehört und keine Verbesserung ihn je wieder erreicht,
+außer als Diff, den jemand von Hand anwendet. Eine **Ebene** bleibt eine
+Abhängigkeit: Der Konsument erweitert sie, überschreibt die Dateien, denen er
+widerspricht, und nimmt den Rest der Verbesserungen mit einem Versionssprung mit.
+
+Ein Teil dessen, was das Theme tun muss, lässt sich von einer Ebene nicht als
+Dateien ausdrücken. Collections zu erzeugen, eine Quellenliste in URL-Präfixe
+aufzulösen, Frontmatter in Routenregeln zu übersetzen und das Ergebnis zu prüfen
+ist Arbeit zur Build-Zeit, und Arbeit zur Build-Zeit ist in Nuxt ein Modul.
+
+## Entscheidung
+
+Wir liefern duxt als **Nuxt-Ebene aus, die ihre eigenen Module trägt**,
+konsumiert mit einem einzigen `extends`-Eintrag. Die Wurzel des Repositories
+*ist* die Ebene: Die Nuxt-Konfiguration, die Content-Konfiguration und `app/`
+liegen in der Wurzel, und das Paketmanifest zeigt auf sie, sodass
+`extends: ['@kirchdev/duxt']` ohne Build-Schritt auflöst. Die konsumierende
+Seite liegt daneben im selben Repository und ist das Entwicklungsziel.
+
+## Konsequenzen
+
+Ein Konsument erbt Theme, Seiten, Komponenten, Konfigurationsstandards und
+Collections und überschreibt jedes davon, indem er eine Datei gleichen Namens
+anlegt. Ein Upgrade ist ein Versionssprung.
+
+Nichts Ebenen-Relatives löst so auf, wie es sich liest. Ein in der Ebene
+geschriebener Pfad wird aus dem Verzeichnis des Konsumenten gelesen, sofern er
+nicht gegen den eigenen Ort der Ebene aufgelöst wurde, und der `@`-Alias gehört
+dem, der die Ebene erweitert, nicht der Ebene — die eigenen Importe der Ebene
+brauchen deshalb einen eigenen Alias. Das hat echte Fehler gekostet, und es ist
+der Preis dieser Anordnung, kein Versehen.
+
+Überschreibbare Namen werden zu einer öffentlichen Oberfläche. Eine Komponente,
+eine Seite oder ein Konfigurationsschlüssel, den ein Konsument überdecken kann,
+ist ein Name, von dem er abhängt — einen davon umzubenennen ist deshalb ein
+brechendes Release, und festgezurrt wird das durch die dokumentierte Oberfläche.
+
+Die Entwicklungsseite und eine Starter-Vorlage sind getrennte Artefakte. Die
+Seite neben der Ebene will Grenzfälle, hässliches Frontmatter, mehrere Quellen
+und einen Tag, aus dem sie liest; ein Fremder, der einen Starter klont, will das
+Gegenteil. Die beiden zu vermengen würde eines von ihnen schlecht machen.
+
+## Erwogene Alternativen
+
+**Eine Starter-Vorlage.** Völlige Freiheit für den Konsumenten, kein
+Upgrade-Pfad für irgendwen — der Grund, warum die Ebene gewonnen hat.
+
+**Ein Modul ohne Ebene.** Ein Modul kann Komponenten und Routen registrieren,
+aber die Substanz des Themes sind Dateien, die ein Konsument überdecken können
+muss, und diese durch ein Modul auszuliefern heißt, sie zu injizieren, statt
+Nuxts eigene Ebenen-Auflösung das tun zu lassen.
diff --git a/docs/de/99.adr/0002-generate-the-collections-from-one-source-list.md b/docs/de/99.adr/0002-generate-the-collections-from-one-source-list.md
new file mode 100644
index 00000000..492b1fa5
--- /dev/null
+++ b/docs/de/99.adr/0002-generate-the-collections-from-one-source-list.md
@@ -0,0 +1,62 @@
+---
+title: Die Collections aus einer Quellenliste erzeugen
+description: Content-Collections zur Ladezeit der Konfiguration aus einer kompakten Quellenliste berechnen, statt Konsumenten sie deklarieren zu lassen.
+status: accepted
+date: 2026-09-06
+---
+
+## Kontext
+
+Nuxt Content bezieht eine Collection aus einem git-Repository an einem Branch
+oder einem Tag, authentifiziert sich gegen ein privates und cacht den Download
+über einen Hash. Das wurde durch das Lesen von Content selbst verifiziert, bevor
+irgendetwas hiervon gebaut wurde, und es heißt, dass die schwere Hälfte
+versionierter Dokumentation über mehrere Repositories bereits existierte und
+nicht neu gebaut werden musste.
+
+Was nicht existierte, war die Ergonomie. Eine Seite, die mehrere Versionen
+mehrerer Projekte ausliefert, deklariert eine Collection je Repository × Ref,
+von Hand: Drei Versionen über vierzehn Repositories sind zweiundvierzig
+Deklarationen, und ein Release zu schneiden bearbeitet alle vierzehn. Content
+bietet auch keinen Hook, um Collections einzuschleusen — aber es lädt die
+Content-Konfiguration jeder Ebene über c12, was heißt, dass diese Datei
+ausgeführter Code ist statt einer Datendatei, und ihre Collections beim Laden
+berechnen darf.
+
+## Entscheidung
+
+Konsumenten deklarieren eine kompakte **Quellenliste** — ein Ordner, optional
+ein Repository, optional Refs — in der eigenen App-Konfiguration der Seite, und
+die Content-Konfiguration der Ebene berechnet daraus zur Ladezeit eine
+Collection je Quelle × Ref. Dieselbe Liste wird vom Build ein zweites Mal
+aufgelöst, zu einem Manifest, das benennt, welche Collection welches URL-Präfix
+bedient, und dieses Manifest ist das, was das Theme liest.
+
+## Konsequenzen
+
+Die Liste hat die Länge der Anzahl der Projekte statt des Produkts aus Projekten
+und Versionen, und der einzelne unversionierte Ordner braucht überhaupt keine
+Konfiguration.
+
+Die Kurzform drückt weniger aus als eine handgeschriebene Collection, und das
+wird immer so bleiben. Überlebbar ist das nur, weil Content die
+Content-Konfiguration jeder Ebene zusammenführt, wobei die spätere gewinnt: Ein
+Konsument, der etwas braucht, das die Kurzform nicht sagen kann, schreibt seine
+eigene Datei und übernimmt vollständig.
+
+Mehrere Funktionen brauchen keine eigene Konfiguration mehr, weil eine Quelle
+bereits ein Repository, einen Ref und einen Ordner benennt — Links zurück zur
+Quelle, das Datum der letzten Aktualisierung und die Liste der Mitwirkenden
+fallen alle daraus ab.
+
+Collection-Namen werden zu Daten. Eine Seite mit zwei Repositories hat keine
+Collection namens `docs`, nichts in der Ebene darf also eine so benennen, und
+das Theme liest den Namen stattdessen aus dem Manifest. Code, der einen
+Collection-Namen fest verdrahtet, funktioniert auf einer Seite mit einer
+einzigen Quelle und bricht auf jeder anderen.
+
+Die Collection-Pfade lösen gegen die Ebene auf statt gegen den Konsumenten, weil
+Content die Ebene, die eine Collection deklariert hat, als deren Wurzel
+vermerkt. Die Ebene berechnet deshalb absolute Pfade, was nur möglich ist, weil
+die Konfiguration ausgeführter Code ist — dieselbe Eigenschaft, auf der die
+ganze Kurzform ruht.
diff --git a/docs/de/99.adr/0003-decide-the-url-prefixes-at-build-time.md b/docs/de/99.adr/0003-decide-the-url-prefixes-at-build-time.md
new file mode 100644
index 00000000..70c0ac90
--- /dev/null
+++ b/docs/de/99.adr/0003-decide-the-url-prefixes-at-build-time.md
@@ -0,0 +1,52 @@
+---
+title: Die URL-Präfixe zur Build-Zeit entscheiden
+description: Ob ein Repository- oder Versionssegment erscheint, entscheidet die Quellenliste vor der ersten Anfrage, nie pro Anfrage.
+status: accepted
+date: 2026-09-06
+---
+
+## Kontext
+
+Eine Seite, die aus mehreren Quellen gebaut ist, muss ein Repository-Segment und
+ein Versionssegment in ihren URLs ausliefern, und eine Seite, die aus einem
+Ordner gebaut ist, darf das nicht — niemand will
+`/my-project/main/guides/deploying` für ein Projekt mit einem einzelnen
+unversionierten Doku-Ordner.
+
+Jedes Segment pro Anfrage optional zu machen funktioniert nicht. Wären beide
+optional, könnte das erste Segment von `/guides/…` ein Ordner, ein Repository
+oder eine Version sein, und erst das Nachschlagen aller drei würde es klären.
+Diese Mehrdeutigkeit ist keine Unbequemlichkeit beim Routing; sie macht die
+Bedeutung einer URL davon abhängig, was zufällig existiert.
+
+## Entscheidung
+
+Jedes Präfix wird **für die ganze Seite, zur Build-Zeit, aus der Quellenliste**
+eingeschaltet: ein Repository-Segment, sobald mehr als ein Repository
+veröffentlicht wird oder ein Schalter es erzwingt, ein Versionssegment, sobald
+eine Quelle mehr als einen Ref veröffentlicht oder ein Schalter es erzwingt. Ein
+Ref je Repository wird ganz ohne Versionssegment ausgeliefert.
+
+## Konsequenzen
+
+Die Form jeder URL steht vor der ersten Anfrage fest, der Router muss also nie
+raten, und ein in einer Seite geschriebener Link kann von einer Prüfung im Build
+aufgelöst werden, statt ihn auszuprobieren.
+
+Ein einzelner unversionierter Ordner liefert Pfade aus, die nichts darüber
+verraten, dass Repositories oder Versionen überhaupt existieren, und genau das
+macht den einfachsten Fall kostenlos.
+
+Eine Seite von einer Quelle auf zwei umzustellen ändert jede URL, die sie
+ausliefert. Das ist eine Migration, und überlebbar macht sie die
+Weiterleitungs-Maschinerie.
+
+Eine Kollision bleibt und lässt sich nicht wegentwerfen: ein Doku-Ordner, der
+wie ein Repository- oder Versionssegment heißt, wobei das Präfix gewinnt und der
+Ordner unerreichbar ist. Der Build weist das mit einer Meldung zurück, statt es
+stillschweigend in eine Richtung aufzulösen.
+
+Weil das eigene Präfix einer Seite nur dem Build bekannt ist, werden Links in
+Seiten als nackte Dokumentationspfade geschrieben und beim Rendern gegen die
+Quelle der Seite aufgelöst. Eine Seite, die ihr eigenes Präfix fest verdrahtet,
+ist auf genau einer Site richtig.
diff --git a/docs/de/99.adr/0004-render-markdown-components-with-mdc.md b/docs/de/99.adr/0004-render-markdown-components-with-mdc.md
new file mode 100644
index 00000000..dc9d4fa0
--- /dev/null
+++ b/docs/de/99.adr/0004-render-markdown-components-with-mdc.md
@@ -0,0 +1,50 @@
+---
+title: Markdown-Komponenten mit MDC rendern
+description: Für Komponenten in Markdown Contents eigene MDC-Syntax nutzen, statt MDX einzuführen.
+status: accepted
+date: 2026-09-06
+---
+
+## Kontext
+
+Ein Dokumentations-Theme braucht Komponenten im Fließtext — Callouts, Code in
+Reitern, Parameterfelder, Bäume. Zwei Syntaxen standen zur Verfügung. **MDX**
+kompiliert Markdown zu einem Komponentenmodul und lässt eine Seite JSX
+importieren und schreiben; **MDC** ist die Block- und Inline-Komponentensyntax,
+die Content ohnehin mitbringt und parst.
+
+Die Seiten werden auch von etwas anderem als einem Browser gelesen. Das Theme
+veröffentlicht die Markdown-Quelle jeder Seite für Modelle, ein Modell bekommt
+also genau die Syntax, in der die Seiten geschrieben sind.
+
+## Entscheidung
+
+Komponenten in Markdown sind **MDC**. Die aus einer Seite aufrufbaren
+Komponenten liegen in einem eigenen Content-Verzeichnis, in dem die gleichnamige
+Datei eines Konsumenten die der Ebene ersetzt.
+
+## Konsequenzen
+
+Es muss nichts installiert und nichts konfiguriert werden, damit eine Seite eine
+Komponente aufruft, und eine Seite bleibt eine Markdown-Datei, statt ein Modul
+zu werden.
+
+Ein MDC-Block übersteht es, einem Modell als Text übergeben zu werden: Er liest
+sich als Komponentenaufruf mit benannten Argumenten. Kompiliertes JSX täte das
+nicht.
+
+Die Syntax ist Contents, also sind auch ihre Fähigkeiten und ihre Grenzen
+Contents. Alles, was eine Seite will und MDC nicht ausdrücken kann, muss als
+Komponente gelöst werden statt als Ausdruck in der Seite — eine Einschränkung
+für Autoren und, für eine Dokumentationsseite, eine erwünschte.
+
+Die Komponentennamen im Content-Verzeichnis gehören zur öffentlichen Oberfläche,
+denn eine Seite, die gegen einen von ihnen geschrieben ist, ist die Datei eines
+Konsumenten.
+
+## Erwogene Alternativen
+
+**MDX.** Ausdrucksstärker, und jedes Stück dieser Ausdrucksstärke ist JavaScript
+in einer Dokumentationsseite. Es bräuchte zudem ein Modul und einen Build-Pfad,
+den Content nicht hat, um am Ende Seiten zu haben, die einem Modell schlechter
+zu übergeben sind.
diff --git a/docs/de/99.adr/0005-ship-the-layer-without-owner-specific-links.md b/docs/de/99.adr/0005-ship-the-layer-without-owner-specific-links.md
new file mode 100644
index 00000000..8fcefa3e
--- /dev/null
+++ b/docs/de/99.adr/0005-ship-the-layer-without-owner-specific-links.md
@@ -0,0 +1,46 @@
+---
+title: Die Ebene ohne betreiberspezifische Links ausliefern
+description: Jeder Standard, der ein bestimmtes Projekt benennen würde — Repository, Issue-Tracker, Community, Impressum —, wird leer ausgeliefert.
+status: accepted
+date: 2026-09-06
+---
+
+## Kontext
+
+Ein Dokumentations-Theme zeichnet mehrere Reihen von Links: Icon-Links in der
+Navigationsleiste, einen Community-Block neben dem Inhaltsverzeichnis,
+rechtliche Links im Fußbereich, Schaltflächen auf der Landeseite. Sie mit dem
+eigenen Projekt des Themes zu füllen lässt eine Vorführseite fertig aussehen —
+und gibt jedem Konsumenten einen „Gib diesem Repository einen Stern“-Button in
+die Hand, der die Arbeit eines anderen mit einem Stern versieht, eine Community,
+die nicht seine ist, und ein Impressum, das für ihn rechtlich falsch ist.
+
+Die Rechtszeile des Fußbereichs hat das Prinzip zuerst entschieden, denn eine
+deutsche Seite muss ein Impressum zeigen, und es zu liefern steht unverkennbar
+nicht der Vorlage zu.
+
+## Entscheidung
+
+Jeder Standard, der ein bestimmtes Projekt oder eine bestimmte Organisation
+benennen würde, wird **leer** ausgeliefert. Die Links des Themes selbst liegen
+in der Konfiguration der konsumierenden Seite, wo sie ein Beispiel sind und kein
+Standard. Generische Umgebung, die niemanden benennt — eine Spaltenüberschrift,
+eine „Doku lesen“-Aktion —, bleibt in der Ebene.
+
+## Konsequenzen
+
+Wer die Ebene als Fremder erweitert, bekommt eine leere Reihe statt einer
+falschen, und eine leere Reihe fehlt sichtbar, während ein falscher Link richtig
+aussieht.
+
+Die Vorführseite trägt mehr Konfiguration, als ein minimaler Konsument braucht,
+und genau das ist ihre Aufgabe: Sie ist das durchgearbeitete Beispiel für jede
+Reihe, die ein Konsument füllen muss.
+
+Die eigenen Nachrichtenschlüssel der Ebene bleiben zurück, wenn ihre Links
+gehen, und diese Schlüssel sind intern. Ein Konsument, der in sie hineingreift,
+hinge an einem Namen, der ohne Major-Release umbenannt werden darf, und ein
+fehlender Schlüssel wird als der Schlüssel selbst ausgegeben — der Bruch
+erreichte also einen Leser, bevor er einen Build erreicht. Konsumenten schreiben
+ihre eigenen Zeichenketten; die
+[Konfigurationsseite](/getting-started/configuration) sagt wie.
diff --git a/docs/de/99.adr/0006-rebuild-on-a-schedule-rather-than-refresh-at-runtime.md b/docs/de/99.adr/0006-rebuild-on-a-schedule-rather-than-refresh-at-runtime.md
new file mode 100644
index 00000000..b1aaf8c4
--- /dev/null
+++ b/docs/de/99.adr/0006-rebuild-on-a-schedule-rather-than-refresh-at-runtime.md
@@ -0,0 +1,38 @@
+---
+title: Nach Zeitplan neu bauen statt zur Laufzeit aktualisieren
+description: Eine Seite, die ein anderes Repository liest, übernimmt dessen Änderungen beim Bauen, und eine Aktualisierung zur Laufzeit wird nicht angeboten.
+status: accepted
+date: 2026-09-06
+---
+
+## Kontext
+
+Eine Seite, die Dokumentation aus anderen Repositories liest, hat einen
+offensichtlichen Wunsch: einen Push ohne Deploy übernehmen. Content lädt ein
+entferntes Repository jedoch während des Builds herunter und kompiliert es in
+die Datenbank, die der Build ausliefert. Das in einem laufenden Server zu
+aktualisieren hieße, die Datenbank an Ort und Stelle neu zu bauen — wofür
+Content keinen unterstützten Weg anbietet, und der einzige verfügbare Weg ist
+dieselbe clientseitige Datenbank, die die Suche liest.
+
+## Entscheidung
+
+Content wird zur Build-Zeit gelesen und nie zur Laufzeit aktualisiert. Eine
+Seite, deren Quellen weitergezogen sind, wird neu gebaut: nach Zeitplan oder
+ausgelöst aus dem Quell-Repository.
+
+## Konsequenzen
+
+Ein Deployment ist unveränderlich und ein Build reproduzierbar — die
+ausgelieferten Seiten sind genau die Seiten, die kompiliert wurden, und derselbe
+Commit erzeugt dieselbe Seite. Statische Ausgabe bleibt möglich, und das ist der
+richtige Standard für Dokumentation.
+
+Dokumentation hinkt ihrer Quelle um das Neubau-Intervall hinterher, und ein
+Quell-Repository, das seine Doku live haben will, muss den Build der Seite
+auslösen. Das ist der Preis, und er wird im Betrieb bezahlt und nicht in der
+Ebene.
+
+Nichts im Theme darf annehmen, dass es eine Quelle erneut lesen kann. Ein
+Feature, das frischeren Inhalt will, als der Build hat, verlangt nach einer
+anderen Architektur und nicht nach einer Einstellung.
diff --git a/docs/de/99.adr/0007-serve-translations-as-collections-of-their-own.md b/docs/de/99.adr/0007-serve-translations-as-collections-of-their-own.md
new file mode 100644
index 00000000..2705859c
--- /dev/null
+++ b/docs/de/99.adr/0007-serve-translations-as-collections-of-their-own.md
@@ -0,0 +1,104 @@
+---
+title: Übersetzungen als eigene Collections ausliefern
+description: Einer Quelle eine Locale-Dimension hinzufügen statt dem Content-Pfad ein Locale-Segment.
+status: accepted
+date: 2026-09-06
+---
+
+## Kontext
+
+Die Ebene übersetzte ihre Oberfläche in sieben Locales und lieferte allen
+denselben Satz Seiten aus: `useDuxtPath()` entfernte das Locale-Segment vor
+jedem Content-Lookup, `/de-DE/guides/deploying` und `/guides/deploying` lösten
+also auf dieselbe Datei auf. Content v3 kennt keine Locale — eine Collection ist
+ein Baum —, übersetzte Seiten brauchten also eine Entscheidung und keine
+Einstellung.
+
+Was die vergleichbaren Generatoren tun, wurde nachgelesen statt angenommen.
+Starlight, VitePress, Docusaurus und MkDocs legen Übersetzungen allesamt in
+einen Ordner je Sprache; nur Starlight hat einen dokumentierten Rückfall für
+eine Seite, die einer Sprache fehlt. Ab einer gewissen Größe verlässt die
+Übersetzung das Werkzeug ganz: React betreibt `de.react.dev` als eigenes
+Repository, Vue eine ganze `vuejs-translations`-Organisation, weil Übersetzer
+nach eigenem Zeitplan und eigenem Review arbeiten. OpenCode baute einen Agenten,
+der die Doku in CI übersetzte, ließ ihn laufen und schaltete ihn ab; siebzehn
+Sprachen stehen seither still.
+
+Die Kosten wurden gemessen, bevor die Form gewählt wurde: Builds über 1 … 200
+Collections skalieren linear mit rund 2,2 s und 0,63 MB Datenbank je Collection,
+ohne Knick. Die Matrix hat keine Decke, die dem Entwurf die Hand führen würde.
+
+## Entscheidung
+
+Eine Quelle bekommt eine `locales`-Liste, und ein Ref ebenso, aufgelöst wie
+`status` es bereits wird (`ref.locales ?? source.locales`). Eine Zeichenkette
+ist ein Ordner innerhalb des `path` der Quelle; ein Objekt verschiebt diese
+Sprache in einen eigenen Ordner, ein eigenes Repository oder einen eigenen Ref.
+
+**Die Standard-Locale ist der Baum in `path` selbst, ohne Ordner**, das
+Hinzufügen des Schlüssels verschiebt also keine URL, die eine Seite bereits
+ausliefert.
+
+**Die Locale gehört nicht in den Content-Pfad.** Sie gehört i18ns Routing, das
+sie dem Pfad ohnehin voranstellt. Original und Übersetzung liegen deshalb unter
+identischen Content-Pfaden in getrennten Collections.
+
+Eine Seite, die einer Sprache fehlt, fällt entlang einer Kette zurück — die
+Locale, ihre Basissprache, eine Geschwisterregion, `fallbackLocale` aus vue-i18n,
+das unübersetzte Original — und dem Leser wird in einem Banner gesagt, welche
+Sprache er zu sehen bekommt.
+
+## Konsequenzen
+
+Jeder Pfadvergleich im Theme bleibt unberührt: Navigation, Weiterleitungen, der
+Breadcrumb, die Bewertung der nächsten Seite im 404 und der Sprachumschalter
+arbeiten allesamt weiter auf einem Pfad, der nie eine Locale trug. Der Rückfall
+ist eine weitere Abfrage auf denselben Pfad statt einer Weiterleitung oder eines
+zweiten Auflösungsschemas.
+
+`useDuxtNavigation` und die Suche folgen `useDuxtCollection`, beide wurden also
+locale-bewusst, ohne geändert zu werden.
+
+Eine Seite, die nichts setzt, bekommt genau das, was sie hatte: eine Collection
+namens `docs`, einen Eintrag im Manifest, eine Abfrage je Seite.
+
+Zwei Konfigurationen können nun so auseinandergehen, dass eine leere Seite dabei
+herauskommt statt eines Fehlers — `content.config.ts` löst die Standard-Locale
+ohne Zugriff auf die Nuxt-Konfiguration auf. Das duxt-Modul prüft deshalb
+`sourceOptions.defaultLocale` gegen `i18n.defaultLocale` und lässt den Build
+scheitern, wenn sie abweichen, statt das eine in das andere zu injizieren und
+Content die andere Antwort ausrechnen zu lassen.
+
+Übersetzungen vervielfachen Collections, und der Build zahlt für jede linear.
+Die Zahl gehört in die Dokumentation, denn über die Matrix entscheidet ein
+Konsument.
+
+Partials folgen den Seiten. `_partials/` ist eine Collection JE SPRACHE, benannt
+wie die Seiten-Collections — `duxt_partials` für das Original, `duxt_partials_de`
+daneben —, und `:partial{name}` durchläuft dieselbe Fallback-Kette wie die Seite.
+Es muss dieselbe Kette sein: Dass eine Seite und die Blöcke, die sie einbindet,
+auf unterschiedliche Sprachen zurückfallen, ist genau der Weg zu einer halb
+übersetzten Seite, auf der nichts davon erzählt.
+
+Die erste Fassung dieser Entscheidung teilte EINE unübersetzte Partials-Collection
+über alle Sprachen hinweg. Das war vertretbar, solange nichts ein Partial
+übersetzte, und hörte in dem Moment auf vertretbar zu sein, in dem ein
+Übersetzungsdurchlauf `docs/de/_partials/` erzeugte — Dateien, die keine
+Collection las, und eine deutsche Seite, die einen englischen Installationshinweis
+rendert, ohne dass irgendetwas darauf hinweist.
+
+## Erwogene Alternativen
+
+**Ein Locale-Segment im Content-Präfix.** Symmetrisch zu `repo` und `version`,
+und es hätte jeden Pfadvergleich im Theme gezwungen, Locales zu lernen — für
+eine URL, der i18n ohnehin ein Präfix voranstellt und die dann zweimal
+geschrieben stünde.
+
+**Ein Dateisuffix — `installation.de-DE.md` neben dem Original.** Keine
+Vervielfachung von Collections, und es scheitert an dem Fall, den die großen
+Projekte tatsächlich haben: Es zwingt die Übersetzung in dasselbe Repository und
+denselben Ref wie das Original.
+
+**Ein 404 für eine fehlende Übersetzung.** Was VitePress durch Auslassung tut.
+Es bestraft den Leser für eine Lücke, die der Schreibende hinterlassen hat, und
+es verbirgt vor allen anderen, dass die Übersetzung unvollständig ist.
diff --git a/docs/de/99.adr/0008-build-the-theme-on-owned-shadcn-vue-components.md b/docs/de/99.adr/0008-build-the-theme-on-owned-shadcn-vue-components.md
new file mode 100644
index 00000000..1514d784
--- /dev/null
+++ b/docs/de/99.adr/0008-build-the-theme-on-owned-shadcn-vue-components.md
@@ -0,0 +1,72 @@
+---
+title: Das Theme auf eigenen shadcn-vue-Komponenten bauen
+description: Die Ebene hält den Quelltext ihrer Interface-Komponenten selbst, statt sie aus einer Komponentenbibliothek oder einem fertigen Dokumentations-Theme zu importieren.
+status: accepted
+date: 2026-09-08
+---
+
+## Kontext
+
+Eine Dokumentationsebene besteht überwiegend aus Oberfläche: Kopfbereich,
+Seitenleiste, Inhaltsverzeichnis, Codeblöcke, Suchdialog. Irgendetwas muss sie
+zeichnen, und die Wahl entscheidet darüber, wie viel ein Konsument ändern kann,
+ohne zu forken.
+
+Der ganze Anspruch der Ebene ist, erweitert statt generiert zu werden —
+`extends: ['@kirchdev/duxt']`, und jede Datei bleibt überschreibbar. Ein Theme,
+dessen Erscheinung nur über die Optionen erreichbar wäre, an die seine Autorin
+gedacht hat, widerspräche dem beim ersten Ding, das ein Konsument anders haben
+will. Nuxts Layer-Auflösung schenkt einem Konsumenten die Überschreibung auf
+Dateiebene bereits — die Frage war also, was diese Dateien sein sollen.
+
+## Entscheidung
+
+Die Ebene besitzt den Quelltext ihrer Komponenten. shadcn-vue dient als Quelle
+der Primitive: sein CLI schreibt den Code einer Komponente ins Repository, und ab
+diesem Punkt gehört die Datei der Ebene und keiner Abhängigkeit.
+`components.json` zeigt das CLI auf den eigenen Alias der Ebene, ein Primitiv
+hinzuzufügen ist also ein Befehl, und Tailwind liefert das Styling darunter.
+
+Die Palette besteht aus CSS-Custom-Properties. Komponenten lesen Tokens und
+halten keine Farben — genau das lässt eine einzeilige Überschreibung jede Fläche
+erreichen.
+
+## Konsequenzen
+
+Ein Konsument kann jeden Teil der Oberfläche ändern, in der Tiefe, die die
+Änderung braucht: ein Token neu definieren, eine Komponente per Namen
+überschatten oder ein eigenes Primitiv hinzufügen. Nichts davon verlangt einen
+Fork, und nichts davon wartet darauf, dass die Ebene eine Option dafür freilegt.
+
+Der Preis ist Pflege. Eine Korrektur an einem Primitiv kommt nicht mit einem
+Versionssprung — sie kommt, wenn jemand das CLI für diese Komponente erneut
+aufruft. Die Ebene trägt das für die Primitive, die sie ausliefert; ein Konsument,
+der eines überschreibt, trägt es ab dann selbst. Um diesen Tausch herum ist die
+Überschreib-Anleitung gebaut, und er ist der Grund, dass es sie überhaupt gibt.
+
+Übernommene Dateien, die das CLI nicht erreicht, sind die scharfe Kante desselben
+Tauschs: ein Stylesheet ohne Registry-Eintrag lässt sich nur durch erneutes
+Herunterladen aktualisieren, lokale Änderungen daran gehen also still verloren,
+statt laut zu kollidieren.
+
+## Erwogene Alternativen
+
+**Ein fertiges Dokumentations-Theme.** Docus ist die naheliegende Wahl — das
+eigene Theme von Nuxt Content, und alles, was eine Dokumentationsseite beim
+ersten Start braucht. Abgelehnt aus demselben Grund, aus dem es reizvoll ist: die
+Seite, die es erzeugt, gehört seiner Autorin, und Umformen heißt entweder eine
+Option, die es gibt, oder einen Fork. Eine Ebene, deren Konsumenten sich
+voneinander unterscheiden sollen, konnte diese Decke nicht akzeptieren.
+
+**Eine Komponentenbibliothek als Abhängigkeit.** Nuxt UI hätte die Primitive ohne
+den Pflegeaufwand geliefert, und die Ebene wäre seinen Releases gefolgt, statt
+Code zu kopieren. Abgelehnt, weil sich die Komponenten einer Abhängigkeit nur so
+weit ändern lassen, wie ihre Props es zulassen — die Ebene hätte dann einen
+zweiten Überschreib-Mechanismus für den Rest erfinden müssen, und bei zweien wäre
+keiner der offensichtliche gewesen, zu dem man greift.
+
+**Handgeschriebene Komponenten ganz ohne Upstream.** Das räumt die
+Abhängigkeitsfrage vollständig aus und wurde wegen der Kosten abgelehnt:
+zugängliche Primitive sind genau dort schwierig, wo man sie leicht falsch macht,
+und ein von Grund auf geschriebener Dialog oder eine Combobox wäre schlechter als
+eine, die von einer Quelle übernommen ist, die das bereits gelöst hat.
diff --git a/docs/de/99.adr/0009-take-the-seo-stack-from-the-nuxt-seo-bundle.md b/docs/de/99.adr/0009-take-the-seo-stack-from-the-nuxt-seo-bundle.md
new file mode 100644
index 00000000..8d1ddff9
--- /dev/null
+++ b/docs/de/99.adr/0009-take-the-seo-stack-from-the-nuxt-seo-bundle.md
@@ -0,0 +1,97 @@
+---
+title: Den SEO-Unterbau aus dem Nuxt-SEO-Bündel nehmen
+description: Die Ebene installiert @nuxtjs/seo und übergibt ihm die Head-Tags und strukturierten Daten, die sie bislang von Hand schrieb — behalten werden nur die Regeln, die von Versionen und Übersetzungen abhängen.
+status: accepted
+date: 2026-09-08
+---
+
+## Kontext
+
+Die Ebene lieferte bereits drei Module aus Nuxt SEO aus — robots, sitemap und
+OG-Image —, jedes einzeln gewählt, als der Bedarf auftauchte. Alles, was sie
+nicht abdecken, war handgeschrieben: ein Canonical-Link, ein `og:`/`twitter:`-
+Block pro Seite und ein `@graph` aus JSON-LD, das in `[...slug].vue` in einem
+Template-String zusammengesetzt wurde.
+
+Das funktionierte und war unsichtbar. Genau darin liegt das Problem: Nichts davon
+war durch einen Test abgedeckt, weil SEO im gerenderten HTML lebt und nicht in
+Logik — und die Tests dieses Repos decken bewusst nur reine Logik ab. Die
+Startseite, also die Seite, die am ehesten geteilt wird, hatte überhaupt keine
+Social Card, die Fehlerseite war indexierbar, und sieben Locales wurden ohne ein
+einziges `og:locale` ausgeliefert. Jede dieser Lücken war ein Versäumnis, das
+niemand sehen konnte.
+
+Die handgeschriebene Hälfte war zugleich die, die wächst. Strukturierte Daten
+sind eine Spezifikation mit großer Oberfläche und eigenen Validatoren; jeder von
+Hand ergänzte Knoten ist ein Knoten, dessen Form man sich aus der Spezifikation
+erlesen muss.
+
+## Entscheidung
+
+Die Ebene hängt von `@nuxtjs/seo` ab und lädt es als ein Modul, an der Stelle, an
+der die drei benannten Module standen — vor `@nuxt/content`, weil die
+Content-Integration der Sitemap das verlangt.
+
+Das Bündel ist ein Alias, kein Wrapper: Seine eigene Dokumentation stellt fest,
+es „enthält keine eigene Logik". Was es einbringt, sind die vier fehlenden Module
+— `nuxt-schema-org` für den Graphen, `nuxt-seo-utils` für den automatischen
+Canonical und die abgeleiteten Social-Tags, `nuxt-link-checker` sowie
+`nuxt-site-config` als die eine Stelle, aus der `site.url` gelesen wird — dazu
+das gemeinsame Devtools-Panel, das über die jeweils installierten berichtet.
+
+Drei Vorgaben von `nuxt-seo-utils` werden abgeschaltet, jede aus einem Grund, den
+die Ebene nicht wegkonstruieren kann: `canonicalLowercase`, weil ein
+Locale-Präfix Groß- und Kleinschreibung unterscheidet und `/de-DE/` nicht
+`/de-de/` ist; `fallbackTitle`, weil ein aus einem Slug erfundener Titel den
+Build-Validator verdecken würde, der über eine Seite ohne Titel fehlschlägt; und
+`mergeWithSiteConfig`, weil `app.vue` das Titel-Template besitzt.
+
+Handgeschrieben bleibt, was die Module nicht wissen können: Der Canonical einer
+versionierten Seite zeigt auf die aktuelle Version statt auf die gerade
+gerenderte Seite, und `noindex` folgt daraus, dass eine Version alt ist oder eine
+Seite in einer Sprache ausgeliefert wird, in die sie nicht übersetzt wurde.
+
+Die Link-Prüfung berichtet, statt fehlzuschlagen. `modules/validate.ts` lässt
+einen Build bereits über einen ins Leere zeigenden Link scheitern, und es ist
+diejenige Prüfung, die Versionen und Sprach-Rückfälle versteht.
+
+## Konsequenzen
+
+Jeder Konsument der Ebene installiert sieben Module statt drei. Das ist der Preis
+der Entscheidung, und ihn zahlen auch Sites, die keines der vier neuen nutzen.
+
+Die Regeln, die bisher Behauptungen in einem Kommentar waren, sind jetzt
+Zusicherungen in `scripts/check-seo.ts`: Das Skript liest die gebauten Seiten und
+schlägt über einen zweiten Canonical fehl, über ein fehlendes `hreflang`, über
+eine indexierbare Fehlerseite oder über einen Graphen, der nicht parst. Es läuft
+in `check` neben `check:a11y`, aus demselben Grund — die Tags existieren nur im
+gerenderten HTML.
+
+Zwei dieser Regeln waren vorher überhaupt nicht prüfbar, weil sie erst
+existieren, wenn die Site ihre eigene Herkunft kennt, und `www` bewusst keine
+Domain nennt. Die Prüfung übergibt dem gebauten Server dafür seine eigene Adresse
+über die Umgebungsvariablen, die die Module ohnehin lesen — statt einer Domain in
+einer Konfiguration, die ein Konsument abschreiben würde.
+
+Eine `Organization` zu veröffentlichen braucht eine Tatsache, die die Ebene nicht
+erfinden darf, also wartet sie auf einen neuen Schlüssel `duxt.organization` und
+bleibt abwesend, bis ein Konsument ihn füllt — dieselbe Haltung wie in ADR-0005.
+
+## Erwogene Alternativen
+
+**Die drei Module behalten und nur `nuxt-schema-org` ergänzen.** Die schmalste
+Änderung, verworfen am Canonical: Die Versionsregel und der automatische Canonical
+müssen ohnehin miteinander vereinbart werden, und ohne `nuxt-seo-utils` bedeutet
+das, den handgeschriebenen `og:`/`twitter:`-Block zu behalten, der auf zwei
+Seiten bereits vergessen worden war.
+
+**Die strukturierten Daten von Hand schreiben und behalten.** Es funktionierte, es
+hatte keine Abhängigkeit, und es wurde verworfen, weil der Graph ohnehin der Teil
+war, der am ehesten falsch und am wenigsten bemerkt wird — und weil eine zweite
+Seite mit einem zweiten Knoten eine zweite Kopie der Identität der Site in sich
+hinein bedeutet hätte.
+
+**Den Link-Checker den Build brechen lassen.** Verworfen, weil zwei Wächter über
+einer Regel bedeuten, dass der laschere entscheidet, wann ein Build bricht. Die
+Prüfung, die die Versionen und Sprach-Rückfälle dieser Ebene versteht, ist ihre
+eigene.
diff --git a/docs/de/99.adr/index.md b/docs/de/99.adr/index.md
new file mode 100644
index 00000000..4dbb0e4a
--- /dev/null
+++ b/docs/de/99.adr/index.md
@@ -0,0 +1,24 @@
+---
+title: Architekturentscheidungen
+description: Das Entscheidungsprotokoll — jede Architekturentscheidung, die für duxt festgehalten wurde.
+icon: lucide:gavel
+---
+
+Eine Entscheidung verdient ein ADR, wenn sie spätere Arbeit einschränkt und ihre
+Begründung sonst verloren ginge: eine Wahl zwischen echten Alternativen, eine
+Konvention, der jeder Teil des Projekts folgen muss, eine Abwägung, die wie ein
+Fehler aussieht, bis man den Grund kennt. Aufzeichnungen werden nur angehängt —
+eine umgekehrte Entscheidung wird als neues ADR geschrieben, das das alte
+ablöst, nie als Änderung daran.
+
+| ADR | Entscheidung | Status | Datum |
+| :--------------------------------------------------------------- | :------------------------------------------------ | :------- | :--------- |
+| [ADR-0001](/adr/0001-build-duxt-as-a-layer-carrying-a-module) | duxt als Ebene bauen, die ein Modul trägt | accepted | 2026-09-06 |
+| [ADR-0002](/adr/0002-generate-the-collections-from-one-source-list) | Die Collections aus einer Quellenliste erzeugen | accepted | 2026-09-06 |
+| [ADR-0003](/adr/0003-decide-the-url-prefixes-at-build-time) | Die URL-Präfixe zur Build-Zeit entscheiden | accepted | 2026-09-06 |
+| [ADR-0004](/adr/0004-render-markdown-components-with-mdc) | Markdown-Komponenten mit MDC rendern | accepted | 2026-09-06 |
+| [ADR-0005](/adr/0005-ship-the-layer-without-owner-specific-links) | Die Ebene ohne betreiberspezifische Links ausliefern | accepted | 2026-09-06 |
+| [ADR-0006](/adr/0006-rebuild-on-a-schedule-rather-than-refresh-at-runtime) | Nach Zeitplan neu bauen statt zur Laufzeit aktualisieren | accepted | 2026-09-06 |
+| [ADR-0007](/adr/0007-serve-translations-as-collections-of-their-own) | Übersetzungen als eigene Collections ausliefern | accepted | 2026-09-06 |
+| [ADR-0008](/adr/0008-build-the-theme-on-owned-shadcn-vue-components) | Das Theme auf eigenen shadcn-vue-Komponenten bauen | accepted | 2026-09-08 |
+| [ADR-0009](/adr/0009-take-the-seo-stack-from-the-nuxt-seo-bundle) | Den SEO-Unterbau aus dem Nuxt-SEO-Bündel nehmen | accepted | 2026-09-08 |
diff --git a/docs/de/_partials/install.md b/docs/de/_partials/install.md
new file mode 100644
index 00000000..7a5ec489
--- /dev/null
+++ b/docs/de/_partials/install.md
@@ -0,0 +1,6 @@
+---
+title: Installation
+---
+
+::package-managers{command="add -D @kirchdev/duxt"}
+::
diff --git a/docs/de/index.md b/docs/de/index.md
new file mode 100644
index 00000000..8e7606df
--- /dev/null
+++ b/docs/de/index.md
@@ -0,0 +1,20 @@
+---
+title: Dokumentation
+description: Eine Nuxt-Ebene, die den docs-Ordner eines Repositories in eine versionierte Dokumentationsseite über mehrere Repositories verwandelt.
+icon: lucide:book-open-text
+navigation: false
+---
+
+Alles, was duxt tut, in vier Teilen. Beginne links, wenn du neu bist, oder wähle
+den Teil, wegen dem du gekommen bist.
+
+::page-cards
+::
+
+Zwei Dinge fehlen hier bewusst. Wie man zu duxt selbst beiträgt, steht in
+[`CONTRIBUTING.md`](https://github.com/kirchDev/duxt/blob/main/CONTRIBUTING.md),
+und die Entscheidungen hinter der Ebene sind in den
+[Architekturentscheidungen](/adr) festgehalten.
+
+Worauf die Ebene aufbaut und wonach sie gebaut wurde, steht in den
+[Danksagungen](/credits).
diff --git a/docs/es/1.getting-started/1.installation.md b/docs/es/1.getting-started/1.installation.md
new file mode 100644
index 00000000..870f421b
--- /dev/null
+++ b/docs/es/1.getting-started/1.installation.md
@@ -0,0 +1,73 @@
+---
+title: Instalación
+description: Añade la capa, extiéndela, escribe Markdown.
+icon: lucide:download
+---
+
+## Añadir la dependencia
+
+:partial{name="install"}
+
+## Extender la capa
+
+```ts [nuxt.config.ts]
+export default defineNuxtConfig({
+ extends: ['@kirchdev/duxt']
+});
+```
+
+## Escribir tu documentación
+
+El Markdown va en `docs/`, en la raíz de tu repositorio — resuelto contra el
+repositorio y no contra el directorio donde se ejecuta el sitio, de modo que un
+sitio en una subcarpeta lo encuentra. Un archivo es una página, una carpeta es
+una sección, y el frontmatter lleva el título, la descripción y el icono.
+
+::file-tree{title="tu-proyecto/"}
+---
+tree:
+ - name: docs/
+ children:
+ - name: index.md
+ - name: 1.getting-started/
+ children:
+ - name: index.md
+ - name: 1.installation.md
+ - name: 2.guides/
+ children:
+ - name: 1.deploying.md
+ - name: nuxt.config.ts
+---
+::
+
+```md [docs/1.getting-started/index.md]
+---
+title: Introducción
+description: Qué es este proyecto.
+icon: lucide:rocket
+---
+
+Tu primera página.
+```
+
+Un prefijo numérico ordena el árbol y se elimina de la URL:
+`1.getting-started/` se sirve en `/getting-started`. Reordenar una sección nunca
+mueve una URL — solo renombrar la parte posterior al punto lo hace.
+
+::callout{type="tip" title="Sin H1 en el cuerpo"}
+El `title` de la página se representa como el ``. Un encabezado propio al
+principio del archivo sería el segundo de la página.
+::
+
+## Qué necesita la compilación
+
+- **Node 24.** La capa lee su base de datos mediante `node:sqlite`, así que no
+ se instala ni hace falta ningún controlador nativo de SQLite.
+- **Acceso de red a tus fuentes**, si alguna es otro repositorio, y un token
+ para las privadas — Content lo lee del `auth` de la colección.
+
+## Resultado
+
+`nuxt dev` sirve el sitio. Todo lo que dibuja el tema son datos con un valor por
+defecto, así que el siguiente paso es el único archivo que lo sobrescribe:
+[Configuración](/getting-started/configuration).
diff --git a/docs/es/1.getting-started/2.configuration.md b/docs/es/1.getting-started/2.configuration.md
new file mode 100644
index 00000000..fd4b9a84
--- /dev/null
+++ b/docs/es/1.getting-started/2.configuration.md
@@ -0,0 +1,105 @@
+---
+title: Configuración
+description: Un archivo configura todo el sitio — qué va en él y cómo se combina.
+icon: lucide:settings
+---
+
+Todo lo que muestra el tema son datos, y `app/app.config.ts` es donde un sitio
+los declara. No hay un segundo archivo de configuración: la misma clave `duxt`
+lleva el tema, las fuentes de documentación y los enlaces propios del sitio.
+
+```ts [app/app.config.ts]
+export default defineAppConfig({
+ duxt: {
+ title: 'mi proyecto',
+ version: 'v1.4.0',
+ sources: [{ path: 'docs' }]
+ }
+});
+```
+
+Cada clave y su valor por defecto están en la
+[referencia de configuración](/reference/configuration); la entrada `sources`
+tiene [su propia página](/reference/sources).
+
+## Cómo se combina tu configuración
+
+Tu objeto `duxt` se combina clave por clave sobre los valores por defecto de la
+capa, y **un array tuyo reemplaza el de la capa** en lugar de añadirse a él. Eso
+importa porque la combinación de `app.config` de Nuxt hace lo contrario:
+concatena arrays, lo que te dejaría sin poder eliminar una sola entrada de la
+barra de navegación que trae la capa. Por eso la capa no incluye ninguna lista
+en `app.config` y las combina ella misma.
+
+La regla práctica: sobrescribe `footer.copyright` y `footer.legal` queda
+intacto; declara `navigation` y la única entrada de la capa desaparece.
+
+## Dos claves se leen en tiempo de compilación
+
+`sources` y `locales` deciden las colecciones, las rutas y los enlaces
+`hreflang`, y las tres quedan fijadas antes de la primera petición. Cambiar
+cualquiera de ellas exige recompilar; todo lo demás surte efecto en cuanto se
+recarga la configuración.
+
+## Algunas claves se generan, no se escriben
+
+`resolvedSources`, `layerVersion` y `layerRepository` aparecen en la
+configuración en tiempo de ejecución y pertenecen a la compilación.
+`resolvedSources` es el manifiesto que el tema lee realmente — qué colección
+sirve qué prefijo de URL — resuelto a partir de la lista `sources` que
+escribiste. Escribir cualquiera de los tres a mano se sobrescribe.
+
+## Lo que la capa no presume saber
+
+`links`, `aside.links` y `footer.legal` se entregan **vacíos**. Un repositorio,
+un gestor de incidencias, una comunidad y un aviso legal nombran cada uno a un
+proyecto concreto, así que un valor por defecto con los de duxt pondría ante tus
+lectores un «Star on GitHub» que marca el trabajo de otra persona.
+
+`sections` se entrega vacío por la razón vecina: nombra las partes de primer
+nivel de tu árbol de documentación, y la capa no puede saber cómo las llamaste.
+Defínelo cuando tu documentación tenga secciones; hasta entonces la fila
+permanece oculta y la barra lateral muestra el árbol completo.
+
+```ts
+sections: [
+ { label: 'Guías', to: '/guides', icon: 'lucide:book-open' },
+ { label: 'Referencia', to: '/reference', icon: 'lucide:list' }
+]
+```
+
+Escribe los tuyos — como cadena simple, o como registro por locale si el sitio
+sirve varios idiomas:
+
+```ts
+links: [
+ {
+ icon: 'lucide:github',
+ to: 'https://github.com/tu/tu-proyecto',
+ label: { 'en-GB': 'Repository', 'es-ES': 'Repositorio' }
+ }
+]
+```
+
+::callout{type="warning" title="duxt.defaults.* no es tuyo"}
+Las claves i18n de la capa traducen los valores por defecto que ella entrega.
+Son internas: renombrar una no es un cambio incompatible, e i18n responde a una
+clave inexistente imprimiendo la clave — así que la rotura sería silenciosa en
+tu sitio. Usa un literal, una clave propia o la forma de registro de arriba.
+::
+
+## El origen del sitio
+
+duxt no puede adivinar el dominio desde el que se servirá, y cuatro cosas lo
+necesitan: `hreflang`, `canonical`, el sitemap y las imágenes de Open Graph.
+Decláralo una vez, donde Nuxt ya lo pide:
+
+```ts [nuxt.config.ts]
+i18n: { baseUrl: 'https://docs.example.com' }
+```
+
+o como `NUXT_PUBLIC_I18N_BASE_URL` en el despliegue. La capa lo copia a
+`site.url`, que es donde miran los módulos de SEO. Si no se define, cada uno de
+ellos degrada a salida relativa en lugar de inventar un dominio — un `canonical`
+relativo sigue resolviéndose, uno adivinado es activamente erróneo. Véase
+[SEO](/concepts/seo).
diff --git a/docs/es/1.getting-started/index.md b/docs/es/1.getting-started/index.md
new file mode 100644
index 00000000..a4b57d53
--- /dev/null
+++ b/docs/es/1.getting-started/index.md
@@ -0,0 +1,36 @@
+---
+title: Introducción
+description: Qué es duxt, y qué llega con una sola línea de configuración.
+icon: lucide:rocket
+---
+
+duxt es una capa de Nuxt. La extiendes, pones Markdown en `docs/` y ya tienes un
+sitio de documentación: navegación, tabla de contenidos, búsqueda, tema,
+`llms.txt` y un servidor MCP incluidos.
+
+```ts [nuxt.config.ts]
+export default defineNuxtConfig({
+ extends: ['@kirchdev/duxt']
+});
+```
+
+Eso es todo el caso de una sola carpeta. Sin colección, sin diseño, sin
+`content.config.ts`.
+
+## Lo que no es
+
+duxt no obtiene el contenido. [Nuxt Content v3](https://content.nuxt.com) ya
+descarga un repositorio git en una rama o una etiqueta, se autentica contra uno
+privado y cachea el resultado por hash — y duxt usa eso en lugar de
+reimplementarlo.
+
+Lo que duxt añade es la parte que, si no, se reescribe en cada repositorio de
+documentación: una colección por versión y repositorio, el esquema de URL que
+las sostiene, el selector que sabe qué página existe dónde, y un tema encima.
+Todo ello se genera a partir de una sola lista — véase
+[Fuentes](/concepts/sources).
+
+## Por dónde seguir
+
+::page-cards
+::
diff --git a/docs/es/2.concepts/1.layers.md b/docs/es/2.concepts/1.layers.md
new file mode 100644
index 00000000..a2db5ad9
--- /dev/null
+++ b/docs/es/2.concepts/1.layers.md
@@ -0,0 +1,50 @@
+---
+title: Capas y sobrescritura
+description: Qué te da una capa de Nuxt, qué te quita, y qué rutas no se resuelven como se leen.
+icon: lucide:layers
+---
+
+duxt es una capa, no una plantilla. La extiendes y cada archivo que trae se
+vuelve sobrescribible: pon un archivo del mismo nombre en tu propio proyecto y
+Nuxt resolverá el tuyo primero. Nada se copia a tu repositorio, así que una
+actualización es un cambio de versión y no una fusión.
+
+## Qué significa esto en la práctica
+
+| Quieres | Haces |
+| :----------------------------------------- | :----------------------------------------------------- |
+| Cambiar un texto, un enlace, un interruptor | Definir la clave en tu `app/app.config.ts` |
+| Reemplazar una pieza del diseño | Crear `app/components/DuxtFooter.vue` en tu proyecto |
+| Rediseñar el tema | Redefinir una variable CSS en tu propia hoja de estilos |
+| Añadir una página | Escribir Markdown en `docs/` |
+
+El orden es deliberado: los tres primeros son compromisos progresivamente
+mayores, y un componente que reemplazas es un componente que ahora mantienes.
+Véase [Sobrescribir el tema](/guides/override-the-theme).
+
+## Qué te quita
+
+Una capa nunca conoce el proyecto que se apoya en ella. Dos consecuencias que
+nota quien la consume:
+
+- **`@` es tuyo, no de la capa.** El alias pertenece a quien extiende duxt, así
+ que las importaciones de la propia capa usan `@duxt`. El tuyo sigue apuntando
+ a tu `app/`.
+- **Una colección se resuelve contra la capa, no contra ti.** Content registra
+ como raíz de cada colección la capa que la declaró, de modo que un `cwd`
+ relativo en la configuración de duxt apuntaría dentro de duxt. Por eso la capa
+ calcula una ruta absoluta al cargar, lo cual funciona porque
+ `content.config.ts` es código ejecutado y no un archivo de datos: sobre esa
+ costura se asienta todo el atajo de `sources`.
+
+## Por qué está construido así
+
+La alternativa es una plantilla de inicio: `nuxi init`, y el tema pasa a ser una
+copia dentro de tu repositorio. Eso da libertad total y ningún camino de
+actualización — cada mejora del tema es un diff que alguien debe aplicar a mano.
+Una capa lo invierte: heredas las mejoras y solo pagas donde discrepaste.
+
+Lo que fija todo esto es la superficie pública. Un componente, una página o una
+clave de `app.config` que puedes sobrescribir es un nombre del que dependes, así
+que renombrar uno es un cambio incompatible de la capa — que es justo lo que
+documenta la [referencia](/reference).
diff --git a/docs/es/2.concepts/2.sources.md b/docs/es/2.concepts/2.sources.md
new file mode 100644
index 00000000..815f8a82
--- /dev/null
+++ b/docs/es/2.concepts/2.sources.md
@@ -0,0 +1,59 @@
+---
+title: Fuentes
+description: Una lista de fuentes se convierte en cada colección, prefijo y versión que sirve el sitio.
+icon: lucide:git-branch
+---
+
+Una **fuente** es una carpeta con Markdown: en este repositorio o en otro, en el
+checkout actual o en refs con nombre. La lista `sources` es el único sitio donde
+se declaran, y todo lo demás se genera a partir de ella.
+
+```ts [app/app.config.ts]
+sources: [
+ { path: 'docs', slug: 'acme' },
+ { repo: 'acme/api', path: 'docs', refs: ['main', 'v2.0.0', 'v1.4.0'] }
+]
+```
+
+Dos repositorios y tres refs: cinco colecciones, los prefijos de URL que las
+sirven, un selector de versiones, las reglas de redirección y las entradas del
+sitemap. Nada de eso se escribe a mano. Cada campo está en la
+[referencia de fuentes](/reference/sources).
+
+## Cómo funciona
+
+Content v3 ya hace la mitad difícil. La fuente de una colección acepta una URL
+de repositorio, una rama o etiqueta y credenciales, y Content la descarga y la
+cachea por hash — múltiples repositorios, repositorios privados y leer desde una
+etiqueta ya existen ahí. duxt no reimplementa nada de eso.
+
+Lo que duxt añade ocurre al cargar la configuración. `content.config.ts` es
+código ejecutado, no un archivo de datos, así que puede leer el `app.config.ts`
+del sitio y calcular sus colecciones a partir de esa lista — una colección por
+repositorio × ref, cada una con un `cwd` absoluto, un nombre derivado del slug y
+el esquema de página que necesita el tema. La misma lista la resuelve una
+segunda vez la compilación, en el **manifiesto**: qué colección sirve qué
+prefijo de URL, qué ref es la predeterminada, de dónde vino cada página. Ese
+manifiesto es lo que lee el tema, y se escribe de vuelta en la configuración de
+la app como `resolvedSources`.
+
+## Por qué está construido así
+
+Declarar las colecciones a mano no escala en la única dirección en la que la
+documentación realmente crece: tres versiones en catorce repositorios son
+cuarenta y dos declaraciones, y cada publicación edita las catorce. El atajo
+mantiene la lista tan larga como el número de proyectos.
+
+El coste es que una lista de fuentes expresa menos que una colección escrita a
+mano. Es un intercambio deliberado: quien necesite algo que el atajo no sepa
+decir escribe su propio `content.config.ts` y toma el control por completo,
+porque Content combina el archivo de cada capa y gana el posterior.
+
+## Lo que se obtiene de paso
+
+Como una fuente nombra un repositorio, una ref y una carpeta, varias funciones
+no necesitan configuración propia: «Editar esta página», la fecha de última
+actualización y la lista de personas que han contribuido se derivan de ahí. Leer
+el historial de una fuente **remota** cuesta un clon completo, así que espera a
+`history: true` — un clon superficial informaría del autor de la punta para cada
+archivo, y eso son datos erróneos, no datos ausentes.
diff --git a/docs/es/2.concepts/3.urls-and-versions.md b/docs/es/2.concepts/3.urls-and-versions.md
new file mode 100644
index 00000000..adb11302
--- /dev/null
+++ b/docs/es/2.concepts/3.urls-and-versions.md
@@ -0,0 +1,76 @@
+---
+title: URL y versiones
+description: Cómo aparecen los segmentos de repositorio y versión, y qué se le dice a quien lee una versión antigua.
+icon: lucide:git-compare
+---
+
+Cada página se sirve en
+
+```text
+/[repo]/[version]/[...ruta]
+```
+
+con ambos prefijos opcionales — y la decisión de si cada uno aparece se toma
+**en tiempo de compilación a partir de la lista de fuentes**, nunca por
+petición.
+
+- Un segmento de repositorio aparece en cuanto hay más de un repositorio, o
+ cuando `showRepo` lo fuerza.
+- Un segmento de versión aparece en cuanto una fuente publica más de una ref, o
+ cuando `showVersion` lo fuerza.
+
+Así, una sola carpeta sin versiones sirve `/guides/deploying`, y nada en la URL
+delata que existan repositorios o versiones.
+
+## Por qué los prefijos se deciden al compilar
+
+Si ambos prefijos fueran opcionales por petición, el primer segmento sería
+ambiguo: `/guides/…` podría ser una carpeta, un repositorio o una versión, y
+solo consultando los tres se sabría cuál. Como la forma de una URL queda fijada
+antes de la primera petición, el enrutador nunca tiene que adivinar.
+
+Lo que queda es una **colisión de nombres** mientras un prefijo está activo: una
+carpeta de documentación llamada como un repositorio o una versión. La
+compilación la rechaza en lugar de resolverla en silencio hacia un lado; véase
+[Qué comprueba la compilación](/concepts/build-checks).
+
+## La versión por defecto
+
+Una ref por repositorio se sirve sin prefijo de versión: la primera de la lista,
+o la que nombre `sourceOptions.defaultRef`. Esa es la URL a la que enlazar y a
+la que se dirige a los buscadores.
+
+## Dónde está una versión en su vida
+
+`status` en una fuente o una ref es un ciclo de vida, no un sinónimo de «es la
+predeterminada»: un sitio puede publicar v2 como actual mientras v1 es
+simplemente más antigua y v0 está realmente muerta, y hay que decirle al lector
+en cuál de las tres se encuentra.
+
+| Estado | Qué se le dice al lector | En el sitemap |
+| :----------- | :-------------------------------------------- | :----------------------- |
+| `upcoming` | Esto aún puede cambiar — sin consejo de subir | Solo si es predeterminada |
+| `current` | Nada | Solo si es predeterminada |
+| `maintained` | Existe una versión más nueva | Solo si es predeterminada |
+| `deprecated` | Existe una versión más nueva; actualiza | Solo si es predeterminada |
+| `eol` | Esta versión está muerta | Nunca |
+
+`upcoming` es el único que no trata de la edad. Una versión puede no ser la
+predeterminada porque todavía no ha ocurrido, y decirle a ese lector que
+«actualice» es exactamente al revés. Semver no puede situar una rama frente a
+una etiqueta, así que este caso se declara en la configuración en lugar de
+deducirse.
+
+Junto al aviso, una página de una versión no predeterminada lleva `noindex` y un
+`canonical` que apunta a la misma página en la versión predeterminada — de otro
+modo, un buscador serviría v0.7.0 con la misma probabilidad que la documentación
+de hoy. Las dos mitades van juntas: el aviso es la visible, el link-rel la
+invisible, y cualquiera de ellas por separado deja a un público equivocado.
+
+## Cuando falta una página en una versión
+
+El selector solo ofrece una versión que realmente tenga la página, y el enlace
+canónico resuelve a la ruta existente más cercana. Una URL que no resuelve a
+nada se responde con un 404 que sugiere la página más próxima de la navegación —
+las URL versionadas se fallan fácilmente por un segmento, y un 404 vacío deja al
+lector sin nada que pulsar.
diff --git a/docs/es/2.concepts/4.collections.md b/docs/es/2.concepts/4.collections.md
new file mode 100644
index 00000000..e3a6802d
--- /dev/null
+++ b/docs/es/2.concepts/4.collections.md
@@ -0,0 +1,72 @@
+---
+title: Colecciones
+description: Qué construye Content a partir de las fuentes, y las cuatro cosas que añade el esquema.
+icon: lucide:database
+---
+
+Una colección es la unidad de obtención de Content: un glob, un lugar de donde
+leerlo y un esquema. duxt declara una por repositorio × ref, así que un sitio
+con una carpeta tiene una colección y un sitio con dos repositorios en tres refs
+tiene seis.
+
+Sus **nombres son datos**, no una constante. Un sitio con dos repositorios tiene
+`docs_duxt` y `docs_workflows` y ninguna colección llamada `docs` — por eso el
+tema lee el nombre de la colección del manifiesto en lugar de fijarlo en el
+código, y por eso una consulta propia debería hacer lo mismo:
+
+```vue
+
+```
+
+## Qué añade el esquema
+
+Content solo almacena lo que un esquema declara, así que cuatro cosas que el
+tema necesita se declaran explícitamente. La lista completa está en la
+[referencia de frontmatter](/reference/frontmatter); las razones están aquí:
+
+- **`icon`, `layout`, `navigation`** — campos del tema. Sin el esquema, un
+ `icon:` en el frontmatter se descarta en silencio antes de que la barra
+ lateral llegue a verlo.
+- **`rawbody`** — el Markdown tal como se escribió. Dos cosas lo necesitan: el
+ botón que entrega una página a un modelo, y `llms-full.txt`.
+- **`redirectFrom`** — convertido en reglas de ruta por la compilación; véase
+ [Mover una página](/guides/move-a-page).
+- **`sitemap`** — lo que mete estas páginas en el sitemap. Una colección tiene
+ que declarar el campo, o el sitemap listará las rutas del sitio y ni una sola
+ página de documentación.
+
+## Fragmentos
+
+`_partials/` en la carpeta de documentación de **cualquier** fuente alimenta una
+colección compartida, y `:partial{name="install"}` en cualquier página de
+cualquier fuente lo representa. Content no trae ninguna directiva de inclusión,
+y a través de varios repositorios esa carencia no tiene solución alguna — una
+nota de instalación que debe decir lo mismo en tres proyectos se copia en tres
+proyectos y se desvía.
+
+Los fragmentos llevan la misma dimensión de idioma que las páginas:
+`docs/_partials/` es `duxt_partials`, `docs/de/_partials/` es
+`duxt_partials_de`, y `:partial` recorre [la misma cadena de
+respaldo](/concepts/localisation) que recorrió la página en la que está. Tiene
+que ser la misma cadena: que una página y los bloques que incluye caigan en
+idiomas distintos es justo como se acaba con una página traducida a medias sin
+que nada lo indique.
+
+Los fragmentos quedan excluidos de las colecciones de páginas. De lo contrario
+cada fragmento sería también una página: en la barra lateral, en la búsqueda, en
+`llms.txt`.
+
+## Los borradores son archivos, no banderas
+
+Un borrador es `deploying.draft.md`, no `draft: true` en el frontmatter. La
+bandera es la escritura obvia y la que no puede funcionar: el contenido de una
+colección se declara antes de que Content haya leído un solo archivo — en una
+fuente remota, antes de que se haya descargado — así que en ese momento nada
+sabe lo que dice ningún frontmatter. Un nombre de archivo sí se conoce. Content
+elimina `.draft` de la URL, de modo que la página se sirve en `/deploying` en el
+servidor de desarrollo y simplemente no está en la compilación.
diff --git a/docs/es/2.concepts/5.build-checks.md b/docs/es/2.concepts/5.build-checks.md
new file mode 100644
index 00000000..895681bd
--- /dev/null
+++ b/docs/es/2.concepts/5.build-checks.md
@@ -0,0 +1,87 @@
+---
+title: Qué comprueba la compilación
+description: Los fallos silenciosos que tuvo esta capa, convertidos en mensajes.
+icon: lucide:shield-check
+---
+
+Todos los errores que esta capa tuvo realmente fueron silenciosos: una colección
+que Content descartó porque su nombre no era un identificador de JavaScript, una
+página que daba 404 porque un enlace había quedado obsoleto, una navegación que
+volvía vacía. Ninguno de los tres dijo nada — la compilación estaba en verde y
+el sitio estaba mal. Así que tras la compilación se ejecutan comprobaciones,
+todas leyendo lo mismo: la caché de análisis que Content deja atrás.
+
+| Comprobación | Gravedad |
+| :--------------------------------------------- | :------- |
+| Una colección sin páginas | error |
+| Una carpeta que choca con un segmento de URL | error |
+| Un `title` o `description` ausente | aviso |
+| Un enlace interno o ancla roto | aviso |
+| Qué lleva cada idioma y qué se ha quedado quieto | nota |
+
+## Por qué la gravedad no es uniforme
+
+Un error cuesta una página que nadie puede alcanzar y que nada en el sitio
+explicaría jamás — una colección vacía es un 404 en cada página de una versión.
+Un aviso cuesta calidad: la página sigue representándose, y una fuente remota
+puede quedarse obsoleta entre una publicación y la siguiente sin que sea culpa
+de esta compilación. Hacer fallar la compilación porque el repositorio de otra
+persona haya avanzado convertiría una compilación en verde en algo dependiente
+de un tercero.
+
+Una **nota** no cuesta nada: es un estado del sitio, no un defecto en él. Una
+página sin traducir no es un error, y reportarla como aviso enseñaría a todo el
+mundo a leer por encima de los avisos.
+
+## El informe de traducciones
+
+Una línea por idioma, en cada compilación:
+
+```
+[duxt] translations
+[duxt] de: 118/121 pages, 3 behind the original
+[duxt] "docs/guides/deploying.md" has no de translation.
+[duxt] "docs/de/concepts/sources.md" has not moved since the original changed.
+```
+
+La segunda cifra necesita `history: true` en la fuente: las fechas vienen de
+git, y una fuente que no ha pedido su historial no tiene ninguna. Sin él el
+informe cuenta la cobertura y no dice nada sobre el desfase, en lugar de
+adivinarlo a partir de la ausencia.
+
+Existe por un fallo concreto. OpenCode tradujo su documentación a diecisiete
+idiomas con un agente en CI, apagó el flujo de trabajo, y en ningún sitio
+constaba que las traducciones hubieran dejado de moverse. La capa ya sabe, por
+página, qué idiomas la llevan y cuándo cambió cada archivo por última vez; un
+informe es lo que convierte eso en algo que alguien ve.
+
+## Lo que las comprobaciones saben y tú no
+
+- **Una colisión de carpetas** solo es un problema mientras un prefijo está
+ activo. Una carpeta de documentación llamada `v2` está bien en un sitio sin
+ versiones e inalcanzable en uno versionado, porque gana el prefijo. La
+ compilación sabe cuál es el caso, y el mensaje nombra el archivo y ofrece la
+ solución.
+- **Un enlace se resuelve como lo resuelve el tema.** Una ruta absoluta escrita
+ en una página es relativa a la fuente de esa misma página, así que se prueba
+ primero bajo el prefijo de la fuente y después desnuda. Un verificador que
+ solo hiciera la forma desnuda informaría de todos los enlaces correctos de un
+ sitio multirrepositorio.
+- **Un enlace se resuelve en su propio idioma.** Todos los idiomas de una fuente
+ sirven rutas de contenido idénticas — la locale va delante de la URL, no en el
+ árbol de contenido — y las anclas se derivan del texto del encabezado, así que
+ un encabezado alemán tiene un ancla alemana. Una comprobación que resolviera
+ solo por la ruta contrastaría un enlace alemán con encabezados ingleses y
+ reportaría cada ancla correcta de un sitio traducido. Un enlace a una página
+ que un idioma no lleva recurre al original, que es lo que hace el sitio.
+- **Las URL externas no se comprueban.** Verificar una metería la red en la
+ compilación, y un host ajeno inaccesible no es motivo para que tu
+ documentación no se publique.
+
+## Escribe los enlaces como rutas absolutas de documentación
+
+`/concepts/sources`, no `../2.concepts/2.sources.md`. El prefijo bajo el que se
+sirve una página se decide al compilar, así que escribir rutas sin él es lo que
+permite que el mismo Markdown sirva `/guides/deploying` en un sitio y
+`/acme/v2.0.0/guides/deploying` en otro. Es también la única forma que ve el
+verificador de enlaces.
diff --git a/docs/es/2.concepts/6.machine-readers.md b/docs/es/2.concepts/6.machine-readers.md
new file mode 100644
index 00000000..30bfd70c
--- /dev/null
+++ b/docs/es/2.concepts/6.machine-readers.md
@@ -0,0 +1,48 @@
+---
+title: Lectores automáticos
+description: Qué obtiene un modelo — llms.txt, el Markdown en bruto de cualquier página y un servidor MCP real.
+icon: lucide:bot
+---
+
+La documentación la leen tanto los modelos como las personas, y las cuatro
+respuestas de abajo se generan a partir de las mismas colecciones desde las que
+se representan las páginas — así que ninguna puede desviarse de lo publicado.
+
+| Ruta | Qué es |
+| :--------------- | :------------------------------------------------------------------------ |
+| `/llms.txt` | Un índice: nombre del sitio, resumen y cada página como entrada enlazada |
+| `/llms-full.txt` | Las mismas páginas, enteras, concatenadas en un único archivo Markdown |
+| ` .md` | El código Markdown de cualquier página |
+| `/mcp` | Un servidor MCP — `list_pages`, `search_docs`, `read_page` |
+
+## Markdown, no HTML
+
+Todo lo de aquí entrega el Markdown tal como se escribió, y por eso el esquema
+de la colección pide a Content que almacene `rawbody`. Un modelo al que se le da
+HTML representado tiene que deshacer un diseño para encontrar un encabezado; uno
+al que se le da la fuente lee lo que escribió el autor, con los bloques de
+código todavía como bloques y un bloque MDC todavía como una llamada a
+componente.
+
+`
.md` lo sirve un **middleware** en lugar de una ruta, porque `.md` es
+un sufijo del último segmento de la ruta y no un segmento propio — no existe un
+patrón de ruta de Nitro que signifique «cualquier ruta que termine en .md».
+
+## Por qué los dos archivos llms
+
+La convención de [llmstxt.org](https://llmstxt.org) nombra dos, y responden a
+preguntas distintas: un modelo que ha leído el índice todavía tiene que
+descargar sesenta páginas, y uno al que se le entrega el archivo completo ya las
+ha leído. Cuál sale más barato depende de la pregunta, así que el sitio publica
+ambos.
+
+## Por qué un servidor MCP real
+
+`/mcp` habla el protocolo mediante el SDK oficial en lugar de ser un endpoint
+JSON de diseño propio de duxt. Un cliente se conecta a él como se conecta a
+cualquier otra cosa, en vez de que alguien escriba un adaptador alrededor de una
+forma que solo tiene este tema.
+
+Su búsqueda es un `LIKE` sobre títulos y descripciones, no el índice que usa el
+navegador: ese se construye en el cliente a partir del volcado de la colección,
+y una herramienta de servidor tiene la propia base de datos.
diff --git a/docs/es/2.concepts/7.seo.md b/docs/es/2.concepts/7.seo.md
new file mode 100644
index 00000000..a911e9fb
--- /dev/null
+++ b/docs/es/2.concepts/7.seo.md
@@ -0,0 +1,77 @@
+---
+title: SEO
+description: Qué emite cada página para los rastreadores y las tarjetas sociales, qué módulo lo escribe, y el único valor que la capa no puede adivinar.
+icon: lucide:search
+---
+
+Cada página emite un head completo, una descripción estructurada de sí misma, una
+tarjeta social y una entrada en la sitemap. Casi todo lo escribe [Nuxt
+SEO](https://nuxtseo.com), que la capa instala como un solo paquete; el resto es
+la parte que depende de versiones y traducciones, y esa es propia de la capa. Un
+valor tiene que venir de ti.
+
+## Por página
+
+- **Meta** — el título con el nombre del sitio añadido, la descripción y el par
+ `og:`/`twitter:` derivado de ambos. `og:type` y `og:url` se fijan por página;
+ `og:locale` y sus alternativas salen de los idiomas que sirve el sitio.
+- **JSON-LD** — un grafo por página: un `WebSite` (y tu `Organization`, una vez
+ definida) declarados para todo el sitio, más un `TechArticle` y una
+ `BreadcrumbList` construida con el mismo rastro que dibuja la miga de pan.
+- **Una imagen de Open Graph**, renderizada desde la plantilla propia de la capa
+ — también en la página de inicio. Publica un componente con el mismo nombre
+ para reemplazarla.
+- **`canonical` y `robots`**, que son la mitad de versión — ver
+ [URL y versiones](/concepts/urls-and-versions). El canónico de una versión
+ antigua apunta a la actual, y por eso lo escribe la capa en lugar de dejar el
+ automático del módulo.
+
+La página de error lleva `noindex, follow`: una 404 indexada compite con la
+página que buscaba quien lee, y las sugerencias que ofrece son páginas reales a
+las que un rastreador todavía debe llegar.
+
+## Para todo el sitio
+
+`/sitemap.xml` lista las páginas de la versión por defecto, por idioma. Una
+versión que no es la por defecto queda excluida porque sus páginas llevan
+`noindex` de todos modos, y una versión `eol` queda excluida sea o no la por
+defecto: listar cualquiera de las dos pide a un rastreador que traiga justo lo
+que la página le dirá luego que descarte. `/robots.txt` se genera al lado.
+
+`duxt.organization` es lo que nombra a quien publica — ver
+[Configuración](/reference/configuration). Sin definir, el sitio sigue
+describiéndose como un `WebSite`; lo que pierde es la entidad detrás, que es lo
+que lee un panel de conocimiento o un resumen de IA para acreditar una fuente.
+
+## Qué se comprueba y qué solo se informa
+
+`pnpm check:seo` lee las páginas construidas y falla ante un segundo `canonical`,
+un `hreflang` ausente, una página de error indexable, un `og:locale` con formato
+incorrecto o un grafo que no se analiza. Se ejecuta en `pnpm check` después de la
+compilación, porque estas etiquetas solo existen en el HTML renderizado.
+
+El verificador de enlaces informa en lugar de fallar. Un enlace que no lleva a
+ninguna parte ya hace fallar la compilación mediante las
+[comprobaciones de compilación](/concepts/build-checks) propias de la capa, que
+son las que entienden versiones y respaldos de idioma; el valor del módulo aquí
+es el panel de devtools y la comprobación en vivo mientras se escribe.
+
+## El único valor que debes declarar
+
+El origen. `hreflang`, `canonical`, el sitemap y las imágenes de Open Graph son
+todas URL absolutas, y una capa no puede conocer el dominio en el que se
+desplegará. Define `i18n.baseUrl` o `NUXT_PUBLIC_I18N_BASE_URL`; la capa lo
+copia a `site.url`, que es donde miran los módulos de SEO.
+
+Si no se define, cada uno de ellos degrada a salida relativa en lugar de
+inventar un dominio. Esa es la elección deliberada: un `canonical` relativo
+sigue resolviéndose correctamente, y uno adivinado es activamente erróneo —
+apuntaría a un rastreador al sitio de otra persona.
+
+## Una variable más para un despliegue progresivo
+
+`NUXT_OG_IMAGE_SECRET` firma las URL de las imágenes de Open Graph. Sin ella se
+genera un secreto por compilación, lo cual está bien con una instancia y mal con
+dos: una tarjeta cuya URL firmó una instancia es rechazada por la siguiente, así
+que un enlace compartido durante un despliegue progresivo pierde su imagen.
+Genérala una vez; no rota.
diff --git a/docs/es/2.concepts/8.localisation.md b/docs/es/2.concepts/8.localisation.md
new file mode 100644
index 00000000..570aef55
--- /dev/null
+++ b/docs/es/2.concepts/8.localisation.md
@@ -0,0 +1,100 @@
+---
+title: Localización
+description: La capa trae siete locales, y cada cadena configurada admite tres formas.
+icon: lucide:languages
+---
+
+Las cadenas del propio tema — etiquetas de navegación, búsqueda, el aviso de
+versión, el 404 — se entregan traducidas en `en-GB`, `en-US`, `de-DE`, `es-ES`,
+`fr-FR`, `pt-PT` y `pt-BR`. Un sitio elige cuáles sirve con `locales`, y
+`i18n.defaultLocale` decide cuál se sirve sin prefijo de URL.
+
+`locales` se lee en tiempo de compilación: los locales deciden rutas y
+`hreflang`, no solo lo que dibuja un componente, así que cambiar la lista exige
+recompilar.
+
+::callout{type="warning" title="Restringir la lista necesita esta clave, no la de i18n"}
+El array `locales` de i18n se combina entre capas en lugar de reemplazar, así
+que declarar `['de-DE']` allí no añade ni quita nada: los otros seis siguen
+enrutados, indexados y anunciados en `hreflang`. `duxt.locales` es la clave que
+realmente la reduce.
+::
+
+## Cada cadena configurada admite tres formas
+
+Todo lo que escribas en la configuración — una etiqueta, un título, una
+descripción — puede ser un literal, una clave i18n o un registro con una cadena
+por locale:
+
+```ts
+label: 'Repository' // literal
+label: 'app.nav.repository' // clave propia
+label: { 'en-GB': 'Repository', 'es-ES': 'Repositorio' } // por locale
+```
+
+Cuál de ellas es se decide según si la cadena es una clave registrada. Un sitio
+monolingüe nunca se topa con las otras dos formas, y un sitio con un puñado de
+cadenas en cinco idiomas no necesita archivos de locale en absoluto — la forma
+de registro se resuelve por el idioma base, así que `pt-PT` cubre `pt-BR` y
+`en-GB` cubre `en-US`.
+
+## No toques las claves de la capa
+
+`duxt.defaults.*` traduce los valores por defecto que la propia capa entrega.
+Esas claves son internas: renombrar una no es un cambio incompatible de la capa,
+e i18n responde a una clave inexistente imprimiendo la clave — quien dependa de
+una se enteraría por un lector, no por una compilación. Usa un literal, una
+clave propia o la forma de registro.
+
+## Rutas y enlaces
+
+Solo las rutas se localizan. `useDuxtPath()` da la ruta en la que está la
+**documentación**, sin el segmento de locale; `useDuxtLink()` la vuelve a
+escribir como enlace, añadiendo de nuevo el segmento. Una URL absoluta, un
+enlace de correo o un ancla suelta pasan intactos. Todo lo que calcule una ruta
+de documentación debería pasar por ese par en lugar de leer la ruta
+directamente.
+
+## Páginas traducidas
+
+La interfaz y las páginas son dos decisiones separadas. Un sitio que no diga
+nada sirve un mismo árbol en todos los locales — el entorno cambia de idioma, el
+Markdown no — y esa es la forma correcta para la mayoría de la documentación.
+
+`locales` en una fuente añade la segunda mitad:
+
+```ts [app/app.config.ts]
+sources: [{ path: 'docs', locales: ['en-GB', 'es'] }]
+```
+
+El locale por defecto es el árbol de `path` mismo, sin carpeta propia, así que
+añadir esta clave nunca mueve una página ya publicada. Cualquier otro idioma es
+una carpeta dentro — `docs/es/` — o algo completamente aparte; véase
+[Varios idiomas](/guides/several-languages).
+
+::callout{type="tip" title="El locale no forma parte de la ruta de contenido"}
+`/es-ES/guides/deploying` y `/guides/deploying` son la misma ruta de contenido en
+dos colecciones. El segmento pertenece al enrutado de i18n, nunca a una
+colección — por eso cambiar de idioma mantiene al lector en la misma página, y
+por eso una traducción ausente se resuelve con una consulta más y no con una
+redirección.
+::
+
+## Cuando una página no tiene traducción
+
+El lector recibe el idioma más cercano que sí la tiene, con un aviso que lo
+dice, y la página queda fuera del índice bajo `noindex` con un `canonical` que
+apunta al idioma en el que realmente está. La cadena es la que la capa ya aplica
+a las cadenas de texto:
+
+1. el propio locale — `es-ES` lee `es-ES`
+2. su idioma base — `es-ES` lee un árbol `es`, de modo que una carpeta `pt/`
+ puede servir tanto a `pt-PT` como a `pt-BR`
+3. un hermano del mismo idioma — `pt-BR` lee `pt-PT` antes de salirse del
+ portugués
+4. `fallbackLocale` de vue-i18n, el mismo valor por el que se resuelve una
+ cadena ausente
+5. el original sin traducir
+
+Un 404 queda deliberadamente fuera: un hueco en una traducción es de quien
+escribe, y responderlo escondiendo la página castiga al lector por ello.
diff --git a/docs/es/2.concepts/9.theming.md b/docs/es/2.concepts/9.theming.md
new file mode 100644
index 00000000..066ce76f
--- /dev/null
+++ b/docs/es/2.concepts/9.theming.md
@@ -0,0 +1,106 @@
+---
+title: Tematización
+description: Cómo está montado el tema — componentes propios, una capa de tokens y un modo oscuro que conmuta por clase.
+icon: lucide:palette
+---
+
+El tema no es una dependencia que configuras. Es un conjunto de archivos que la
+capa posee, construido sobre [shadcn-vue](https://www.shadcn-vue.com) y Tailwind 4,
+y la razón de esa forma decide lo que te cuesta sobrescribir algo.
+
+Cambiar algo es [Sobrescribir el tema](/guides/override-the-theme). Esta página
+explica por qué las costuras están donde están.
+
+## Los componentes se copian, no se importan
+
+shadcn-vue no es una biblioteca de componentes que instalas. Su CLI escribe el
+código fuente de un componente en tu proyecto, y desde ese momento el archivo es
+tuyo. La capa guarda sus primitivos en `app/components/ui/`, y `components.json`
+apunta el CLI a ese alias, así que añadir uno es una sola orden.
+
+Se siguen dos consecuencias, y van en direcciones opuestas:
+
+- **Puedes cambiar cualquier cosa, sin bifurcar.** Un componente que quieres que
+ se vea distinto es un archivo que editas — no hay un upstream contra el que
+ pelear, ni una API del tema en la que buscar un hueco.
+- **Nada se actualiza solo.** Una corrección de un primitivo te llega cuando
+ vuelves a ejecutar el CLI para él, no cuando una dependencia sube de versión. La
+ capa asume ese coste para sus propios primitivos; un componente que sobrescribes
+ en tu proyecto lo asumes tú.
+
+Ese canje es toda la razón del tercer nivel de la guía de sobrescritura: el cambio
+más pequeño que resuelve la tarea es el correcto, porque un componente que
+reemplazas es un componente que ahora mantienes.
+
+::callout{type="info" title="De dónde viene la decisión"}
+Por qué shadcn-vue en lugar de un tema de documentación ya hecho está registrado
+en [ADR 0008](/adr/0008-build-the-theme-on-owned-shadcn-vue-components).
+::
+
+## Una capa de tokens, dos hojas de estilo
+
+La paleta son propiedades personalizadas de CSS en `oklch`, definidas una vez en
+`:root` y redefinidas en `.dark`. Cada componente lee un token; ninguno guarda un
+color. Eso es lo que hace posible una sobrescritura de una línea — redefines
+`--primary` en tu propia hoja de estilos y todas las superficies que lo usan se
+mueven.
+
+Dos archivos lo sostienen, y no son intercambiables:
+
+| Archivo | Contiene |
+| :---------------------------- | :------------------------------------------------------------------------------- |
+| `app/assets/css/duxt.css` | La paleta, la entrada de Tailwind, el preajuste de la capa — el archivo que lees |
+| `app/assets/css/typeset.css` | El typeset de shadcn, copiado literalmente — el archivo que **no** editas |
+
+Detrás de `typeset.css` no hay entrada de registro, así que el CLI no puede
+obtenerlo ni actualizarlo. Actualizar significa descargar el archivo de nuevo, y
+por eso los cambios locales van en el preajuste de `duxt.css`: una edición hecha
+en `typeset.css` es una que la siguiente actualización descarta en silencio.
+
+## El modo oscuro es una clase, no una media query
+
+`@custom-variant dark (&:is(.dark *))` — el tema conmuta con una clase `dark`, y
+`@nuxtjs/color-mode` pone exactamente esa clase porque la capa lo configura con
+`classSuffix: ''`.
+
+Este es el detalle con el que tropieza quien sobrescribe un color. Un token
+definido solo bajo `@media (prefers-color-scheme: dark)` no se lee nunca, y un
+token definido una vez en `:root` se usa en ambos modos — lo que significa que es
+incorrecto en uno de ellos. Redefine en ambos bloques, o acepta que un modo
+conserve el valor de la capa.
+
+## La paleta se aparta del valor de shadcn, a propósito
+
+`--muted-foreground` es aquí más oscuro de lo que lo entrega el conjunto neutro de
+shadcn. Ese token lleva las descripciones de página, la tabla de contenidos y la
+miga de pan, y con el valor de shadcn mide por debajo del 4,5:1 que pide WCAG AA
+sobre las superficies en las que realmente aterriza.
+
+Ninguna comprobación basada en navegador lo detecta: una pasada de accesibilidad
+sobre las páginas construidas no tiene color calculado que medir. La capa mide por
+tanto los tokens mismos, en `tests/contrast.test.ts`, emparejando cada color de
+primer plano con los fondos sobre los que se usa de verdad. Si sobrescribes un
+token de primer plano, asumes esa garantía — la prueba lee el archivo de la capa,
+no el tuyo.
+
+## Hay que decirle a Tailwind dónde está la capa
+
+`@source '../../../app'` está cerca del principio de `duxt.css` y no es adorno.
+Tailwind busca nombres de clase desde la **raíz de compilación**, que es el
+directorio del proyecto consumidor. La capa vive fuera de él, en `node_modules`,
+así que sin esa línea faltaría en el CSS generado cada utilidad usada por un
+componente de la capa: un sitio que se representa sin estilos.
+
+La ruta es relativa a la hoja de estilos, de modo que vale allí donde el paquete
+esté instalado.
+
+## Dónde te deja esto
+
+- Un color, un radio, una tipografía: redefine el token en tu propia hoja de estilos.
+- Un diseño que no encaja en tu sitio: ensombrece el componente por su nombre.
+- Un primitivo que la capa no trae: añádelo con el CLI.
+
+Cada paso hacia abajo cuesta más mantenimiento que el anterior. La
+[guía de sobrescritura](/guides/override-the-theme) recorre los tres, y
+[Dale identidad a tu sitio](/guides/brand-your-site) cubre la parte que es
+identidad y no tema — el nombre, la marca y los iconos.
diff --git a/docs/es/2.concepts/index.md b/docs/es/2.concepts/index.md
new file mode 100644
index 00000000..b4e0ec4a
--- /dev/null
+++ b/docs/es/2.concepts/index.md
@@ -0,0 +1,12 @@
+---
+title: Conceptos
+description: Cómo funciona la capa — el modelo de fuentes, el esquema de URL, las colecciones y las comprobaciones.
+icon: lucide:compass
+---
+
+Las páginas de inicio te llevan de cero a un sitio en marcha. Estas explican qué
+son las piezas, dónde están las costuras y por qué cada una está construida como
+está.
+
+::page-cards
+::
diff --git a/docs/es/3.guides/1.several-repositories.md b/docs/es/3.guides/1.several-repositories.md
new file mode 100644
index 00000000..19519198
--- /dev/null
+++ b/docs/es/3.guides/1.several-repositories.md
@@ -0,0 +1,61 @@
+---
+title: Varios repositorios
+description: Servir la documentación de varios proyectos desde un solo sitio.
+icon: lucide:folder-git
+---
+
+Un sitio, muchos proyectos. Cada repositorio de origen no contiene más que
+Markdown — sin Nuxt, sin `package.json`, sin dependencias — que es la única
+disposición viable para un proyecto en Go, PHP o Rust.
+
+## Pasos
+
+::steps
+1. **Nombra cada repositorio como una fuente.** Un segmento de repositorio
+ aparece en cada URL en cuanto hay más de uno, así que dale a cada uno un
+ `slug` que estés dispuesto a conservar: forma parte de la URL.
+
+ ```ts [app/app.config.ts]
+ sources: [
+ { path: 'docs', slug: 'acme', origin: { repo: 'acme/site', ref: 'main' } },
+ { repo: 'acme/api', path: 'docs' },
+ { repo: 'acme/cli', path: 'docs' }
+ ]
+ ```
+
+2. **Usa `origin`, no `repo`, para este repositorio.** `repo` es lo que hace que
+ Content descargue una fuente, así que nombrar el tuyo ahí haría que la
+ compilación clonara el checkout en el que ya está. `origin` solo dice dónde
+ viven las páginas, para el enlace «Editar esta página».
+
+3. **Autentica los privados.** Content lee un token del `auth` de la colección;
+ nada de eso es específico de duxt.
+
+4. **Escribe tus propias `sections`.** La navegación que trae la capa da por
+ supuesta una única fuente sin prefijo. Con un segmento de repositorio en cada
+ ruta, sus entradas dejan de resolver: nombra las secciones tú mismo,
+ apuntando a `/acme/getting-started` en lugar de a `/getting-started`.
+
+5. **Compila una vez y lee los mensajes.** Una colección vacía hace fallar la
+ compilación, y es el fallo que este montaje produce más a menudo: un `path`
+ que no existe en el otro repositorio se ve exactamente igual que un
+ repositorio sin documentación.
+
+::
+
+## Lo que obtienes gratis
+
+La búsqueda abarca todas las fuentes y ordena los resultados en conjunto, con la
+fuente en la que está el lector primero — la versión que está leyendo, o su
+predeterminada, una por repositorio, para que una página nunca aparezca una vez
+por versión. `_partials/` de cualquier fuente alimenta una colección compartida,
+así que una nota de instalación se escribe una vez y se incluye en los tres
+proyectos.
+
+## Lista de comprobación
+
+- [ ] Cada fuente tiene un `slug` que te conviene tener en la URL
+- [ ] Este repositorio usa `origin`, no `repo`
+- [ ] Las `sections` apuntan a rutas que incluyen el segmento de repositorio
+- [ ] La compilación no informa de ninguna colección vacía
+- [ ] Buscar desde un repositorio encuentra una página de otro
diff --git a/docs/es/3.guides/2.several-versions.md b/docs/es/3.guides/2.several-versions.md
new file mode 100644
index 00000000..a5c5588e
--- /dev/null
+++ b/docs/es/3.guides/2.several-versions.md
@@ -0,0 +1,74 @@
+---
+title: Varias versiones
+description: Servir la misma documentación en varias refs, con selector y aviso.
+icon: lucide:git-compare
+---
+
+Una ref se convierte en una versión. Apunta una fuente a etiquetas y cada una se
+publica, con un segmento de versión en la URL y una entrada en el selector.
+
+## Pasos
+
+::steps
+1. **Enumera las refs.** Una cadena simple es una **rama**. Una etiqueta tiene
+ que declararlo — git mantiene ambas en espacios de nombres separados, y pedir
+ una etiqueta bajo `refs/heads` hace fallar la compilación.
+
+ ```ts [app/app.config.ts]
+ sources: [
+ {
+ repo: 'acme/api',
+ path: 'docs',
+ refs: [
+ { branch: 'main', status: 'upcoming' },
+ { tag: 'v2.0.0' },
+ { tag: 'v1.4.0' },
+ { tag: 'v0.9.0', status: 'eol' }
+ ]
+ }
+ ],
+ sourceOptions: { defaultRef: 'v2.0.0' }
+ ```
+
+2. **Nombra la predeterminada.** `defaultRef` es la ref que se sirve sin prefijo
+ de versión y a la que se dirige a los buscadores. Sin definir es la primera
+ de la lista — que rara vez es lo que quieres cuando la lista empieza por una
+ rama.
+
+3. **Declara el ciclo de vida donde no puede deducirse.** Semver ordena las
+ etiquetas, así que «más antigua que la predeterminada» no necesita ayuda. Lo
+ que no puede es situar una rama frente a una etiqueta: `main` es `upcoming`,
+ y sin decirlo se le pide a su lector que actualice a una versión más antigua
+ que la que está leyendo.
+
+4. **Usa `latest` si no quieres editar esta lista en cada publicación.** Se
+ resuelve al compilar a la etiqueta semver más reciente de ese repositorio.
+
+ ```ts
+ refs: [{ branch: 'main', status: 'upcoming' }, 'latest']
+ ```
+
+5. **Marca el requisito de versión de una página en el texto**, donde alguien
+ que mira una versión no puede ver las demás:
+
+ ```md
+ ### Reintentos :since{version="v2.0.0"}
+ ```
+
+::
+
+## Qué pasa con las versiones antiguas
+
+Una versión no predeterminada lleva `noindex` y un `canonical` que apunta a la
+misma página en la predeterminada, y una versión `eol` abandona el sitemap por
+completo. Eso es lo que impide que un buscador ofrezca `v0.9.0` cuando el lector
+quería la documentación de hoy — véase
+[URL y versiones](/concepts/urls-and-versions).
+
+## Lista de comprobación
+
+- [ ] Cada etiqueta está escrita como `{ tag: … }`, no como cadena simple
+- [ ] `defaultRef` nombra la versión en la que deben aterrizar los lectores
+- [ ] Una rama de la lista lleva un `status` explícito
+- [ ] El selector muestra todas las versiones, con distintivo en las muertas
+- [ ] La página de una versión muerta no está en `/sitemap.xml`
diff --git a/docs/es/3.guides/3.several-languages.md b/docs/es/3.guides/3.several-languages.md
new file mode 100644
index 00000000..9e99e4fe
--- /dev/null
+++ b/docs/es/3.guides/3.several-languages.md
@@ -0,0 +1,126 @@
+---
+title: Varios idiomas
+description: Servir la interfaz en varios idiomas, y traducir también las páginas.
+icon: lucide:languages
+---
+
+Las cadenas del tema están traducidas en siete locales. Un sitio sirve el
+subconjunto que quiere, traduce las cadenas que él mismo configuró y — si le
+apetece — también las páginas.
+
+## Pasos
+
+::steps
+1. **Reduce la lista con `duxt.locales`.**
+
+ ```ts [app/app.config.ts]
+ locales: ['en-GB', 'es-ES']
+ ```
+
+ No el array `locales` de i18n: ese se combina entre capas en lugar de
+ reemplazar, así que declarar dos allí deja las otras cinco enrutadas y
+ anunciadas en `hreflang`.
+
+2. **Elige la que va sin prefijo** en `nuxt.config.ts` con
+ `i18n.defaultLocale`. Es relevante para la URL: cambiarla después mueve todas
+ las páginas en ambos sentidos a la vez.
+
+3. **Traduce tus propias cadenas.** Para un puñado, la forma de registro cuesta
+ menos que un archivo de locale por idioma:
+
+ ```ts
+ sections: [
+ {
+ label: { 'en-GB': 'Guides', 'es-ES': 'Guías' },
+ to: '/guides',
+ icon: 'lucide:book-open'
+ }
+ ]
+ ```
+
+ Para más de un puñado, registra tus propias claves i18n y escribe la clave. No
+ recurras a `duxt.defaults.*` — son las claves internas de la capa, y un
+ renombrado allí rompería tu sitio en silencio.
+
+4. **Define el origen.** `hreflang` no vale en relativo, así que necesita
+ `i18n.baseUrl` o `NUXT_PUBLIC_I18N_BASE_URL`.
+
+5. **Traduce las páginas, si quieres.** Una carpeta por idioma dentro de la
+ fuente; el locale por defecto se queda donde está y no mueve ninguna URL:
+
+ ```ts [app/app.config.ts]
+ sources: [{ path: 'docs', locales: ['en-GB', 'es'] }]
+ ```
+
+ ::file-tree{title="docs/"}
+ ---
+ tree:
+ - name: index.md
+ - name: 1.getting-started/
+ children:
+ - name: index.md
+ - name: es/
+ children:
+ - name: index.md
+ ---
+ ::
+
+ Traduce tan pocas páginas como quieras — todo lo demás recae en el original,
+ con un aviso que le dice al lector por qué.
+
+6. **Abre una página en cada locale** y comprueba que la interfaz cambió de
+ idioma, que la ruta sigue resolviendo y que ninguna etiqueta configurada
+ aparece como clave en crudo.
+::
+
+## Solo algunas versiones, u otro repositorio
+
+`locales` en una ref sobrescribe el de la fuente, que es la forma habitual: la
+versión actual está traducida y las dos anteriores no.
+
+```ts
+{
+ path: 'docs',
+ locales: ['en-GB', 'es'],
+ refs: [
+ { tag: 'v2.0.0' }, // ambos idiomas
+ { tag: 'v1.0.0', locales: ['en-GB'] } // solo el original
+ ]
+}
+```
+
+La forma de objeto mueve un idioma a otro sitio por completo — su propio
+repositorio, con sus propios responsables y su propio ritmo:
+
+```ts
+locales: ['en-GB', { locale: 'es', repo: 'acme/docs-es', path: 'docs' }]
+```
+
+Eso es lo que hacen React y Vue fuera de sus herramientas, con `de.react.dev` y
+la organización `vuejs-translations`, porque quienes traducen trabajan con su
+propio calendario y su propia revisión. Aquí es una entrada de fuente en lugar
+de un segundo sitio web — un índice de búsqueda, un selector de idioma.
+
+::callout{type="warning" title="Lo que cuesta compilar una traducción"}
+Aproximadamente **dos segundos de compilación y 0,6 MB de base de datos por
+idioma × versión × repositorio**, medido lineal hasta al menos 200 colecciones.
+Traducir todas las versiones de todos los repositorios multiplica las tres;
+traducir solo la versión actual es lo que hace la mayoría de los proyectos.
+::
+
+## Cuando una página no tiene traducción
+
+El lector recibe el idioma más cercano que la tenga, con un aviso, y la página
+lleva `noindex` más un canonical que apunta al idioma en el que realmente está.
+La cadena — locale, idioma base, región hermana, `fallbackLocale`, original —
+está en [Localización](/concepts/localisation).
+
+## Lista de comprobación
+
+- [ ] `duxt.locales` enumera exactamente los locales que sirves
+- [ ] `/sitemap.xml` contiene una entrada por página y locale, y ninguna más
+- [ ] Los enlaces `hreflang` son absolutos
+- [ ] Ninguna etiqueta configurada aparece como clave en crudo
+- [ ] Cambiar de locale mantiene al lector en la misma página
+- [ ] Una página que no tradujiste muestra el aviso de recurso, no un 404
+- [ ] `duxt.sourceOptions.defaultLocale` coincide con `i18n.defaultLocale`
diff --git a/docs/es/3.guides/4.override-the-theme.md b/docs/es/3.guides/4.override-the-theme.md
new file mode 100644
index 00000000..a54b0b6e
--- /dev/null
+++ b/docs/es/3.guides/4.override-the-theme.md
@@ -0,0 +1,72 @@
+---
+title: Sobrescribir el tema
+description: Cambiar un color, reemplazar un componente, añadir una primitiva de shadcn-vue.
+icon: lucide:palette
+---
+
+La capa entrega el tema como archivos que puedes ensombrecer. Tres niveles, del
+menor al mayor — y el menor que resuelva el problema es el correcto, porque un
+componente que reemplazas es un componente que ahora mantienes.
+
+## Pasos
+
+:::steps
+1. **Un color: redefine el token.** La paleta es el conjunto neutro de shadcn
+ como variables CSS. Redefine una en tu propia hoja de estilos; no hace falta
+ bifurcar el archivo de la capa.
+
+ ```css [app/assets/css/main.css]
+ :root {
+ --primary: oklch(0.55 0.2 265);
+ }
+ .dark {
+ --primary: oklch(0.7 0.18 265);
+ }
+ ```
+
+ Importan los dos bloques: el tema conmuta con una **clase** `dark`, no con
+ una media query, así que un token definido una sola vez está mal en uno de
+ los dos modos.
+
+ ::callout{type="warning" title="Comprueba el contraste que acabas de cambiar"}
+ `--muted-foreground` lleva las descripciones de página, la tabla de
+ contenidos y la miga de pan. El valor de la capa es deliberadamente más
+ oscuro que el de shadcn por defecto, que mide por debajo de 4,5:1 sobre las
+ superficies en las que este token aterriza de verdad.
+ ::
+
+2. **Una pieza del diseño: crea el archivo.** Nuxt resuelve primero tu
+ componente del mismo nombre, así que reemplazar `DuxtFooter` es crear
+ `app/components/DuxtFooter.vue` en tu proyecto. Los nombres, props y slots
+ están en la [referencia de componentes](/reference/components) — un nombre
+ documentado forma parte de la superficie pública, así que no se renombrará
+ sin una versión mayor.
+
+3. **Un slot, donde lo haya.** `DuxtPageFeedback` emite `feedback` y expone su
+ estado a través de un slot, de modo que puedes colgarle tu propio formulario
+ sin reemplazar el componente — desarrollado en
+ [Recoger opiniones de página](/guides/collect-page-feedback).
+
+4. **Una primitiva nueva: usa la CLI.** La capa posee sus componentes de
+ shadcn-vue en lugar de importarlos, y `components.json` ya apunta la CLI al
+ alias correcto:
+
+ ```bash
+ pnpm dlx shadcn-vue@latest add tooltip
+ ```
+
+5. **Un componente para Markdown.** Todo lo que esté en
+ `app/components/content/` se puede invocar desde una página con la sintaxis
+ de bloque MDC. Tu propio archivo del mismo nombre reemplaza el de la capa —
+ véase la [referencia MDC](/reference/mdc-components).
+
+:::
+
+## Lista de comprobación
+
+- [ ] No se copió nada de la capa que un token pudiera haber cambiado
+- [ ] Cada token redefinido tiene valor para claro y para oscuro
+- [ ] Los componentes reemplazados se llaman exactamente como los nombra la
+ referencia
+- [ ] `pnpm build` sigue representando todas las páginas (un override es donde
+ se rompe el SSR)
diff --git a/docs/es/3.guides/5.move-a-page.md b/docs/es/3.guides/5.move-a-page.md
new file mode 100644
index 00000000..8859d2be
--- /dev/null
+++ b/docs/es/3.guides/5.move-a-page.md
@@ -0,0 +1,56 @@
+---
+title: Mover una página
+description: redirectFrom en el frontmatter, convertido en reglas de ruta bajo todos los prefijos.
+icon: lucide:signpost
+---
+
+Una página movida es el único enlace roto que un sitio de documentación se crea
+a sí mismo. Pon la URL antigua en el frontmatter de la página nueva y la
+redirección se publica en el mismo commit que el movimiento.
+
+## Pasos
+
+::steps
+1. **Mueve el archivo** y renómbralo como pretendías.
+
+2. **Enumera las rutas antiguas en la propia página**, sin ningún prefijo:
+
+ ```md [docs/3.guides/6.deploy-a-site.md]
+ ---
+ title: Desplegar un sitio
+ description: Dónde puede ejecutarse un sitio duxt.
+ redirectFrom:
+ - /getting-started/deploy
+ - /deployment
+ ---
+ ```
+
+3. **Recompila.** La compilación convierte cada entrada en reglas de ruta — bajo
+ todos los prefijos de repositorio, versión y locale en los que se sirve la
+ página.
+
+4. **Comprueba una de ellas**, prefijo incluido, en el sitio compilado.
+
+::
+
+## Por qué el frontmatter es el sitio correcto
+
+El arreglo siempre vivió fuera del sitio: una regla de nginx, un archivo
+`_redirects` de Netlify, una lista de Cloudflare — escritos por quien despliega
+el sitio y nunca por quien movió la página. Y solo la capa puede resolverlo una
+vez, porque solo el manifiesto resuelto sabe qué prefijos existen; quien
+escribiera las reglas a mano tendría que redescubrir todo el esquema en cada
+página.
+
+::callout{type="tip" title="Reordenar nunca necesita una redirección"}
+El prefijo numérico se elimina de la URL, así que renombrar `3.guides/` a
+`4.guides/` no mueve nada. Solo la parte posterior al punto es la URL.
+::
+
+## Lista de comprobación
+
+- [ ] Todas las rutas antiguas están listadas, escritas sin prefijo
+- [ ] La compilación no informa de ningún enlace interno roto
+- [ ] Una URL antigua también redirige bajo el prefijo de versión, no solo
+ desnuda
+- [ ] No se ha añadido nada a la configuración del servidor web
diff --git a/docs/es/3.guides/6.deploy-a-site.md b/docs/es/3.guides/6.deploy-a-site.md
new file mode 100644
index 00000000..70203aac
--- /dev/null
+++ b/docs/es/3.guides/6.deploy-a-site.md
@@ -0,0 +1,86 @@
+---
+title: Desplegar un sitio
+description: Dónde puede ejecutarse un sitio duxt, qué necesita allí y por qué el contenido no se refresca en ejecución.
+icon: lucide:cloud-upload
+---
+
+Un sitio duxt es una aplicación Nuxt. Cualquier cosa que ejecute Nuxt lo
+ejecuta.
+
+## Pasos
+
+:::steps
+1. **Prefiere estático.** `nuxt generate` produce una carpeta de HTML — sin
+ servidor, sin coste de ejecución, cada página cacheada en el borde. Es la
+ opción correcta por defecto para documentación.
+
+ ::package-managers{command="run generate"}
+ ::
+
+ `nuxt build` produce un servidor Node, que solo necesitas si una página debe
+ reaccionar a una petición.
+
+2. **Dale a la compilación lo que necesita.** Node 24 (la base de datos se lee
+ mediante `node:sqlite`, así que no se instala ningún controlador nativo),
+ acceso de red a cualquier fuente remota y un token para las privadas.
+
+3. **Define el origen.** `NUXT_PUBLIC_I18N_BASE_URL`, o `i18n.baseUrl` en la
+ configuración. Sin él, `hreflang`, `canonical`, el sitemap y las tarjetas
+ sociales recaen en salida relativa.
+
+4. **Define un secreto de imagen OG si despliegas más de una instancia.**
+ `NUXT_OG_IMAGE_SECRET`, generado una vez. Sin él cada compilación firma las
+ URL de imagen con un secreto propio, y una tarjeta firmada por una instancia
+ es rechazada por la siguiente.
+
+5. **Programa una recompilación.** Un sitio que lee otro repositorio recoge sus
+ cambios cuando compila, no cuando se publican.
+
+:::
+
+## Por qué no hay refresco en ejecución
+
+Content descarga un repositorio remoto al compilar y lo compila en la base de
+datos que la compilación entrega. Refrescar eso en ejecución significaría
+reconstruir la base de datos dentro de un servidor vivo — Content no ofrece
+ninguna vía soportada para ello, y la única disponible es la misma base de datos
+de cliente que lee la búsqueda. Una recompilación programada, o disparada desde
+el repositorio de origen, mantiene el despliegue inmutable y el contenido
+reproducible.
+
+## Cuando una compilación dice `database is locked`
+
+Content guarda su caché de análisis en `.data/content/contents.sqlite`, y una
+compilación que el kernel o un `Ctrl-C` dejó atrás puede seguir reteniéndola.
+Cada compilación posterior falla entonces con `database is locked` — un mensaje
+que no nombra ni el archivo ni el proceso.
+
+duxt pone esa base de datos en [modo WAL](https://www.sqlite.org/wal.html) antes
+de que Content la abra, lo que permite que un lector y un escritor convivan y
+elimina por completo la versión cotidiana de esto. Lo que no puede eliminar es
+un segundo proceso todavía en marcha:
+
+```bash
+pgrep -af 'nuxt (build|dev|generate)' # el proceso huérfano, si lo hay
+kill
+```
+
+Si no aparece nada y el error persiste, borra `.data/content/`: es una caché, y
+la siguiente compilación la reconstruye desde las fuentes.
+
+## Qué sirve el sitio además de páginas
+
+`/llms.txt`, `/llms-full.txt`, el código `.md` de cualquier página, `/mcp`,
+`/sitemap.xml`, `/robots.txt` y — una vez configurado — `/rss.xml`. La lista
+completa con sus salvedades está en la
+[referencia de endpoints](/reference/endpoints).
+
+## Lista de comprobación
+
+- [ ] `nuxt generate` termina sin error del validador
+- [ ] El origen está definido en el despliegue, no solo en local
+- [ ] `/sitemap.xml` lista URL absolutas en el dominio real
+- [ ] Hay una recompilación programada, o disparada desde cada repositorio de
+ origen
+- [ ] `NUXT_OG_IMAGE_SECRET` está definido si más de una instancia sirve el
+ sitio
diff --git a/docs/es/3.guides/7.collect-page-feedback.md b/docs/es/3.guides/7.collect-page-feedback.md
new file mode 100644
index 00000000..9cc60cc8
--- /dev/null
+++ b/docs/es/3.guides/7.collect-page-feedback.md
@@ -0,0 +1,120 @@
+---
+title: Recoger opiniones de página
+description: Envuelve DuxtPageFeedback y guarda la respuesta donde ya vive tu analítica.
+icon: lucide:thumbs-up
+---
+
+Cada página de documentación termina con «¿Te ha resultado útil esta página?», y
+la capa no hace nada con la respuesta: la mantiene en el estado del propio
+componente mientras dura la visita, emite un evento y no almacena nada. Envolver
+el componente es la forma de que la respuesta te llegue.
+
+## Pasos
+
+::steps
+
+1. **Envuelve el componente de la capa con su propio nombre.** Nuxt resuelve tu
+ archivo primero, así que `app/components/DuxtPageFeedback.vue` en tu proyecto
+ reemplaza el de la capa — y el alias `@duxt` sigue alcanzando el original por
+ ruta, de modo que la envoltura conserva el marcado, las traducciones y el
+ reinicio al navegar de la capa:
+
+ ```vue [app/components/DuxtPageFeedback.vue]
+
+
+
+
+
+ ```
+
+2. **Dale a la respuesta un sitio donde aterrizar.** Una ruta propia, para que
+ el navegador del lector nunca hable con un tercero que no elegiste:
+
+ ```ts [server/api/feedback.post.ts]
+ export default defineEventHandler(async (event) => {
+ const { path, helpful } = await readBody(event);
+
+ // Tu almacenamiento, tu analítica, tu cola.
+ await recordFeedback({ path, helpful });
+
+ return { ok: true };
+ });
+ ```
+
+ Una llamada de analítica en el cliente funciona igual — sustituye el `$fetch`
+ de la envoltura por lo que exponga el SDK de tu proveedor.
+
+3. **Recompila y responde la pregunta una vez**, luego comprueba que la
+ respuesta llegó con la ruta que esperabas — bajo un prefijo de versión, no
+ solo en la URL desnuda.
+
+::
+
+## Representar tu propio control
+
+El evento conserva los botones de la capa. Para reemplazarlos, usa en su lugar
+el slot por defecto: expone el mismo estado desde el que se representa el
+componente, así que el reinicio al navegar y el estado de «gracias» siguen
+siendo tarea de la capa.
+
+```vue [app/components/DuxtPageFeedback.vue]
+
+
+
+
+
+ Gracias.
+
+
+```
+
+`answer(helpful)` fija el estado y emite el evento, así que un slot y un
+escuchador se combinan: cuelga tu formulario del slot y conserva el manejador
+`@feedback` del paso 1 para entregar lo que haya recogido.
+
+::callout{type="warning" title="Dos respuestas no son dos lectores"}
+Nada en el componente identifica a un visitante, y así se queda a propósito. Si
+cuentas respuestas por página, cuéntalas como respuestas — un navegador que
+recarga y responde otra vez es una segunda respuesta, no un segundo lector, y
+convertir lo uno en lo otro es una cuestión de consentimiento que tu política de
+privacidad tiene que responder antes.
+::
+
+## Por qué la capa no almacena nada
+
+Un tema de documentación que llamara a casa por defecto tendría que elegir un
+destino para los lectores de otra persona, y no hay ninguno defendible: la capa
+no sabe ni dónde vive tu analítica ni qué promete tu política de privacidad.
+Entregar la pregunta con un evento y sin backend mantiene la decisión donde
+corresponde — la misma razón por la que la fila legal del pie y los enlaces al
+repositorio se entregan vacíos
+([ADR 0005](/adr/0005-ship-the-layer-without-owner-specific-links)).
+
+## Lista de comprobación
+
+- [ ] La envoltura representa el componente de la capa en vez de reimplementarlo
+- [ ] La respuesta se envía con la ruta en la que se dio, prefijos incluidos
+- [ ] El destino es tuyo — una ruta, una cola o un SDK que elegiste
+- [ ] Nada en la carga útil identifica al lector
diff --git a/docs/es/3.guides/8.brand-your-site.md b/docs/es/3.guides/8.brand-your-site.md
new file mode 100644
index 00000000..722dfdd5
--- /dev/null
+++ b/docs/es/3.guides/8.brand-your-site.md
@@ -0,0 +1,91 @@
+---
+title: Dale identidad a tu sitio
+description: Define el nombre, el logotipo y los iconos, para que el sitio se lea como tuyo y no como el de la capa.
+icon: lucide:badge-check
+---
+
+La capa se entrega sin identidad a propósito: sin nombre, sin marca, sin iconos.
+Un sitio que la extendiera y mostrara la identidad de duxt estaría anunciando el
+proyecto de otro, así que cada una de estas cosas la defines tú — y hasta
+entonces, la cabecera recae en un icono genérico de libro.
+
+## Pasos
+
+:::steps
+
+1. **El nombre.** `title` es la palabra en la barra de navegación, en la plantilla
+ del título SEO, en el 404, en el canal RSS y como encabezado de `llms.txt`. Un
+ valor, siete lectores.
+
+ ```ts [app/app.config.ts]
+ export default defineAppConfig({
+ duxt: {
+ title: 'Acme SDK'
+ }
+ });
+ ```
+
+ Igual en todos los idiomas, así que una cadena simple es lo correcto. Donde no
+ lo sea, la forma de registro funciona aquí como en cualquier otro sitio — mira
+ [Varios idiomas](/guides/several-languages).
+
+2. **El logotipo, si tienes uno.** `logo.src` sustituye el par de icono y nombre
+ por una imagen. `srcDark` se intercambia por CSS bajo la clase `dark`, no por
+ script — una elección por script representa la marca clara en el HTML del
+ servidor y la cambia tras la hidratación, lo que se ve como un parpadeo.
+
+ ```ts [app/app.config.ts]
+ logo: {
+ src: '/wordmark.svg',
+ srcDark: '/wordmark-dark.svg',
+ alt: 'Acme SDK'
+ }
+ ```
+
+ `alt` recae en `title`, así que defínelo solo donde la marca diga algo que el
+ nombre no dice.
+
+ ::callout{type="info" title="La altura es fija, el ancho la sigue"}
+ Cada logotipo tiene su propia proporción, por eso el componente fija la altura
+ y deja que el ancho la siga. Una marca pensada para una caja cuadrada se verá
+ famélica; una mucho más ancha que alta no se aplastará.
+ ::
+
+3. **Los iconos del navegador.** Esto no es configuración de duxt, sino entradas
+ normales de head de Nuxt — porque un favicon es de un sitio, nunca de un tema.
+
+ ```ts [nuxt.config.ts]
+ app: {
+ head: {
+ link: [
+ { rel: 'icon', type: 'image/svg+xml', href: '/favicon.svg' },
+ { rel: 'apple-touch-icon', href: '/apple-touch-icon.png' }
+ ]
+ }
+ }
+ ```
+
+ Un icono SVG puede llevar su propia regla `prefers-color-scheme`, así que un
+ archivo cubre una franja de pestañas clara y una oscura. El PNG (180×180)
+ existe solo porque iOS ignora los iconos SVG.
+
+4. **La línea de atribución, si la quieres fuera.** El pie lleva una línea
+ "Powered by duxt" con la versión de la capa. `poweredBy: false` la quita — una
+ capa a la que no se le puede decir que deje de nombrarse a sí misma es adware.
+
+:::
+
+## Lo que sigue siendo de la capa
+
+Colores, espaciado y componentes son otra cuestión, y otra página:
+[Sobrescribir el tema](/guides/override-the-theme). Esta página cubre la
+identidad: el nombre y la marca. Merece la pena mantenerlas separadas, porque un
+color de marca suele resultar ser un token y no una bifurcación del tema.
+
+## Lista de comprobación
+
+- [ ] `title` está definido y nada en la cabecera sigue leyéndose como "Documentation"
+- [ ] `logo.srcDark` está definido, o la marca es legible sobre ambos fondos
+- [ ] El favicon y el apple-touch-icon resuelven — ambos se sirven desde `public/`
+- [ ] `llms.txt` y `rss.xml` nombran tu sitio, no un marcador de posición
+- [ ] `poweredBy` es una elección deliberada y no un valor por defecto que nadie miró
diff --git a/docs/es/3.guides/index.md b/docs/es/3.guides/index.md
new file mode 100644
index 00000000..6ec77608
--- /dev/null
+++ b/docs/es/3.guides/index.md
@@ -0,0 +1,12 @@
+---
+title: Guías
+description: Una tarea por página, de principio a fin — varios repositorios, varias versiones, varios idiomas y un despliegue.
+icon: lucide:book-open
+---
+
+Cada página de aquí completa una tarea y termina con una lista de comprobación.
+El modelo que hay detrás está en [Conceptos](/concepts); cada clave que definen
+está en la [Referencia](/reference).
+
+::page-cards
+::
diff --git a/docs/es/4.reference/1.configuration.md b/docs/es/4.reference/1.configuration.md
new file mode 100644
index 00000000..7fc484f0
--- /dev/null
+++ b/docs/es/4.reference/1.configuration.md
@@ -0,0 +1,199 @@
+---
+title: Configuración
+description: Cada clave bajo duxt en app.config.ts — qué controla y cuándo se lee.
+icon: lucide:settings
+---
+
+Todo lo de abajo vive bajo `duxt` en tu `app/app.config.ts`. Cómo funciona la
+combinación, y por qué los arrays reemplazan en lugar de añadirse, está en
+[Configuración](/getting-started/configuration).
+
+## Sitio
+
+| Clave | Por defecto | Qué controla |
+| :----------------- | :------------------- | :------------------------------------------------------- |
+| `title` | **ninguno** | El nombre en la barra de navegación y en el título SEO |
+| `logo` | **ninguno** | Un logotipo en lugar del par de icono y nombre — ver abajo |
+| `version` | **ninguna** | El distintivo junto a él |
+| `organization` | **ninguna** | Quién publica el sitio, para schema.org — ver abajo |
+| `locales` | los siete | Qué idiomas de la capa sirve este sitio |
+| `breadcrumb` | `true` | `false` quita el rastro sobre el título de la página |
+| `pageIcon` | **ninguno** | Icono para páginas sin uno propio — el de la sección gana |
+| `packageManagers` | pnpm, npm, yarn, bun | Qué gestores ofrece un bloque de comando, en ese orden |
+| `poweredBy` | activado | `false` quita la línea «Powered by duxt» del pie |
+
+### `logo`
+
+| Campo | Tipo | Notas |
+| :-------- | :------- | :------------------------------------------------------------ |
+| `src` | `string` | La marca. Sin valor queda el icono genérico junto a `title` |
+| `srcDark` | `string` | Se intercambia por CSS bajo la clase `dark`, no por script |
+| `alt` | texto | Recae en `title` |
+
+Definir uno es [Dale identidad a tu sitio](/guides/brand-your-site).
+
+### `organization`
+
+El nodo `Organization` que los buscadores y los resúmenes de IA leen para nombrar
+una fuente. Ausente hasta que lo definas: duxt renderiza la documentación de
+otros y no adivina de quién.
+
+| Campo | Tipo | Notas |
+| :----- | :------- | :---------------------------------------------------------------- |
+| `name` | texto | El nombre de quien publica. Sin él no se publica nada |
+| `url` | `string` | Su sitio. Recurre a `site.url` |
+| `logo` | `string` | Absoluto, o una ruta desde la raíz; cuadrado y de 112px o más |
+
+
+## Navegación y enlaces
+
+Una entrada de sección es un enlace más un campo propio:
+
+| Campo | Tipo | Notas |
+| :--------- | :------- | :------------------------------------------------------------------ |
+| `pageIcon` | `string` | Icono para las páginas de esta sección que no definen uno. Prevalece sobre `duxt.pageIcon`; el frontmatter de una página sigue ganando |
+
+Útil donde las páginas no pueden llevar icono — el frontmatter de un ADR está
+fijado en `title`, `description`, `status` y `date`, así que el registro de
+decisiones se representaría sin ninguno.
+
+| Clave | Por defecto | Qué controla |
+| :----------- | :-------------- | :------------------------------------------------------------- |
+| `navigation` | una entrada | Enlaces de la barra; una entrada con `children` es un desplegable |
+| `sections` | **vacío** | La segunda fila de la barra — las partes de primer nivel |
+| `links` | **vacío** | Enlaces de icono a la derecha de la barra |
+| `aside` | solo el título | `title` y `links` bajo la tabla de contenidos |
+| `footer` | **vacío** | `copyright` y `legal` |
+| `landing` | una acción | La página en `/` |
+
+La mayoría se entregan vacíos, por dos razones distintas. `links`,
+`aside.links`, `footer.legal`, `title`, `version` y el texto de la página de
+inicio nombran un proyecto concreto — un repositorio, una comunidad, un aviso
+legal, un nombre, una publicación, una frase sobre lo que hace — y pertenecen a
+quien gestiona el sitio. `sections` nombra las
+partes de un **árbol** de documentación, y el árbol es tuyo: cuatro pestañas
+apuntando a nombres de carpeta que la capa hubiera adivinado no llevarían a
+ninguna parte. Defínelo cuando tu documentación tenga secciones; hasta entonces
+la fila no se representa y la barra lateral muestra el árbol completo, que es la
+forma correcta para una documentación sin secciones.
+
+### La página de inicio
+
+La página en `/` dibuja tres bandas, y cada una está ausente hasta que se define
+su clave.
+
+| Clave | Por defecto | Qué dibuja |
+| :------------ | :-------------------------- | :--------------------------------------------------- |
+| `badge` | **ninguno** | La píldora sobre el titular — texto, o un objeto badge |
+| `headline` | **ninguno** | El `h1`; recae en `title` |
+| `description` | **ninguna** | El párrafo bajo él, y la meta descripción de la página |
+| `actions` | una, «Lee la documentación» | Los botones del bloque principal; una acción sin `to` resuelve a la primera sección |
+| `command` | **ninguno** | Un comando de instalación copiable bajo los botones |
+| `preview` | **ninguno** | Una página de este sitio, incrustada en una ventana de navegador |
+| `features` | **vacío** | La rejilla de tarjetas |
+
+`command` es una `string` simple, no texto: un comando de shell es igual en
+todos los idiomas, y uno traducido por error es uno que no se ejecuta. La capa
+no entrega ninguno — no sabe cómo se llama tu proyecto, por la misma razón por
+la que `links` está vacío.
+
+No hay llamada a la acción final, deliberadamente. Los botones del bloque
+principal son la llamada; repetirlos bajo una rejilla de tarjetas exige a un lector recién
+llegado decidir dos veces.
+
+`badge` se entrega vacío por la misma razón que `links`: una píldora sobre el
+titular dice algo del estado de un proyecto — «beta», «v2 ya está» — y la capa
+no sabe nada del estado del tuyo. Una cadena es la forma corta; el objeto añade
+icono, color y destino.
+
+| Campo de `badge` | Tipo | Notas |
+| :--------------- | :-------- | :--------------------------------------------------------- |
+| `label` | texto | Obligatorio. `{version}` se sustituye por la `version` del sitio |
+| `icon` | `string` | Cualquier nombre de Iconify |
+| `variant` | `string` | `default`, `secondary`, `outline`, `success`, `destructive` |
+| `to` | `string` | Convierte toda la píldora en un enlace |
+| `external` | `boolean` | La pestaña nueva |
+
+```ts [app/app.config.ts]
+badge: {
+ label: '{version} publicada',
+ icon: 'lucide:rocket',
+ variant: 'success',
+ to: 'https://github.com/acme/sdk/releases/latest',
+ external: true
+}
+```
+
+`preview` es una ventana viva, no una imagen. Incrusta una página de **este**
+sitio en un marco de navegador que el lector puede desplazar, navegar y cuyo
+tema puede cambiar sin salir de la página de inicio. El marco se monta solo
+cuando la banda entra en el viewport, y nunca durante el renderizado en
+servidor — un iframe en el HTML inicial es una segunda carga completa
+compitiendo con la primera.
+
+| Campo de `preview` | Tipo | Notas |
+| :----------------- | :------- | :--------------------------------------------------------- |
+| `to` | `string` | La página a incrustar; por defecto, la primera sección |
+| `height` | `string` | Cualquier longitud CSS. Por defecto `32rem`, `24rem` bajo `sm` |
+| `src` | `string` | Una captura EN LUGAR de la página viva |
+| `srcDark` | `string` | Sirve el modo oscuro; sin él, `src` sirve ambos |
+| `alt` | texto | El texto alternativo y el nombre accesible del marco |
+
+::callout{type="tip" title="Una captura es la opción más barata"}
+El marco vivo carga la aplicación una segunda vez. Un sitio que prefiera no
+pagar eso define `src` y obtiene la misma ventana alrededor de una imagen fija.
+::
+
+### Forma de un enlace
+
+| Campo | Tipo | Notas |
+| :------------ | :------------------ | :------------------------------------------------ |
+| `label` | texto | Obligatorio |
+| `to` | `string` | Una ruta de documentación se localiza; una URL no |
+| `icon` | `string` | Cualquier nombre de Iconify, p. ej. `lucide:github` |
+| `description` | texto | Se muestra en un desplegable de la barra |
+| `external` | `boolean` | La pestaña nueva y la flecha — nunca el enrutado |
+| `children` | `DuxtLink[]` | Convierte una entrada de barra en un desplegable |
+| `variant` | variante de botón | Solo `landing.actions` |
+
+Todo campo marcado como _texto_ admite un literal, una clave i18n o un registro
+por locale — véase [Localización](/concepts/localisation).
+
+## Fuentes
+
+| Clave | Se lee en | Qué controla |
+| :-------------- | :----------------- | :------------------------------------------ |
+| `sources` | tiempo de compilación | Las fuentes de documentación |
+| `sourceOptions` | tiempo de compilación | Cómo esas fuentes se convierten en prefijos |
+| `versions` | ejecución | Sobrescribe las versiones derivadas cuando necesitan etiquetas o descripciones |
+
+Ambas claves de fuente tienen [su propia página](/reference/sources). `locales`
+es la tercera clave de compilación: cambiar cualquiera de las tres exige
+recompilar.
+
+## El feed
+
+`/rss.xml` está vacío hasta que `feed.path` nombra una sección:
+
+```ts
+feed: { path: '/changelog', title: 'mi proyecto — publicaciones' }
+```
+
+Desactivado por defecto a propósito. Un feed es una lista de cosas que **han
+pasado**, y una página de referencia editada no es un acontecimiento — un sitio
+que publica cada edición como elemento enseña a sus lectores a darse de baja.
+Los elementos se ordenan por el `date` propio de la página, recurriendo al
+último commit que la tocó, y solo la versión predeterminada de cada fuente
+contribuye, de modo que un changelog versionado no repite cada entrada una vez
+por versión.
+
+## Generado, no escrito
+
+| Clave | Escrito por | Contiene |
+| :----------------- | :--------------- | :----------------------------------------------- |
+| `resolvedSources` | la compilación | El manifiesto: qué colección sirve qué prefijo |
+| `layerVersion` | la compilación | La versión de duxt, para el pie |
+| `layerRepository` | la compilación | El repositorio de duxt, para el pie |
+
+Definir cualquiera de los tres a mano se sobrescribe en la siguiente
+compilación.
diff --git a/docs/es/4.reference/2.sources.md b/docs/es/4.reference/2.sources.md
new file mode 100644
index 00000000..8650255f
--- /dev/null
+++ b/docs/es/4.reference/2.sources.md
@@ -0,0 +1,125 @@
+---
+title: Fuentes
+description: Los campos de una entrada de fuente, sus refs, y las opciones que dan forma a las URL.
+icon: lucide:git-branch
+---
+
+`duxt.sources` es una lista de entradas de fuente; `duxt.sourceOptions` dice
+cómo se convierten en URL. Ambas se leen en tiempo de compilación. Qué generan y
+por qué está en [Fuentes](/concepts/sources).
+
+## Una entrada de fuente
+
+| Campo | Tipo | Notas |
+| :-------- | :----------------------- | :---------------------------------------------------------------- |
+| `path` | `string` | Carpeta con el Markdown, relativa a la raíz del repositorio |
+| `repo` | `string` | `owner/name` o una URL de git. **Omitido significa este repositorio** |
+| `refs` | lista de refs | Refs a publicar como versiones. Omitido significa el checkout actual |
+| `label` | texto | Mostrado en el selector y usado en la URL; por defecto, la ref |
+| `slug` | `string` | El segmento de URL del repositorio; por defecto, su nombre |
+| `status` | estado | Ciclo de vida de cada versión que publica esta entrada |
+| `origin` | `{ repo, ref? }` | Dónde viven las páginas leídas del disco, para enlazar de vuelta |
+| `history` | `boolean` | Leer el historial git de esta fuente |
+| `locales` | lista de locales | Idiomas en los que está disponible esta fuente — véase abajo |
+
+Dos campos son fáciles de confundir y caros de equivocar:
+
+- **`repo` descarga, `origin` solo enlaza.** Nombrar tu propio repositorio en
+ `repo` hace que la compilación clone el checkout en el que ya está.
+- **`history` cuesta un clon completo.** Content clona un repositorio remoto con
+ `--depth 1`, así que su checkout contiene un commit y cada archivo parece
+ escrito por quien cortó la punta — datos erróneos, no datos ausentes.
+ Activarlo completa el clon una vez. Una fuente leída del disco ya es un
+ checkout completo y se lee de todos modos.
+
+## Refs
+
+Una cadena sola es una **rama**. Una etiqueta tiene que decirlo: git mantiene
+ramas y etiquetas en espacios de nombres separados, y pedir una etiqueta bajo
+`refs/heads` hace fallar la compilación con `Could not find refs/heads/…`.
+
+```ts
+refs: [
+ 'main', // una rama
+ { branch: 'next', status: 'upcoming' }, // la misma, con un ciclo de vida
+ { tag: 'v2.0.0', label: 'v2' }, // una etiqueta
+ 'latest' // la etiqueta semver más nueva, resuelta al compilar
+]
+```
+
+`'latest'` está reservado. Una rama que de verdad se llame `latest` necesita la
+forma `{ branch }`.
+
+## Locales
+
+Omitido, la fuente tiene un idioma y una colección. Listado, cada entrada se
+convierte en una colección propia.
+
+```ts
+locales: [
+ 'en-GB', // el predeterminado: docs/ mismo
+ 'de', // docs/de/
+ { locale: 'fr', path: 'translations/fr' }, // en otro sitio de este repositorio
+ { locale: 'es', repo: 'acme/docs-es', path: 'docs' } // otro repositorio
+]
+```
+
+Una cadena es una carpeta dentro del `path` de la fuente. La forma de objeto
+sobrescribe `path`, `repo` y `ref` solo para ese idioma — que es lo que permite
+que una traducción viva en un repositorio propio, con sus propios responsables y
+su propio calendario de publicación.
+
+**El locale por defecto es el árbol de `path` mismo y no ocupa carpeta.** Cuál
+es viene de `sourceOptions.defaultLocale`, y tiene que coincidir con
+`i18n.defaultLocale` — la compilación falla si difieren, porque
+`content.config.ts` resuelve las colecciones sin acceso a la configuración de
+Nuxt.
+
+Una ref puede llevar sus propios `locales`, sobrescribiendo los de la fuente
+igual que hace `status`. Esa es la forma habitual: la versión actual está
+traducida, las anteriores no.
+
+```ts
+refs: [
+ { tag: 'v2.0.0' }, // hereda los locales de la fuente
+ { tag: 'v1.0.0', locales: ['en-GB'] } // solo el original
+]
+```
+
+Nombra una carpeta por **idioma** donde la región no aporte nada: `docs/pt/`
+sirve tanto a `pt-PT` como a `pt-BR`, porque una página recae en su idioma base.
+La misma regla que siguen los archivos de locale de la propia capa.
+
+## Estado
+
+| Valor | Significa |
+| :----------- | :--------------------------------------------------- |
+| `upcoming` | Aún no publicada — todavía puede cambiar |
+| `current` | La documentación que hay que leer |
+| `maintained` | Más antigua, aún con soporte |
+| `deprecated` | Más antigua, y el lector debería actualizar |
+| `eol` | Muerta — se avisa, y fuera del sitemap |
+
+Lo que cada uno le cuesta a una página está en
+[URL y versiones](/concepts/urls-and-versions).
+
+## Opciones de fuente
+
+| Campo | Por defecto | Qué hace |
+| :-------------- | :----------------- | :--------------------------------------------------------- |
+| `showRepo` | desactivado | Fuerza un segmento de repositorio con un solo repositorio |
+| `showVersion` | desactivado | Fuerza un segmento de versión con una sola versión |
+| `defaultRef` | la primera ref | La ref servida sin prefijo de versión |
+| `defaultLocale` | el primer locale | El locale servido desde `path` mismo, sin carpeta |
+
+## El manifiesto resuelto
+
+La compilación convierte la lista anterior en `duxt.resolvedSources`, y eso es
+lo que lee el tema. `useDuxtCollection()` lo expone; cada entrada lleva el
+nombre de la colección, el `prefix` de URL, `repo`, `version`, `isDefault`,
+`locale`, `isDefaultLocale`, `status`, el repositorio y la ref de los que
+vinieron las páginas, y si se leyó su `history`.
+
+El locale está deliberadamente ausente de `prefix`: i18n ya lo antepone a la
+ruta, así que todos los idiomas de una fuente comparten prefijo y solo se
+diferencian por la colección.
diff --git a/docs/es/4.reference/3.frontmatter.md b/docs/es/4.reference/3.frontmatter.md
new file mode 100644
index 00000000..7c605d7e
--- /dev/null
+++ b/docs/es/4.reference/3.frontmatter.md
@@ -0,0 +1,54 @@
+---
+title: Frontmatter
+description: Los campos que una página puede llevar más allá del título y la descripción, y los dos que rellena la compilación.
+icon: lucide:file-code
+---
+
+Content solo almacena lo que declara el esquema de una colección, así que esta
+es la lista completa: un campo no declarado se descarta en silencio, antes de
+que la barra lateral o la búsqueda lleguen a verlo.
+
+## Lo que escribes tú
+
+| Campo | Tipo | Qué hace |
+| :------------- | :--------- | :------------------------------------------------------------------- |
+| `title` | `string` | Se representa como el `` de la página, y su título SEO y social |
+| `description` | `string` | El resumen — barra lateral, tarjetas, imagen OG, `llms.txt` |
+| `icon` | `string` | Se muestra junto a la entrada en la barra lateral, la fila y las fichas |
+| `navigation` | `boolean` | `false` oculta la página de la navegación |
+| `layout` | `string` | `landing` representa la página sin el diseño de documentación |
+| `redirectFrom` | `string[]` | URL antiguas de esta página — véase [Mover una página](/guides/move-a-page) |
+| `date` | `string` | Cuándo está fechada esta página, para el feed |
+
+`title` y `description` son los dos por cuya ausencia avisa la compilación.
+Ninguno rompe una página, y ambos degradan en silencio la tabla de contenidos,
+la tarjeta social y `llms.txt`.
+
+::callout{type="tip" title="Sin H1 en el cuerpo"}
+`title` se representa como el encabezado. Un encabezado `#` al principio del
+archivo es el segundo `` de la página.
+::
+
+## Lo que rellena la compilación
+
+| Campo | Rellenado por | Contiene |
+| :------------- | :------------------------ | :---------------------------------------------- |
+| `lastUpdated` | el módulo git-meta | El último commit que tocó el archivo |
+| `contributors` | el módulo git-meta | Nombre, número de commits y usuario, por autor |
+| `rawbody` | el esquema de la colección | El Markdown tal como se escribió |
+| `sitemap` | la integración de sitemap | Lo que mete la página en `/sitemap.xml` |
+
+Escribir cualquiera de ellos a mano no sirve de nada: la compilación los
+sobrescribe. Los dos campos de git necesitan un historial real, así que una
+fuente remota necesita `history: true` — véase la
+[referencia de fuentes](/reference/sources).
+
+## El nombre del archivo lleva dos cosas más
+
+- **Un prefijo numérico ordena el árbol** y se elimina de la URL:
+ `3.guides/2.deploy.md` se sirve en `/guides/deploy`. Reordenar nunca mueve una
+ URL.
+- **`.draft.md` es un borrador.** Se sirve en el servidor de desarrollo y no
+ está en la compilación. No hay `draft: true`, porque el contenido de una
+ colección se declara antes de haber leído archivo alguno — véase
+ [Colecciones](/concepts/collections).
diff --git a/docs/es/4.reference/4.mdc-components/1.callout.md b/docs/es/4.reference/4.mdc-components/1.callout.md
new file mode 100644
index 00000000..707df650
--- /dev/null
+++ b/docs/es/4.reference/4.mdc-components/1.callout.md
@@ -0,0 +1,72 @@
+---
+title: Callout
+description: Un apunte en el que el lector debe detenerse — un consejo, una advertencia, un peligro.
+icon: lucide:message-square-warning
+---
+
+Un apunte al margen en una de cuatro variantes. El cuerpo es Markdown, así que
+dentro funcionan enlaces, `code` y listas.
+
+## Uso
+
+::preview
+:::callout{type="tip" title="No hace falta nada más"}
+El cuerpo también es Markdown — dentro funcionan enlaces, `code` y listas.
+:::
+
+#code
+```mdc
+::callout{type="tip" title="No hace falta nada más"}
+El cuerpo también es Markdown — dentro funcionan enlaces, `code` y listas.
+::
+```
+::
+
+## Tipos
+
+::preview
+:::callout{type="info"}
+`info` — el predeterminado, para el contexto que la página da por sabido.
+:::
+
+:::callout{type="tip"}
+`tip` — algo que facilita la tarea.
+:::
+
+:::callout{type="warning"}
+`warning` — una forma de equivocarse con esto.
+:::
+
+:::callout{type="danger"}
+`danger` — pérdida de datos, o un cambio que no se puede deshacer.
+:::
+
+#code
+```mdc
+::callout{type="info"}
+`info` — el predeterminado, para el contexto que la página da por sabido.
+::
+```
+::
+
+## API
+
+### Props
+
+| Prop | Tipo | Por defecto | Notas |
+| :------ | :------------------------------------ | :---------- | :----------------------------------------------- |
+| `type` | `info` · `tip` · `warning` · `danger` | `info` | Elige el color, el icono y el rol |
+| `title` | `string` | — | Primera línea en negrita; después sigue el cuerpo |
+| `icon` | `string` | según tipo | Cualquier nombre de Iconify, sobrescribiendo el predeterminado |
+
+### Slots
+
+| Slot | Qué va dentro |
+| :-------- | :----------------------- |
+| `default` | El cuerpo, como Markdown |
+
+## Notas
+
+Un callout `danger` lleva `role="alert"`; los demás no. Una alerta interrumpe a
+un lector de pantalla a mitad de frase, lo cual es correcto para «esto borra tus
+datos» y erróneo para un consejo.
diff --git a/docs/es/4.reference/4.mdc-components/10.package-managers.md b/docs/es/4.reference/4.mdc-components/10.package-managers.md
new file mode 100644
index 00000000..054c1df9
--- /dev/null
+++ b/docs/es/4.reference/4.mdc-components/10.package-managers.md
@@ -0,0 +1,44 @@
+---
+title: Gestores de paquetes
+description: Un comando, una pestaña por gestor de paquetes, recordado entre páginas.
+icon: lucide:package
+---
+
+Un comando mostrado para cada gestor de paquetes que ofrece el sitio, con la
+elección del lector recordada para todo el sitio.
+
+## Uso
+
+Escribe el comando **sin** el gestor: el `install` de npm y la grafía de `dlx`
+de cada gestor se sustituyen solas.
+
+::preview
+:::package-managers{command="add -D @kirchdev/duxt"}
+:::
+
+#code
+```mdc
+::package-managers{command="add -D @kirchdev/duxt"}
+::
+```
+::
+
+## API
+
+### Props
+
+| Prop | Tipo | Por defecto | Notas |
+| :--------- | :--------- | :--------------------- | :--------------------------------------------- |
+| `command` | `string` | obligatorio | Sin el gestor — `add -D pkg` |
+| `managers` | `string[]` | `duxt.packageManagers` | Sobrescribe la lista del sitio para un bloque |
+
+## Notas
+
+La elección es una cookie, no almacenamiento del navegador, y a propósito: el
+almacenamiento se lee después de la hidratación, así que el bloque
+representaría un gestor y luego cambiaría a otro delante del lector. Es propia,
+no identifica a nadie y lleva un valor que eligió el lector — véase
+[`usePackageManager()`](/reference/composables/reading/use-package-manager).
+
+Un bloque puede listar menos gestores de los que abarca la elección del lector;
+recae en su propia primera pestaña en lugar de no mostrar nada.
diff --git a/docs/es/4.reference/4.mdc-components/11.file-tree.md b/docs/es/4.reference/4.mdc-components/11.file-tree.md
new file mode 100644
index 00000000..8dc1d6dd
--- /dev/null
+++ b/docs/es/4.reference/4.mdc-components/11.file-tree.md
@@ -0,0 +1,60 @@
+---
+title: Árbol de archivos
+description: Una estructura de carpetas, con iconos por tipo de archivo.
+icon: lucide:folder-tree
+---
+
+Un listado de directorio como árbol, con el icono de cada tipo de archivo leído
+de su extensión.
+
+## Uso
+
+::preview
+:::file-tree{title="tu-proyecto/"}
+---
+tree:
+ - name: docs/
+ children:
+ - name: index.md
+ - name: 1.getting-started/
+ children:
+ - name: index.md
+ - name: 1.installation.md
+ - name: app/
+ children:
+ - name: app.config.ts
+ - name: nuxt.config.ts
+---
+:::
+
+#code
+```mdc
+::file-tree{title="tu-proyecto/"}
+---
+tree:
+ - name: docs/
+ children:
+ - name: index.md
+ - name: nuxt.config.ts
+---
+::
+```
+::
+
+## API
+
+### Props
+
+| Prop | Tipo | Por defecto | Notas |
+| :------ | :-------- | :---------- | :--------------------------------------- |
+| `tree` | `Entry[]` | — | Se da como YAML en el cuerpo del bloque |
+| `title` | `string` | — | La etiqueta raíz encima del árbol |
+
+Una entrada es `{ name, children? }`. Un nombre que termina en `/`, o que lleva
+hijos, es un directorio.
+
+## Notas
+
+Construido sobre el árbol de reka-ui, así que tiene foco itinerante, navegación
+con las flechas y los roles ARIA. Los nodos se indexan por ruta y no por nombre:
+los nombres se repiten entre ramas, las rutas no.
diff --git a/docs/es/4.reference/4.mdc-components/12.preview.md b/docs/es/4.reference/4.mdc-components/12.preview.md
new file mode 100644
index 00000000..84145157
--- /dev/null
+++ b/docs/es/4.reference/4.mdc-components/12.preview.md
@@ -0,0 +1,65 @@
+---
+title: Vista previa
+description: Un componente representado tal como lo representa la página, con su código al lado.
+icon: lucide:eye
+---
+
+El ejemplo es el componente real en el tema real — los mismos tokens, el mismo
+modo oscuro — así que el lector ve lo que hace un bloque en vez de una
+descripción de él. Esta página, y todas las demás de esta carpeta, están
+construidas con él.
+
+## Uso
+
+::preview
+:::callout{type="tip"}
+Representado por la página que estás leyendo.
+:::
+
+#code
+```mdc
+::callout{type="tip"}
+Representado por la página que estás leyendo.
+::
+```
+::
+
+````mdc
+::preview
+:::callout{type="tip"}
+Representado por la página que estás leyendo.
+:::
+
+#code
+```mdc
+::callout{type="tip"}
+Representado por la página que estás leyendo.
+::
+```
+::
+````
+
+## API
+
+Sin props.
+
+### Slots
+
+| Slot | Qué va dentro |
+| :-------- | :------------------------------------------------------------- |
+| `default` | El ejemplo, representado |
+| `code` | El código, como bloque cercado. Omítelo y no aparece la tira de pestañas |
+
+## Notas
+
+El código de `#code` se escribe a mano en vez de derivarse del ejemplo que hay
+encima, y ese es el único coste real del bloque. Para cuando el componente se
+ejecuta, sus hijos son nodos ya representados y el Markdown que los produjo ya
+no está; volver a serializarlos sería adivinar una sintaxis de la que nunca
+vinieron.
+
+::callout{type="warning" title="Dos copias divergen"}
+Nada comprueba que la pestaña del código coincida con lo que se representa a su
+lado. Mantén el ejemplo lo bastante corto como para que cambiar uno sin el otro
+salte a la vista.
+::
diff --git a/docs/es/4.reference/4.mdc-components/13.page-cards.md b/docs/es/4.reference/4.mdc-components/13.page-cards.md
new file mode 100644
index 00000000..aeedea5c
--- /dev/null
+++ b/docs/es/4.reference/4.mdc-components/13.page-cards.md
@@ -0,0 +1,31 @@
+---
+title: Tarjetas de página
+description: Los hijos de una sección, como tarjetas.
+icon: lucide:layout-grid
+---
+
+Los hijos de una sección, dibujados como tarjetas con su icono, título y
+descripción — con lo que termina cada `index.md` de esta documentación.
+
+## Uso
+
+```mdc
+::page-cards
+::
+```
+
+Sin ninguna prop lista los hijos de la página actual, y por eso aquí se muestra
+por su nombre en lugar de renderizado: esta página no tiene.
+
+## API
+
+### Props
+
+| Prop | Tipo | Por defecto | Notas |
+| :----- | :------- | :--------------- | :------------------------------- |
+| `path` | `string` | la página actual | Lista los hijos de otra sección |
+
+## Notas
+
+El título, la descripción y el icono vienen del frontmatter de cada hijo, así
+que una tarjeta no puede desviarse de la página a la que apunta.
diff --git a/docs/es/4.reference/4.mdc-components/14.partial.md b/docs/es/4.reference/4.mdc-components/14.partial.md
new file mode 100644
index 00000000..be667858
--- /dev/null
+++ b/docs/es/4.reference/4.mdc-components/14.partial.md
@@ -0,0 +1,35 @@
+---
+title: Partial
+description: Un bloque escrito una vez y renderizado en cada fuente que lo necesita.
+icon: lucide:puzzle
+---
+
+En línea, y el único componente que sale de la fuente de la propia página:
+`_partials/` en **cualquier** fuente alimenta una única colección compartida.
+
+## Uso
+
+```mdc
+:partial{name="install"}
+```
+
+## API
+
+### Props
+
+| Prop | Tipo | Por defecto | Notas |
+| :----- | :------- | :---------- | :------------------------------------- |
+| `name` | `string` | — | El archivo en `_partials/`, sin `.md` |
+
+## Notas
+
+Content no trae ninguna directiva de inclusión, y entre repositorios eso es una
+carencia sin solución: una nota de instalación que tiene que leerse igual en
+tres proyectos se copia en tres proyectos y se desvía. Un bloque definido en un
+repositorio se puede invocar desde las páginas de otro.
+
+Traducido como una página. Una fuente que declara `locales` obtiene una
+colección de fragmentos por idioma — `docs/de/_partials/install.md` es el
+`install` alemán — y el componente recurre exactamente igual que la página que
+lo rodea, terminando en el original sin traducir. Escribe solo los idiomas que
+tengas; el resto se resuelve hacia arriba.
diff --git a/docs/es/4.reference/4.mdc-components/15.since.md b/docs/es/4.reference/4.mdc-components/15.since.md
new file mode 100644
index 00000000..5d0a3cd5
--- /dev/null
+++ b/docs/es/4.reference/4.mdc-components/15.since.md
@@ -0,0 +1,34 @@
+---
+title: Since
+description: La versión en la que algo llegó, junto a un encabezado.
+icon: lucide:milestone
+---
+
+En línea. Marca la versión en la que llegó algo — usable dentro de un
+encabezado.
+
+## Uso
+
+### Reintentos :since{version="v2.0.0"}
+
+```mdc
+### Reintentos :since{version="v2.0.0"}
+```
+
+## API
+
+### Props
+
+| Prop | Tipo | Por defecto | Notas |
+| :-------- | :------- | :---------- | :--------------------- |
+| `version` | `string` | — | Se imprime tal cual |
+
+## Notas
+
+Cuelga del modelo de versiones en vez de repetirlo: lo que dice el badge es la
+versión en la que algo LLEGÓ, que es un hecho sobre la API y no sobre la página
+en la que se documenta — así que sigue siendo correcto en cada versión de la
+documentación que lleve la frase.
+
+Para cualquier otra cosa, `:badge[…]` llama directamente al Badge del propio
+tema; MDC resuelve por su nombre cualquier componente registrado globalmente.
diff --git a/docs/es/4.reference/4.mdc-components/16.mermaid.md b/docs/es/4.reference/4.mdc-components/16.mermaid.md
new file mode 100644
index 00000000..4bd3e39e
--- /dev/null
+++ b/docs/es/4.reference/4.mdc-components/16.mermaid.md
@@ -0,0 +1,46 @@
+---
+title: Mermaid
+description: Un diagrama a partir de un bloque de código o de una prop.
+icon: lucide:workflow
+---
+
+Un bloque ```` ```mermaid ```` es un diagrama; `::mermaid` con una prop `code`
+es el mismo componente.
+
+## Uso
+
+```mermaid
+flowchart LR
+ A[app.config.ts] --> B[resolveSources]
+ B --> C[collections]
+ B --> D[resolvedSources]
+ C --> E[Content]
+ D --> F[theme]
+```
+
+`````md
+```mermaid
+flowchart LR
+ A[app.config.ts] --> B[resolveSources]
+ B --> C[collections]
+```
+`````
+
+## API
+
+### Props
+
+| Prop | Tipo | Por defecto | Notas |
+| :----- | :------- | :---------- | :-------------------------------------------------------- |
+| `code` | `string` | — | El código fuente del diagrama, cuando no se usa un bloque |
+
+## Notas
+
+Se carga bajo demanda y solo en el navegador: mermaid pesa aproximadamente medio
+megabyte y arrastra su propio parser, y un sitio en el que tres páginas de
+sesenta llevan un diagrama no debe hacer que las otras cincuenta y siete lo
+paguen.
+
+Eso también significa que el diagrama se renderiza en el cliente — la salida del
+servidor es el texto fuente, que es el recurso honesto para un lector sin
+JavaScript.
diff --git a/docs/es/4.reference/4.mdc-components/17.prose/1.prose-a.md b/docs/es/4.reference/4.mdc-components/17.prose/1.prose-a.md
new file mode 100644
index 00000000..ddada009
--- /dev/null
+++ b/docs/es/4.reference/4.mdc-components/17.prose/1.prose-a.md
@@ -0,0 +1,36 @@
+---
+title: ProseA
+description: Un enlace, resuelto contra la fuente en la que se escribió.
+icon: lucide:link
+---
+
+Todo enlace de una página Markdown pasa por este componente.
+
+## Qué añade
+
+Una ruta de documentación se reescribe para que lleve el prefijo de la fuente de
+la que vino la página, más el locale del lector. Una URL externa se deja
+intacta y recibe la pestaña nueva y la flecha.
+
+## Por qué
+
+Markdown no tiene ni idea de que se está sirviendo bajo un prefijo.
+`/getting-started`, escrito en el `docs/` de este repositorio, es correcto en un
+sitio con una fuente y un 404 en un sitio con dos, donde la misma página vive en
+`/duxt/getting-started` — y quien escribió el Markdown, que puede ser otro
+repositorio entero, no puede saber cuál de los dos.
+
+Peor aún: el mismo archivo se sirve en varios prefijos a la vez cuando se
+publica en más de una versión. Un enlace no se puede reescribir en tiempo de
+compilación, porque no hay una única respuesta correcta. Se resuelve en cada
+render, contra la fuente desde la que se está leyendo la página — así, un enlace
+dentro de `v0.7.0` mantiene al lector en `v0.7.0`.
+
+## API
+
+### Props
+
+| Prop | Tipo | Notas |
+| :------- | :-------- | :--------------------------------------- |
+| `href` | `string` | Tal como se escribió en el Markdown |
+| `target` | `string` | Se establece para un enlace externo |
diff --git a/docs/es/4.reference/4.mdc-components/17.prose/2.prose-headings.md b/docs/es/4.reference/4.mdc-components/17.prose/2.prose-headings.md
new file mode 100644
index 00000000..54a74e88
--- /dev/null
+++ b/docs/es/4.reference/4.mdc-components/17.prose/2.prose-headings.md
@@ -0,0 +1,34 @@
+---
+title: ProseH2, ProseH3, ProseH4
+description: Encabezados, cada uno con un ancla de enlace permanente.
+icon: lucide:heading
+---
+
+Tres componentes, un mismo comportamiento: `ProseH2`, `ProseH3` y `ProseH4`
+renderizan los encabezados con los que se construye el índice de una página.
+
+## Qué añaden
+
+Un ancla de enlace permanente junto al encabezado, visible al pasar el ratón y
+alcanzable con el teclado. El `id` es el slug del propio Content, así que el
+ancla, el índice y la navegación dentro de la página coinciden.
+
+## API
+
+### Props
+
+| Prop | Tipo | Notas |
+| :--- | :------- | :---------------------------------------- |
+| `id` | `string` | El slug que Content generó para el texto |
+
+### Slots
+
+| Slot | Qué va dentro |
+| :-------- | :---------------------- |
+| `default` | El texto del encabezado |
+
+## Notas
+
+`ProseH1` no se sobrescribe, y es deliberado: el título de la página lo renderiza
+la página, no el Markdown, así que un encabezado `#` de Markdown dentro del
+cuerpo sería un segundo `h1` en la página.
diff --git a/docs/es/4.reference/4.mdc-components/17.prose/3.prose-img.md b/docs/es/4.reference/4.mdc-components/17.prose/3.prose-img.md
new file mode 100644
index 00000000..3458b1fb
--- /dev/null
+++ b/docs/es/4.reference/4.mdc-components/17.prose/3.prose-img.md
@@ -0,0 +1,41 @@
+---
+title: ProseImg
+description: "Una imagen: consciente del tema y ampliable."
+icon: lucide:image
+---
+
+Toda imagen de una página Markdown.
+
+## Qué añade
+
+**Dos variantes.** Una captura de un editor oscuro sobre una página blanca — o
+al revés — es lo más feo que se ve habitualmente en una documentación, y el CSS
+no puede arreglarlo porque lo que está mal son los píxeles, no el marco. Una
+página puede nombrar un segundo archivo:
+
+```md
+{dark="/shots/sidebar-dark.png"}
+```
+
+**Clic para ampliar**, salvo que la imagen pida lo contrario:
+
+```md
+{zoom="false"}
+```
+
+## API
+
+### Props
+
+| Prop | Tipo | Por defecto | Notas |
+| :----- | :-------- | :---------- | :--------------------------------------------- |
+| `src` | `string` | — | Tal como se escribió |
+| `alt` | `string` | — | Tal como se escribió |
+| `dark` | `string` | — | Un segundo archivo, servido con el tema oscuro |
+| `zoom` | `boolean` | `true` | `false` deja la imagen inerte |
+
+## Notas
+
+La variante oscura se nombra explícitamente en lugar de adivinarse por
+convención. Derivar `-dark` de un nombre de archivo hace que un archivo que falta
+se convierta en una imagen rota, y quien la escribió no se entera nunca.
diff --git a/docs/es/4.reference/4.mdc-components/17.prose/4.prose-pre.md b/docs/es/4.reference/4.mdc-components/17.prose/4.prose-pre.md
new file mode 100644
index 00000000..4b6d14f7
--- /dev/null
+++ b/docs/es/4.reference/4.mdc-components/17.prose/4.prose-pre.md
@@ -0,0 +1,46 @@
+---
+title: ProsePre
+description: "El bloque de código: nombre de archivo, icono de lenguaje, botón de copiar."
+icon: lucide:terminal
+---
+
+Todo bloque de código delimitado del sitio, y el componente que convierte un
+bloque mermaid en un diagrama.
+
+## Qué añade
+
+La tarjeta alrededor del código — el nombre del archivo, un icono para su
+lenguaje y un botón de copiar — más números de línea opcionales.
+
+## Números de línea
+
+Se activan por bloque, escribiéndolos en su meta:
+
+`````md
+```ts line-numbers
+```
+`````
+
+No están activos por defecto: la mayoría de los fragmentos de una documentación
+tienen cuatro líneas, y una columna de números al lado es ruido. Los números los
+dibuja un contador CSS, así que quien copia el bloque se lleva el código y no la
+numeración.
+
+## Anotaciones de Shiki
+
+Dos funcionan en el propio bloque, más allá de las cuatro que trae Content:
+
+| Escrito | Hace |
+| :--------------------- | :-------------------------- |
+| ```` ```ts {2,4-6} ```` | Resalta esas líneas |
+| ```` ```ts /useDuxtConfig/ ```` | Resalta esa palabra |
+
+## API
+
+### Props
+
+| Prop | Tipo | Notas |
+| :--------- | :------- | :---------------------------------------- |
+| `code` | `string` | El código fuente en bruto, para el botón de copiar |
+| `language` | `string` | Del bloque |
+| `filename` | `string` | Del `[…]` del bloque |
diff --git a/docs/es/4.reference/4.mdc-components/17.prose/5.prose-table.md b/docs/es/4.reference/4.mdc-components/17.prose/5.prose-table.md
new file mode 100644
index 00000000..19e6a2fd
--- /dev/null
+++ b/docs/es/4.reference/4.mdc-components/17.prose/5.prose-table.md
@@ -0,0 +1,21 @@
+---
+title: ProseTable
+description: Una tabla que se desplaza en lugar de la página.
+icon: lucide:table
+---
+
+Toda tabla de Markdown.
+
+## Qué añade
+
+Un contenedor de desplazamiento alrededor de la tabla, de modo que una tabla
+ancha se desplaza dentro de su propia caja en vez de hacer que la página entera
+se desplace de lado.
+
+## API
+
+### Slots
+
+| Slot | Qué va dentro |
+| :-------- | :------------ |
+| `default` | La tabla |
diff --git a/docs/es/4.reference/4.mdc-components/17.prose/index.md b/docs/es/4.reference/4.mdc-components/17.prose/index.md
new file mode 100644
index 00000000..9bcc5e1a
--- /dev/null
+++ b/docs/es/4.reference/4.mdc-components/17.prose/index.md
@@ -0,0 +1,16 @@
+---
+title: Sobrescrituras de prosa
+description: Lo que la capa cambia en el Markdown plano — enlaces, encabezados, imágenes, código y tablas.
+icon: lucide:type
+---
+
+Estos componentes nunca se invocan por su nombre. Content representa el Markdown
+plano a través de ellos, así que sustituir uno cambia el aspecto que tienen un
+párrafo, un enlace o un bloque de código corrientes en todas las páginas.
+
+Se listan aquí por la misma razón que los bloques que tienen al lado: un nombre
+documentado forma parte de la superficie pública de la capa, y tu propio archivo
+con ese mismo nombre sustituye al nuestro.
+
+::page-cards
+::
diff --git a/docs/es/4.reference/4.mdc-components/2.steps.md b/docs/es/4.reference/4.mdc-components/2.steps.md
new file mode 100644
index 00000000..bb6d25b7
--- /dev/null
+++ b/docs/es/4.reference/4.mdc-components/2.steps.md
@@ -0,0 +1,47 @@
+---
+title: Pasos
+description: Una lista ordenada dibujada como pasos numerados sobre un raíl.
+icon: lucide:list-ordered
+---
+
+Envuelve una **lista ordenada** en una disposición por pasos. Numera la lista
+que envuelva, así que el Markdown sigue siendo una lista corriente.
+
+## Uso
+
+::preview
+:::steps
+1. Añade la dependencia.
+2. Extiende la capa.
+3. Escribe Markdown.
+:::
+
+#code
+```mdc
+::steps
+1. Añade la dependencia.
+2. Extiende la capa.
+3. Escribe Markdown.
+::
+```
+::
+
+## API
+
+Sin props. El bloque da estilo a su `` hijo.
+
+### Slots
+
+| Slot | Qué va dentro |
+| :-------- | :------------------------------------- |
+| `default` | Una lista ordenada, elementos Markdown |
+
+## Notas
+
+Merece la pena recurrir a él en cuanto un paso lleva un bloque de código: en una
+lista ordenada corriente el bloque aleja el texto de su propio número, y esta
+disposición mantiene el número fijado a su lado.
+
+Los marcadores son un contador CSS sobre los elementos de la lista, no contenido
+— un lector que copie los pasos se lleva el texto sin la numeración dibujada dos
+veces.
diff --git a/docs/es/4.reference/4.mdc-components/3.code-group.md b/docs/es/4.reference/4.mdc-components/3.code-group.md
new file mode 100644
index 00000000..24565681
--- /dev/null
+++ b/docs/es/4.reference/4.mdc-components/3.code-group.md
@@ -0,0 +1,66 @@
+---
+title: Grupo de código
+description: Varios bloques de código como un único bloque con pestañas, etiquetados por nombre de archivo.
+icon: lucide:file-code
+---
+
+Varios bloques de código mostrados como un solo bloque, con una pestaña cada
+uno. La etiqueta de cada pestaña es el propio nombre de archivo del bloque,
+porque es entre eso entre lo que elige el lector y ya está escrito.
+
+## Uso
+
+::preview
+:::code-group
+```ts [nuxt.config.ts]
+export default defineNuxtConfig({
+ extends: ['@kirchdev/duxt']
+});
+```
+
+```json [package.json]
+{
+ "devDependencies": {
+ "@kirchdev/duxt": "latest"
+ }
+}
+```
+:::
+
+#code
+`````mdc
+::code-group
+```ts [nuxt.config.ts]
+export default defineNuxtConfig({
+ extends: ['@kirchdev/duxt']
+});
+```
+
+```json [package.json]
+{
+ "devDependencies": { "@kirchdev/duxt": "latest" }
+}
+```
+::
+`````
+::
+
+## API
+
+Sin props. Un bloque sin nombre de archivo recibe su lenguaje como etiqueta.
+
+### Slots
+
+| Slot | Qué va dentro |
+| :-------- | :----------------------------- |
+| `default` | Una serie de bloques de código |
+
+## Notas
+
+Los hijos se leen de los VNodes del slot por defecto en lugar de registrarse
+desde un componente hijo: un bloque de código es un `ProsePre`, no algo a lo que
+este bloque pueda pedirle que se anuncie, y su nombre de archivo es una prop de
+ese nodo.
+
+Para cualquier cosa que no sea código, usa
+[Grupo de pestañas](/reference/mdc-components/tab-group).
diff --git a/docs/es/4.reference/4.mdc-components/4.tab-group.md b/docs/es/4.reference/4.mdc-components/4.tab-group.md
new file mode 100644
index 00000000..a9123b58
--- /dev/null
+++ b/docs/es/4.reference/4.mdc-components/4.tab-group.md
@@ -0,0 +1,58 @@
+---
+title: Grupo de pestañas
+description: Pestañas para todo lo que no es código.
+icon: lucide:panels-top-left
+---
+
+`::tab-group` alrededor de bloques [`::tab`](/reference/mdc-components/tab) — un
+panel por pestaña, navegable con el teclado.
+
+## Uso
+
+::preview
+::::tab-group
+:::tab{label="macOS"}
+Todo lo que la capa necesita viene con Node.
+:::
+
+:::tab{label="Windows"}
+Lo mismo, y nada de la compilación depende de la plataforma.
+:::
+::::
+
+#code
+```mdc
+:::tab-group
+ ::tab{label="macOS"}
+ Todo lo que la capa necesita viene con Node.
+ ::
+
+ ::tab{label="Windows"}
+ Lo mismo, y nada de la compilación depende de la plataforma.
+ ::
+:::
+```
+::
+
+## API
+
+Sin props. Las etiquetas vienen de los hijos.
+
+### Slots
+
+| Slot | Qué va dentro |
+| :-------- | :-------------- |
+| `default` | bloques `::tab` |
+
+## Notas
+
+**No `::tabs`.** El kit de UI del tema ya registra un `Tabs` — el de shadcn — y
+dos componentes con el mismo nombre no son una sobrescritura que la compilación
+resuelva a tu favor: Nuxt se queda con uno de los dos, y se quedó con el de
+shadcn, así que `::tabs` renderizaba una primitiva sin triggers y sin estado. Un
+componente de contenido no puede llevar un nombre que pertenece al kit de UI.
+
+Construido sobre las primitivas de reka-ui y no sobre el wrapper de shadcn,
+porque las etiquetas no se conocen hasta que los hijos se han registrado a sí
+mismos: MDC las entrega como contenido de slot, no como prop. La navegación con
+el teclado, el foco itinerante y los roles ARIA vienen con ellas.
diff --git a/docs/es/4.reference/4.mdc-components/5.tab.md b/docs/es/4.reference/4.mdc-components/5.tab.md
new file mode 100644
index 00000000..734882bd
--- /dev/null
+++ b/docs/es/4.reference/4.mdc-components/5.tab.md
@@ -0,0 +1,38 @@
+---
+title: Pestaña
+description: Un panel de un grupo de pestañas.
+icon: lucide:square
+---
+
+Un panel dentro de [`::tab-group`](/reference/mdc-components/tab-group). Por sí
+solo no significa nada.
+
+## Uso
+
+```mdc
+:::tab-group
+ ::tab{label="pnpm"}
+ La capa es una dependencia de desarrollo.
+ ::
+:::
+```
+
+## API
+
+### Props
+
+| Prop | Tipo | Por defecto | Notas |
+| :------ | :------- | :---------- | :--------------------------------- |
+| `label` | `string` | — | El texto del trigger de la pestaña |
+
+### Slots
+
+| Slot | Qué va dentro |
+| :-------- | :---------------------- |
+| `default` | El panel, como Markdown |
+
+## Notas
+
+El componente registra su propia etiqueta en el padre durante el setup, y eso es
+lo que permite construir la lista de triggers a partir de hijos que MDC todavía
+no ha renderizado.
diff --git a/docs/es/4.reference/4.mdc-components/6.accordion.md b/docs/es/4.reference/4.mdc-components/6.accordion.md
new file mode 100644
index 00000000..9f43f4f6
--- /dev/null
+++ b/docs/es/4.reference/4.mdc-components/6.accordion.md
@@ -0,0 +1,52 @@
+---
+title: Acordeón
+description: Secciones plegables — una FAQ, o respuestas entre las que elige un lector.
+icon: lucide:chevrons-up-down
+---
+
+`::accordion` alrededor de bloques
+[`::accordion-item`](/reference/mdc-components/accordion-item).
+
+## Uso
+
+::preview
+::::accordion
+:::accordion-item{label="¿Escribe content.config.ts quien la consume?" icon="lucide:file-question"}
+No. `duxt.sources` en `app.config.ts` es la única declaración; el propio
+`content.config.ts` de la capa la lee.
+:::
+
+:::accordion-item{label="¿Hace falta un controlador nativo de SQLite?" icon="lucide:database"}
+No. La base de datos se lee a través de `node:sqlite`, y por eso la compilación
+necesita Node 24.
+:::
+::::
+
+#code
+```mdc
+:::accordion
+ ::accordion-item{label="¿Escribe content.config.ts quien la consume?" icon="lucide:file-question"}
+ No. `duxt.sources` en `app.config.ts` es la única declaración.
+ ::
+:::
+```
+::
+
+## API
+
+Sin props.
+
+### Slots
+
+| Slot | Qué va dentro |
+| :-------- | :------------------------- |
+| `default` | bloques `::accordion-item` |
+
+## Notas
+
+`type="multiple"` a propósito: una FAQ en la que abrir una respuesta cierra
+aquella por la que ibas a medias es una FAQ peor.
+
+Sobre las primitivas de reka-ui, como el árbol y las pestañas, por la misma
+razón — un desplegable hecho a mano no tiene gestión del teclado, ni
+`aria-expanded`, ni relación alguna entre el trigger y la región que abre.
diff --git a/docs/es/4.reference/4.mdc-components/7.accordion-item.md b/docs/es/4.reference/4.mdc-components/7.accordion-item.md
new file mode 100644
index 00000000..a2187cd9
--- /dev/null
+++ b/docs/es/4.reference/4.mdc-components/7.accordion-item.md
@@ -0,0 +1,32 @@
+---
+title: Elemento de acordeón
+description: Una sección plegable.
+icon: lucide:chevron-down
+---
+
+Una sección dentro de [`::accordion`](/reference/mdc-components/accordion).
+
+## Uso
+
+```mdc
+:::accordion
+ ::accordion-item{label="¿Por qué una capa y no un módulo?" icon="lucide:layers"}
+ Un módulo no puede entregar páginas, un tema ni un valor por defecto de app.config.
+ ::
+:::
+```
+
+## API
+
+### Props
+
+| Prop | Tipo | Por defecto | Notas |
+| :------ | :------- | :---------- | :------------------------------------------------ |
+| `label` | `string` | — | El texto del trigger |
+| `icon` | `string` | — | Cualquier nombre de Iconify, antes de la etiqueta |
+
+### Slots
+
+| Slot | Qué va dentro |
+| :-------- | :-------------------------- |
+| `default` | La respuesta, como Markdown |
diff --git a/docs/es/4.reference/4.mdc-components/8.field-group.md b/docs/es/4.reference/4.mdc-components/8.field-group.md
new file mode 100644
index 00000000..b120e0ea
--- /dev/null
+++ b/docs/es/4.reference/4.mdc-components/8.field-group.md
@@ -0,0 +1,42 @@
+---
+title: Grupo de campos
+description: Una lista de campos como un único bloque.
+icon: lucide:list-tree
+---
+
+Agrupa bloques [`::field`](/reference/mdc-components/field) para que se lean como
+una sola lista con separadores en vez de como tarjetas sueltas.
+
+## Uso
+
+::preview
+::::field-group
+:::field{name="path" type="string" required}
+Carpeta con el Markdown, relativa a la raíz del repositorio.
+:::
+
+:::field{name="history" type="boolean" default="false"}
+Lee el historial git de esta fuente para «Última actualización» y las personas
+que han contribuido.
+:::
+::::
+
+#code
+```mdc
+:::field-group
+ ::field{name="path" type="string" required}
+ Carpeta con el Markdown, relativa a la raíz del repositorio.
+ ::
+:::
+```
+::
+
+## API
+
+Sin props.
+
+### Slots
+
+| Slot | Qué va dentro |
+| :-------- | :---------------- |
+| `default` | Bloques `::field` |
diff --git a/docs/es/4.reference/4.mdc-components/9.field.md b/docs/es/4.reference/4.mdc-components/9.field.md
new file mode 100644
index 00000000..34627407
--- /dev/null
+++ b/docs/es/4.reference/4.mdc-components/9.field.md
@@ -0,0 +1,50 @@
+---
+title: Campo
+description: Un parámetro, propiedad u opción, con su tipo y su valor por defecto.
+icon: lucide:tag
+---
+
+El componente más usado que a un tema de documentación le falta: un parámetro de
+la API, con más explicación de la que cabe en una fila de tabla.
+
+## Uso
+
+::preview
+:::field{name="showRepo" type="boolean" default="false"}
+Fuerza un segmento de repositorio con un solo repositorio.
+:::
+
+#code
+```mdc
+::field{name="showRepo" type="boolean" default="false"}
+Fuerza un segmento de repositorio con un solo repositorio.
+::
+```
+::
+
+## API
+
+### Props
+
+| Prop | Tipo | Por defecto | Notas |
+| :--------- | :-------- | :---------- | :-------------------------------------------------- |
+| `name` | `string` | — | El nombre del parámetro, en monoespaciada |
+| `type` | `string` | — | Dibujado a su lado, en monoespaciada |
+| `default` | `string` | — | Alineado a la derecha, como `= value` |
+| `required` | `boolean` | `false` | Dibuja un distintivo en vez de un valor por defecto |
+
+### Slots
+
+| Slot | Qué va dentro |
+| :-------- | :---------------------------- |
+| `default` | La descripción, como Markdown |
+
+## Notas
+
+Una lista de definiciones sería la elección semántica y aquí es la equivocada: el
+nombre, el tipo, el valor por defecto y si es obligatorio son cuatro cosas
+distintas, y `` les da un único hueco entre todas.
+
+Una tabla sigue siendo la mejor opción para una lista larga de campos de una
+línea — véase [Fuentes](/reference/sources), donde ocho campos están para
+compararse de un vistazo en vez de leerse uno a uno.
diff --git a/docs/es/4.reference/4.mdc-components/index.md b/docs/es/4.reference/4.mdc-components/index.md
new file mode 100644
index 00000000..8b5adf4c
--- /dev/null
+++ b/docs/es/4.reference/4.mdc-components/index.md
@@ -0,0 +1,22 @@
+---
+title: Componentes MDC
+description: Los bloques que puede invocar una página Markdown, una página cada uno, con sus props.
+icon: lucide:blocks
+---
+
+Cualquier cosa en `app/components/content/` se puede invocar desde Markdown con
+sintaxis MDC — sin módulo, sin MDX. Estos vienen con la capa; un archivo propio
+con el mismo nombre reemplaza a uno.
+
+Todas las páginas de aquí están construidas igual: qué es el bloque, un ejemplo
+representado tal como lo representa este sitio con al lado el Markdown que lo
+produjo, y después sus props, slots y eventos.
+
+::page-cards
+::
+
+::callout{type="tip" title="Anidar añade un signo de dos puntos"}
+Un bloque dentro de otro bloque abre con un signo de dos puntos más que su
+padre — `:::accordion` alrededor de `::accordion-item`. El cierre coincide con
+la apertura.
+::
diff --git a/docs/es/4.reference/5.components/1.layout/1.duxt-header.md b/docs/es/4.reference/5.components/1.layout/1.duxt-header.md
new file mode 100644
index 00000000..d11b8f4e
--- /dev/null
+++ b/docs/es/4.reference/5.components/1.layout/1.duxt-header.md
@@ -0,0 +1,28 @@
+---
+title: DuxtHeader
+description: "La barra de navegación: título, navegación, búsqueda y los enlaces de iconos."
+icon: lucide:panel-top
+---
+
+La barra en lo alto de cada página — el título del sitio, las entradas de
+navegación, la búsqueda, los selectores de versión y de idioma, y el conmutador
+de tema.
+
+## Uso
+
+```vue
+
+```
+
+## API
+
+Sin props. Todo viene de la configuración: `title`, `navigation`, `links`,
+`version` y `locales`.
+
+## Notas
+
+Una entrada sin `to` ni `children` significa «la documentación». La entrada
+«Docs» de la propia capa se entrega sin `to` a propósito: dónde vive la
+documentación depende de los prefijos de quien la consume, y la capa no puede
+saberlo. Se resuelve a la primera sección, que es lo que hace que la barra de
+navegación por defecto funcione igual en un sitio versionado que en uno plano.
diff --git a/docs/es/4.reference/5.components/1.layout/2.duxt-footer.md b/docs/es/4.reference/5.components/1.layout/2.duxt-footer.md
new file mode 100644
index 00000000..bd1953ac
--- /dev/null
+++ b/docs/es/4.reference/5.components/1.layout/2.duxt-footer.md
@@ -0,0 +1,32 @@
+---
+title: DuxtFooter
+description: "El pie: el nombre del sitio, sus enlaces legales y la línea propia de la capa."
+icon: lucide:panel-bottom
+---
+
+Una línea, y solo lo que debe un pie de página: de quién es este sitio, y los
+enlaces que pide la ley. La navegación vive en la cabecera y en la barra
+lateral.
+
+## Uso
+
+```vue
+
+```
+
+## API
+
+Sin props. Lee `footer.copyright`, `footer.legal` y `poweredBy`.
+
+## Notas
+
+La línea «Powered by duxt» lee su versión y su repositorio del `package.json`
+propio de duxt en tiempo de compilación — release-please actualiza ese archivo,
+y una segunda copia del número es una copia equivocada desde la primera
+publicación en adelante.
+
+Se puede desactivar, porque una capa a la que no se le puede decir que deje de
+nombrarse es adware: `poweredBy: false` la quita.
+
+La fila legal se entrega **vacía**. Un aviso legal nunca le corresponde a la
+plantilla.
diff --git a/docs/es/4.reference/5.components/1.layout/index.md b/docs/es/4.reference/5.components/1.layout/index.md
new file mode 100644
index 00000000..ff56805c
--- /dev/null
+++ b/docs/es/4.reference/5.components/1.layout/index.md
@@ -0,0 +1,17 @@
+---
+title: Diseño
+description: Los dos componentes que enmarcan una página — la barra de navegación y el pie.
+icon: lucide:layout-panel-top
+---
+
+Lo que rodea a la documentación en cada página. Ninguno de los dos acepta prop:
+leen [`useDuxtConfig()`](/reference/composables/config-and-content/use-duxt-config),
+así que lo que dibujan es lo que dice la configuración.
+
+La fila de secciones bajo la barra de navegación es
+[`DuxtSections`](/reference/components/navigation/duxt-sections), archivado con
+la navegación — nombra las partes del árbol de documentación, que es navegar, no
+enmarcar.
+
+::page-cards
+::
diff --git a/docs/es/4.reference/5.components/2.navigation/1.duxt-sections.md b/docs/es/4.reference/5.components/2.navigation/1.duxt-sections.md
new file mode 100644
index 00000000..9df4e942
--- /dev/null
+++ b/docs/es/4.reference/5.components/2.navigation/1.duxt-sections.md
@@ -0,0 +1,26 @@
+---
+title: DuxtSections
+description: La segunda fila de la barra de navegación — las partes de primer nivel de la documentación.
+icon: lucide:rows-3
+---
+
+La fila de secciones bajo la cabecera, solo en las páginas de documentación.
+
+## Uso
+
+```vue
+
+```
+
+## API
+
+Sin props. Lee `sections`; no representa nada cuando esa lista está vacía.
+
+## Notas
+
+Está con la documentación en lugar de en la cabecera global, porque la portada
+no tiene secciones que mostrar.
+
+Una sección rara vez es la página misma — es la rama en la que está la página.
+Así que la coincidencia exacta anuncia `aria-current="page"` y un ancestro
+anuncia `true`, que es la distinción para la que existe `aria-current`.
diff --git a/docs/es/4.reference/5.components/2.navigation/2.duxt-navigation.md b/docs/es/4.reference/5.components/2.navigation/2.duxt-navigation.md
new file mode 100644
index 00000000..2890442b
--- /dev/null
+++ b/docs/es/4.reference/5.components/2.navigation/2.duxt-navigation.md
@@ -0,0 +1,34 @@
+---
+title: DuxtNavigation
+description: El árbol de la barra lateral.
+icon: lucide:list-tree
+---
+
+La lista anidada de páginas de la sección actual.
+
+## Uso
+
+```vue
+
+
+
+
+
+```
+
+## API
+
+### Props
+
+| Prop | Tipo | Notas |
+| :------ | :------------------------- | :------------------------------------------ |
+| `items` | `ContentNavigationItem[]` | Obligatorio — una rama del árbol de Content |
+
+## Notas
+
+El árbol viene de [`useDuxtNavigation()`](/reference/composables/config-and-content/use-duxt-navigation),
+que pregunta a la colección que sirve la ruta actual — así que un sitio
+versionado obtiene el árbol de esa versión, no el de la versión por defecto.
diff --git a/docs/es/4.reference/5.components/2.navigation/3.duxt-navigation-link.md b/docs/es/4.reference/5.components/2.navigation/3.duxt-navigation-link.md
new file mode 100644
index 00000000..53718bf6
--- /dev/null
+++ b/docs/es/4.reference/5.components/2.navigation/3.duxt-navigation-link.md
@@ -0,0 +1,29 @@
+---
+title: DuxtNavigationLink
+description: Una entrada de la barra lateral.
+icon: lucide:link-2
+---
+
+Una sola entrada, con su icono, su estado activo y — si es una rama — sus
+hijos.
+
+## Uso
+
+```vue
+
+```
+
+## API
+
+### Props
+
+| Prop | Tipo | Notas |
+| :----- | :----------------------- | :--------------------------------- |
+| `item` | `ContentNavigationItem` | Obligatorio — un nodo del árbol |
+
+## Notas
+
+Merece la pena reemplazarlo por su cuenta cuando lo único que falla es el
+aspecto de una entrada: ensombrecer este deja el árbol, el estado activo y el
+plegado a
+[`DuxtNavigation`](/reference/components/navigation/duxt-navigation).
diff --git a/docs/es/4.reference/5.components/2.navigation/4.duxt-breadcrumb.md b/docs/es/4.reference/5.components/2.navigation/4.duxt-breadcrumb.md
new file mode 100644
index 00000000..e3516954
--- /dev/null
+++ b/docs/es/4.reference/5.components/2.navigation/4.duxt-breadcrumb.md
@@ -0,0 +1,27 @@
+---
+title: DuxtBreadcrumb
+description: La miga de pan sobre el título de la página.
+icon: lucide:chevrons-right
+---
+
+La sección, y luego los grupos que haya entre ella y la página.
+
+## Uso
+
+```vue
+
+```
+
+## API
+
+### Props
+
+| Prop | Tipo | Notas |
+| :----- | :------- | :--------------------------------------------- |
+| `path` | `string` | Obligatorio — la ruta de documentación, sin prefijo |
+
+## Notas
+
+`breadcrumb: false` en la configuración la quita en todo el sitio: un árbol de
+documentación plano recibe una miga de pan que solo repite el nombre de su
+propia sección.
diff --git a/docs/es/4.reference/5.components/2.navigation/5.duxt-toc.md b/docs/es/4.reference/5.components/2.navigation/5.duxt-toc.md
new file mode 100644
index 00000000..e0104e07
--- /dev/null
+++ b/docs/es/4.reference/5.components/2.navigation/5.duxt-toc.md
@@ -0,0 +1,33 @@
+---
+title: DuxtToc
+description: La tabla de contenidos, y el aside que va debajo.
+icon: lucide:list
+---
+
+Los encabezados de la página actual, siguiendo al lector mientras se desplaza,
+más los enlaces `aside` configurados debajo.
+
+## Uso
+
+```vue
+
+```
+
+## API
+
+### Props
+
+| Prop | Tipo | Notas |
+| :------ | :---------- | :--------------------------------------- |
+| `links` | `TocLink[]` | Obligatorio — los enlaces toc de Content |
+
+## Notas
+
+Qué encabezado es el actual viene de
+[`useActiveHeading()`](/reference/composables/reading/use-active-heading), que
+observa los encabezados en lugar del desplazamiento — una última sección corta
+se sigue anunciando como alcanzada.
+
+El bloque `aside.links` de debajo se entrega **vacío**: un enlace al
+repositorio, un gestor de incidencias o una comunidad son de quien gestiona el
+sitio.
diff --git a/docs/es/4.reference/5.components/2.navigation/6.duxt-page-nav.md b/docs/es/4.reference/5.components/2.navigation/6.duxt-page-nav.md
new file mode 100644
index 00000000..c8420818
--- /dev/null
+++ b/docs/es/4.reference/5.components/2.navigation/6.duxt-page-nav.md
@@ -0,0 +1,30 @@
+---
+title: DuxtPageNav
+description: Anterior y siguiente, limitados por la sección.
+icon: lucide:arrow-left-right
+---
+
+Los dos enlaces al pie de una página.
+
+## Uso
+
+```vue
+
+```
+
+## API
+
+### Props
+
+| Prop | Tipo | Notas |
+| :----- | :------- | :--------------------------------------------- |
+| `path` | `string` | Obligatorio — la ruta de documentación, sin prefijo |
+
+## Notas
+
+Se detiene en el límite de la sección a propósito: pasar a otra parte de la
+documentación es tarea de la fila de secciones, no de un enlace que dice
+«siguiente».
+
+Los grupos se aplanan en sus hijos, así que «siguiente» es la siguiente página
+que leería un lector, no la siguiente carpeta.
diff --git a/docs/es/4.reference/5.components/2.navigation/index.md b/docs/es/4.reference/5.components/2.navigation/index.md
new file mode 100644
index 00000000..6060819c
--- /dev/null
+++ b/docs/es/4.reference/5.components/2.navigation/index.md
@@ -0,0 +1,15 @@
+---
+title: Navegación
+description: Moverse por el sitio — la fila de secciones, la barra lateral, el rastro, la tabla de contenidos y anterior/siguiente.
+icon: lucide:compass
+---
+
+Seis componentes sobre un mismo árbol. La fila de secciones nombra sus partes de
+primer nivel, la barra lateral recorre la rama en la que está el lector, y los
+tres últimos responden a «dónde estoy» y «qué viene después».
+
+La mayoría acepta una prop, porque cada uno dibuja una parte concreta del árbol
+en lugar de la configuración entera.
+
+::page-cards
+::
diff --git a/docs/es/4.reference/5.components/3.controls/1.duxt-search.md b/docs/es/4.reference/5.components/3.controls/1.duxt-search.md
new file mode 100644
index 00000000..7ebf43a5
--- /dev/null
+++ b/docs/es/4.reference/5.components/3.controls/1.duxt-search.md
@@ -0,0 +1,26 @@
+---
+title: DuxtSearch
+description: Búsqueda en todas las fuentes, con los resultados clasificados juntos.
+icon: lucide:search
+---
+
+El campo de búsqueda y su diálogo, abiertos con el botón o con ⌘K .
+
+## Uso
+
+```vue
+
+```
+
+## API
+
+Sin props. Construido sobre
+[`useDuxtSearch()`](/reference/composables/reading/use-duxt-search).
+
+## Notas
+
+Los resultados abarcan todas las fuentes y se clasifican juntos, así que un
+término que aparece en dos repositorios se responde desde ambos. Cuando nada
+coincide exactamente, la lista recae en las coincidencias aproximadas de
+[`useFuzzySearch()`](/reference/composables/reading/use-fuzzy-search) y lo dice —
+una errata debería devolver la página, no una caja vacía.
diff --git a/docs/es/4.reference/5.components/3.controls/2.duxt-version.md b/docs/es/4.reference/5.components/3.controls/2.duxt-version.md
new file mode 100644
index 00000000..a819c6e8
--- /dev/null
+++ b/docs/es/4.reference/5.components/3.controls/2.duxt-version.md
@@ -0,0 +1,32 @@
+---
+title: DuxtVersion
+description: El distintivo de versión, y el selector cuando hay más de una.
+icon: lucide:layers
+---
+
+Un solo elemento junto al título: un distintivo cuando no hay nada que elegir, y
+ese mismo distintivo como disparador de un desplegable cuando sí lo hay.
+
+## Uso
+
+```vue
+
+```
+
+## API
+
+Sin props. Lee `versions`, o el manifiesto de fuentes resuelto cuando eso no
+está definido.
+
+## Notas
+
+Antes, la versión del proyecto estaba como distintivo junto al título mientras
+las versiones de la documentación vivían en un desplegable aparte en el extremo
+derecho — dos controles diciendo cosas distintas, y el de la derecha solo
+aparecía en las fuentes que tienen versiones, así que los iconos junto a él se
+desplazaban al moverte entre repositorios.
+
+Cambiar de versión mantiene la página: se intercambia el prefijo en lugar de
+enviar al lector a la raíz de la versión. Cada entrada lleva su ciclo de vida,
+porque una lista donde tres entradas parecen iguales no dice nada sobre cuál de
+ellas todavía es seguro leer.
diff --git a/docs/es/4.reference/5.components/3.controls/3.duxt-version-banner.md b/docs/es/4.reference/5.components/3.controls/3.duxt-version-banner.md
new file mode 100644
index 00000000..330bc99d
--- /dev/null
+++ b/docs/es/4.reference/5.components/3.controls/3.duxt-version-banner.md
@@ -0,0 +1,32 @@
+---
+title: DuxtVersionBanner
+description: La barra que le dice a un lector que esta versión no es la actual.
+icon: lucide:triangle-alert
+---
+
+Se muestra sobre la página cuando la versión que se está leyendo no es la
+predeterminada.
+
+## Uso
+
+```vue
+
+```
+
+## API
+
+Sin props. Lee
+[`useDuxtVersion()`](/reference/composables/paths-and-versions/use-duxt-version).
+
+## Notas
+
+La mitad visible del problema de las versiones. `canonical` y `noindex` le dicen
+a un rastreador qué versión servir; al lector no le dicen nada. Quien llegó a la
+v0.7.0 desde un resultado de búsqueda, o desde un enlace en una incidencia
+antigua, ve una documentación que parece exactamente la actual. Cualquiera de
+las dos mitades por separado deja a un público equivocado, así que las etiquetas
+del head y este aviso se entregan juntos.
+
+A una versión `upcoming` se le dice que su documentación aún puede cambiar, y
+**no** se le dice que actualice — ese es el único estado al que nunca se debe
+llegar adivinando.
diff --git a/docs/es/4.reference/5.components/3.controls/4.duxt-locale.md b/docs/es/4.reference/5.components/3.controls/4.duxt-locale.md
new file mode 100644
index 00000000..d83dbec8
--- /dev/null
+++ b/docs/es/4.reference/5.components/3.controls/4.duxt-locale.md
@@ -0,0 +1,28 @@
+---
+title: DuxtLocale
+description: El selector de idioma.
+icon: lucide:languages
+---
+
+Nada en absoluto cuando el sitio sirve un idioma; un desplegable cuando sirve
+varios.
+
+## Uso
+
+```vue
+
+```
+
+## API
+
+Sin props. Lee `locales`.
+
+## Notas
+
+Construido igual que [`DuxtVersion`](/reference/components/controls/duxt-version)
+a su lado, de modo que quien la consume con un solo locale nunca ve que duxt
+habla siete idiomas.
+
+Usa `switchLocalePath` en lugar de un enlace a la raíz del locale: cambiar de
+idioma en una página sobre despliegue debería aterrizar en esa misma página en
+el nuevo idioma, no devolver al lector a la puerta de entrada.
diff --git a/docs/es/4.reference/5.components/3.controls/5.duxt-progress.md b/docs/es/4.reference/5.components/3.controls/5.duxt-progress.md
new file mode 100644
index 00000000..314ebf60
--- /dev/null
+++ b/docs/es/4.reference/5.components/3.controls/5.duxt-progress.md
@@ -0,0 +1,27 @@
+---
+title: DuxtProgress
+description: Cuánto ha bajado el lector por la página.
+icon: lucide:loader
+---
+
+Una línea de dos píxeles bajo la cabecera.
+
+## Uso
+
+```vue
+
+```
+
+## API
+
+Sin props.
+
+## Notas
+
+Una línea en lugar de un número: en una página de referencia larga la pregunta
+útil es «¿estoy cerca del final?», y un porcentaje la responde peor que una
+línea. Lleva `aria-hidden`, porque no dice nada que un lector de pantalla no
+pueda ya preguntarle al documento.
+
+Se mide contra el documento y no contra un contenedor con scroll, así que sigue
+siendo correcta tanto en el diseño de documentación como en la portada.
diff --git a/docs/es/4.reference/5.components/3.controls/6.duxt-shortcuts.md b/docs/es/4.reference/5.components/3.controls/6.duxt-shortcuts.md
new file mode 100644
index 00000000..f22efe75
--- /dev/null
+++ b/docs/es/4.reference/5.components/3.controls/6.duxt-shortcuts.md
@@ -0,0 +1,25 @@
+---
+title: DuxtShortcuts
+description: La hoja de atajos de teclado, con ?.
+icon: lucide:keyboard
+---
+
+La superposición que lista cada tecla que asigna el tema, abierta con
+? .
+
+## Uso
+
+```vue
+
+```
+
+## API
+
+Sin props.
+
+## Notas
+
+Su lista es `duxtShortcuts` — el mismo array contra el que comparan los
+manejadores —, así que la hoja no puede llegar a anunciar una tecla que nada
+asigna. Véase
+[`useDuxtShortcuts()`](/reference/composables/reading/use-duxt-shortcuts).
diff --git a/docs/es/4.reference/5.components/3.controls/7.duxt-skip-link.md b/docs/es/4.reference/5.components/3.controls/7.duxt-skip-link.md
new file mode 100644
index 00000000..55d5eb96
--- /dev/null
+++ b/docs/es/4.reference/5.components/3.controls/7.duxt-skip-link.md
@@ -0,0 +1,28 @@
+---
+title: DuxtSkipLink
+description: El primer elemento enfocable de la página.
+icon: lucide:skip-forward
+---
+
+«Saltar al contenido», invisible hasta que recibe el foco.
+
+## Uso
+
+```vue
+
+```
+
+## API
+
+Sin props.
+
+## Notas
+
+Sin él, cada cambio de página significa tabular por la cabecera, la fila de
+secciones y toda la barra lateral antes de la primera palabra del texto.
+`sr-only` lo reduce a un único píxel, y el estado de foco lo devuelve a la
+pantalla por encima de todo lo demás.
+
+El clic se maneja en lugar de dejárselo al hash, porque el navegador solo mueve
+el foco en un enlace de hash en algunos navegadores — el manejador lo mueve en
+todos.
diff --git a/docs/es/4.reference/5.components/3.controls/index.md b/docs/es/4.reference/5.components/3.controls/index.md
new file mode 100644
index 00000000..53bccf37
--- /dev/null
+++ b/docs/es/4.reference/5.components/3.controls/index.md
@@ -0,0 +1,18 @@
+---
+title: Controles
+description: Lo que maneja un lector — la búsqueda, los selectores, la hoja de atajos y dos indicadores.
+icon: lucide:sliders-horizontal
+---
+
+Cada uno de estos es un control que el lector maneja, o un estado que lee, y no
+una parte de la estructura de la página. Ninguno toma props: leen
+[`useDuxtConfig()`](/reference/composables/config-and-content/use-duxt-config)
+y no representan nada cuando la configuración no les da nada que ofrecer.
+
+Esa última parte es el patrón que conviene conocer antes de reemplazar alguno.
+Un selector con una sola opción no es un selector, así que `DuxtVersion` dibuja
+un distintivo y `DuxtLocale` no dibuja nada en absoluto — un sitio con un solo
+locale nunca llega a enterarse de que duxt habla siete idiomas.
+
+::page-cards
+::
diff --git a/docs/es/4.reference/5.components/4.page/1.duxt-page-info.md b/docs/es/4.reference/5.components/4.page/1.duxt-page-info.md
new file mode 100644
index 00000000..2f7b5610
--- /dev/null
+++ b/docs/es/4.reference/5.components/4.page/1.duxt-page-info.md
@@ -0,0 +1,35 @@
+---
+title: DuxtPageInfo
+description: "De dónde vino la página: enlace de edición, última actualización, quién ha contribuido."
+icon: lucide:git-commit-horizontal
+---
+
+La procedencia, bajo la tabla de contenidos.
+
+## Uso
+
+```vue
+
+```
+
+## API
+
+### Props
+
+| Prop | Tipo | Notas |
+| :----- | :------- | :------------------------------------------------------- |
+| `page` | `object` | Obligatorio — el documento de Content que se representa |
+
+## Notas
+
+No al pie del artículo, que es donde estuvo primero y donde se leía como una
+ocurrencia tardía debajo de los enlaces de anterior y siguiente. «Editar esta
+página», cuándo cambió por última vez y quién la escribió son una sola pregunta
+— la procedencia — y la columna de la derecha ya es la que responde qué ES una
+página, en lugar de qué dice.
+
+Todo lo de aquí es derivado. El enlace de edición apunta al repositorio de la
+fuente y a la ref de la que se leyó la página, así que una página que viene de
+un tag enlaza con su origen en lugar de con un formulario de edición que
+editaría la rama equivocada. «Última actualización» y la lista de personas que
+han contribuido necesitan `history: true` en la fuente.
diff --git a/docs/es/4.reference/5.components/4.page/2.duxt-copy-page.md b/docs/es/4.reference/5.components/4.page/2.duxt-copy-page.md
new file mode 100644
index 00000000..66cabdab
--- /dev/null
+++ b/docs/es/4.reference/5.components/4.page/2.duxt-copy-page.md
@@ -0,0 +1,40 @@
+---
+title: DuxtCopyPage
+description: Entrega esta página a un modelo, o lee su código fuente.
+icon: lucide:clipboard-copy
+---
+
+Un control dividido junto al título de la página: copiar el Markdown, verlo o
+abrirlo en un modelo de chat.
+
+## Uso
+
+```vue
+
+```
+
+## API
+
+### Props
+
+| Prop | Tipo | Notas |
+| :-------- | :------- | :-------------------------------------------------- |
+| `path` | `string` | Obligatorio — la ruta de la documentación |
+| `title` | `string` | Se usa en el prompt que se entrega a un modelo |
+| `rawbody` | `string` | El código fuente Markdown, para el botón de copiar |
+
+## Notas
+
+Un control dividido en lugar de cuatro botones — el caso habitual es
+«copiarlo», y los otros tres son la misma intención por otra puerta.
+
+Lo que todos ellos entregan es el **Markdown**, no la página representada: a un
+modelo al que le das HTML le toca deshacer una maquetación para encontrar un
+encabezado, y uno al que le das el código fuente lee lo que escribió quien la
+redactó. El frontmatter se elimina, así que lo que se copia se lee como la
+página y no como el archivo.
+
+Los enlaces externos necesitan una URL absoluta, y el origen se lee del
+navegador en el momento del clic en lugar de la configuración — el único valor
+que seguro que es correcto, incluido un despliegue de vista previa cuyo dominio
+nadie apuntó.
diff --git a/docs/es/4.reference/5.components/4.page/3.duxt-page-feedback.md b/docs/es/4.reference/5.components/4.page/3.duxt-page-feedback.md
new file mode 100644
index 00000000..e7d8be16
--- /dev/null
+++ b/docs/es/4.reference/5.components/4.page/3.duxt-page-feedback.md
@@ -0,0 +1,39 @@
+---
+title: DuxtPageFeedback
+description: "«¿Te ha resultado útil esta página?» — un evento, y ningún backend."
+icon: lucide:thumbs-up
+---
+
+El bloque al pie de cada página.
+
+## Uso
+
+```vue
+ track(helpful)" />
+```
+
+## API
+
+### Emits
+
+| Evento | Payload | Cuándo |
+| :--------- | :------------------ | :------------------------- |
+| `feedback` | `helpful: boolean` | El lector responde |
+
+### Slots
+
+| Slot | Props | Qué sustituye |
+| :-------- | :-------------------- | :------------------------ |
+| `default` | `{ answered, answer }` | La pregunta y los botones |
+
+## Notas
+
+Un slot con un evento y ningún backend, y ese es todo el diseño. La capa no
+almacena nada y no envía nada: no le corresponde saber dónde vive la analítica
+de quien la consume, y un tema de documentación que llama a casa por defecto no
+es un tema que se pueda publicar. La respuesta vive en el estado propio del
+componente mientras dura la visita y en ningún sitio más — un cambio de ruta la
+reinicia, porque una página nueva es una pregunta nueva.
+
+Conectarla tiene su propia guía:
+[Recoger opiniones de página](/guides/collect-page-feedback).
diff --git a/docs/es/4.reference/5.components/4.page/4.duxt-code-block.md b/docs/es/4.reference/5.components/4.page/4.duxt-code-block.md
new file mode 100644
index 00000000..6053a9ef
--- /dev/null
+++ b/docs/es/4.reference/5.components/4.page/4.duxt-code-block.md
@@ -0,0 +1,40 @@
+---
+title: DuxtCodeBlock
+description: La tarjeta alrededor de un bloque de código.
+icon: lucide:code
+---
+
+El marco en el que se dibuja todo bloque delimitado: nombre de archivo, icono de
+lenguaje, botón de copiar.
+
+## Uso
+
+Lo invoca [`ProsePre`](/reference/mdc-components/prose/prose-pre) en lugar de
+escribirse a mano, pero se puede usar directamente:
+
+```vue
+
+```
+
+## API
+
+### Props
+
+| Prop | Tipo | Notas |
+| :--------- | :------- | :--------------------------------------------------------------- |
+| `code` | `string` | El código fuente en bruto, para el botón de copiar y como cuerpo |
+| `language` | `string` | Elige el icono |
+| `filename` | `string` | La etiqueta de la cabecera |
+
+### Slots
+
+| Slot | Qué va dentro |
+| :-------- | :---------------------------------------- |
+| `default` | El marcado resaltado, cuando lo hay |
+
+## Notas
+
+Sin nombre de archivo, el botón de copiar flota en la esquina y aparece al pasar
+el ratón; con uno, se asienta en la cabecera, donde siempre está visible. Un
+bloque que muestra siempre un botón en la esquina de la página es más ruidoso
+que uno que lo enseña cuando el lector va a buscarlo.
diff --git a/docs/es/4.reference/5.components/4.page/index.md b/docs/es/4.reference/5.components/4.page/index.md
new file mode 100644
index 00000000..63b2f1a7
--- /dev/null
+++ b/docs/es/4.reference/5.components/4.page/index.md
@@ -0,0 +1,11 @@
+---
+title: Página
+description: Los componentes que pertenecen a la página misma.
+icon: lucide:file-text
+---
+
+La procedencia, el control de copiar, el bloque de opiniones y la tarjeta
+alrededor de un bloque de código.
+
+::page-cards
+::
diff --git a/docs/es/4.reference/5.components/index.md b/docs/es/4.reference/5.components/index.md
new file mode 100644
index 00000000..cb1debe8
--- /dev/null
+++ b/docs/es/4.reference/5.components/index.md
@@ -0,0 +1,24 @@
+---
+title: Componentes
+description: Los componentes propios de la capa, con una página cada uno — para qué sirve cada uno y qué acepta.
+icon: lucide:component
+---
+
+Todos los componentes de aquí son **ensombrecibles**: un archivo del mismo
+nombre en tu proyecto sustituye al de la capa, sin ninguna configuración. Un
+nombre documentado aquí forma parte de la superficie pública, así que no se
+renombrará sin una versión mayor.
+
+Las páginas están agrupadas por lo que hace un componente: los dos que enmarcan
+una página, los seis que navegan por su árbol, los controles que maneja un
+lector y los componentes que pertenecen a la página misma.
+
+::page-cards
+::
+
+::callout{type="tip" title="Reemplaza lo menos posible"}
+Antes de ensombrecer un componente, comprueba si una clave de configuración, un
+slot o un token CSS ya hace lo que necesitas — véase
+[Sobrescribir el tema](/guides/override-the-theme). Un archivo reemplazado deja
+de recibir las correcciones de la capa.
+::
diff --git a/docs/es/4.reference/6.composables/1.config-and-content/1.use-duxt-config.md b/docs/es/4.reference/6.composables/1.config-and-content/1.use-duxt-config.md
new file mode 100644
index 00000000..045ec5ee
--- /dev/null
+++ b/docs/es/4.reference/6.composables/1.config-and-content/1.use-duxt-config.md
@@ -0,0 +1,31 @@
+---
+title: useDuxtConfig()
+description: La configuración fusionada, con cada campo de texto ya resuelto.
+icon: lucide:settings
+---
+
+Al que hay que recurrir en un componente reemplazado.
+
+## Firma
+
+```ts
+const duxt = useDuxtConfig();
+```
+
+## Devuelve
+
+La configuración entera, reactiva: los valores por defecto de la capa con tu
+`app.config.ts` fusionado encima, y cada campo de texto ya convertido en cadena.
+
+## Notas
+
+Aquí pasan dos cosas que un componente no debería tener que conocer. La fusión
+propia de `app.config` que hace Nuxt **añade** los arrays en lugar de
+sustituirlos, así que quien la consume y sobrescribe `navigation` obtendría las
+entradas de la capa después de las suyas; esta fusión las sustituye. Y una
+etiqueta puede ser un literal, una clave i18n o un registro por locale — las
+tres se colapsan a una cadena, así que una plantilla nunca se encuentra con una
+clave.
+
+El resultado sigue vivo, así que cambiar de idioma actualiza la barra de
+navegación sin recargar la página entera.
diff --git a/docs/es/4.reference/6.composables/1.config-and-content/2.use-duxt-collection.md b/docs/es/4.reference/6.composables/1.config-and-content/2.use-duxt-collection.md
new file mode 100644
index 00000000..4b2c7134
--- /dev/null
+++ b/docs/es/4.reference/6.composables/1.config-and-content/2.use-duxt-collection.md
@@ -0,0 +1,25 @@
+---
+title: useDuxtCollection()
+description: Qué colección sirve esta ruta.
+icon: lucide:library
+---
+
+## Firma
+
+```ts
+const { collection, source, sources } = useDuxtCollection();
+```
+
+## Devuelve
+
+| Valor | Tipo | Qué es |
+| :----------- | :------- | :------------------------------------------------- |
+| `collection` | `string` | El nombre de la colección que sirve la ruta actual |
+| `source` | `object` | La fuente resuelta que hay detrás |
+| `sources` | `array` | Todas las fuentes resueltas |
+
+## Notas
+
+`collection` es una **cadena**, no un tipo literal — los nombres de colección de
+un sitio se generan a partir de sus propias fuentes, así que nada en la capa
+puede nombrarlos. Pásalo a `queryCollection` en lugar de escribir `'docs'`.
diff --git a/docs/es/4.reference/6.composables/1.config-and-content/3.use-duxt-navigation.md b/docs/es/4.reference/6.composables/1.config-and-content/3.use-duxt-navigation.md
new file mode 100644
index 00000000..113b7fce
--- /dev/null
+++ b/docs/es/4.reference/6.composables/1.config-and-content/3.use-duxt-navigation.md
@@ -0,0 +1,22 @@
+---
+title: useDuxtNavigation()
+description: El árbol de navegación de la colección que sirve esta ruta.
+icon: lucide:list-tree
+---
+
+## Firma
+
+```ts
+const { data: navigation } = await useDuxtNavigation();
+```
+
+## Devuelve
+
+Un manejador de `useAsyncData` sobre el árbol de navegación de la colección que
+sirve la ruta actual.
+
+## Notas
+
+Preguntar por ruta en lugar de una sola vez para todo el sitio es lo que hace
+correcto un árbol versionado: leer `v0.7.0` obtiene las páginas de esa versión,
+no las de la versión por defecto.
diff --git a/docs/es/4.reference/6.composables/1.config-and-content/4.use-duxt-section.md b/docs/es/4.reference/6.composables/1.config-and-content/4.use-duxt-section.md
new file mode 100644
index 00000000..e3f83d09
--- /dev/null
+++ b/docs/es/4.reference/6.composables/1.config-and-content/4.use-duxt-section.md
@@ -0,0 +1,23 @@
+---
+title: useDuxtSection()
+description: La parte de la documentación en la que está esta página.
+icon: lucide:folder-open
+---
+
+## Firma
+
+```ts
+const { section, items } = useDuxtSection(navigation);
+```
+
+## Devuelve
+
+| Valor | Tipo | Qué es |
+| :-------- | :------- | :----------------------------------------------- |
+| `section` | `object` | La sección configurada bajo la que cae la página |
+| `items` | `array` | La rama del árbol de esa sección |
+
+## Notas
+
+Sin `sections` configuradas vuelve el árbol entero, que es la forma correcta para
+una documentación que no está dividida en partes.
diff --git a/docs/es/4.reference/6.composables/1.config-and-content/5.use-duxt-breadcrumb.md b/docs/es/4.reference/6.composables/1.config-and-content/5.use-duxt-breadcrumb.md
new file mode 100644
index 00000000..df2b44a7
--- /dev/null
+++ b/docs/es/4.reference/6.composables/1.config-and-content/5.use-duxt-breadcrumb.md
@@ -0,0 +1,20 @@
+---
+title: useDuxtBreadcrumb()
+description: El rastro que va de la sección hasta una página.
+icon: lucide:chevrons-right
+---
+
+## Firma
+
+```ts
+const trail = useDuxtBreadcrumb(path);
+```
+
+## Devuelve
+
+Las entradas entre la sección y la página, cada una con su título y su ruta.
+
+## Notas
+
+Un grupo que no lleva página propia aparece en el rastro sin enlace — la carpeta
+es real, la página que hay detrás no.
diff --git a/docs/es/4.reference/6.composables/1.config-and-content/index.md b/docs/es/4.reference/6.composables/1.config-and-content/index.md
new file mode 100644
index 00000000..9430c2d9
--- /dev/null
+++ b/docs/es/4.reference/6.composables/1.config-and-content/index.md
@@ -0,0 +1,11 @@
+---
+title: Configuración y contenido
+description: La configuración fusionada, y qué colección sirve la página que se está leyendo.
+icon: lucide:database
+---
+
+Empieza aquí en un componente reemplazado: estos cinco responden a «qué dice
+este sitio» y «de qué forma parte esta página».
+
+::page-cards
+::
diff --git a/docs/es/4.reference/6.composables/2.paths-and-versions/1.use-duxt-path.md b/docs/es/4.reference/6.composables/2.paths-and-versions/1.use-duxt-path.md
new file mode 100644
index 00000000..7383d75a
--- /dev/null
+++ b/docs/es/4.reference/6.composables/2.paths-and-versions/1.use-duxt-path.md
@@ -0,0 +1,21 @@
+---
+title: useDuxtPath()
+description: La ruta en la que está la documentación, sin el locale.
+icon: lucide:milestone
+---
+
+## Firma
+
+```ts
+const path = useDuxtPath();
+```
+
+## Devuelve
+
+La ruta actual con el segmento de locale eliminado — la ruta tal como la conoce
+la documentación.
+
+## Notas
+
+Todo lo que razona sobre páginas razona sobre esto, no sobre `route.path`:
+`/de/duxt/getting-started` y `/duxt/getting-started` son la misma página.
diff --git a/docs/es/4.reference/6.composables/2.paths-and-versions/2.use-duxt-link.md b/docs/es/4.reference/6.composables/2.paths-and-versions/2.use-duxt-link.md
new file mode 100644
index 00000000..d494f2a6
--- /dev/null
+++ b/docs/es/4.reference/6.composables/2.paths-and-versions/2.use-duxt-link.md
@@ -0,0 +1,25 @@
+---
+title: useDuxtLink()
+description: Una ruta de documentación, escrita de vuelta como enlace.
+icon: lucide:link
+---
+
+El inverso de [`useDuxtPath()`](/reference/composables/paths-and-versions/use-duxt-path).
+
+## Firma
+
+```ts
+const localeLink = useDuxtLink();
+
+localeLink('/getting-started');
+```
+
+## Devuelve
+
+Una función que vuelve a poner el prefijo de locale en una ruta de
+documentación, y deja en paz una URL absoluta.
+
+## Notas
+
+Úsala para cada `to` que renderice un componente reemplazado. Una ruta escrita a
+mano en un componente es correcta en un idioma y errónea en los otros seis.
diff --git a/docs/es/4.reference/6.composables/2.paths-and-versions/3.use-duxt-version.md b/docs/es/4.reference/6.composables/2.paths-and-versions/3.use-duxt-version.md
new file mode 100644
index 00000000..8f14cb45
--- /dev/null
+++ b/docs/es/4.reference/6.composables/2.paths-and-versions/3.use-duxt-version.md
@@ -0,0 +1,31 @@
+---
+title: useDuxtVersion()
+description: Qué muestra el selector, qué dice el aviso, si indexar.
+icon: lucide:layers
+---
+
+## Firma
+
+```ts
+const version = useDuxtVersion();
+```
+
+## Devuelve
+
+| Valor | Qué responde |
+| :-------------- | :--------------------------------------------- |
+| `sources` | Todas las versiones del repositorio que se lee |
+| `current` | La versión a la que pertenece esta página |
+| `preferred` | La versión en la que debería estar un lector |
+| `isPreferred` | Si esas dos son la misma |
+| `status` | Su ciclo de vida — upcoming, current, eol … |
+| `kind` | Rama o etiqueta |
+| `shouldIndex` | Si la página pide ser indexada |
+| `shouldWarn` | Si se muestra el aviso |
+| `preferredPath` | Esta misma página, en la versión preferida |
+
+## Notas
+
+Tres preguntas desde un solo sitio — qué muestra el selector, qué dice el aviso
+y si la página pide ser indexada — porque son la misma pregunta sobre la misma
+versión.
diff --git a/docs/es/4.reference/6.composables/2.paths-and-versions/4.use-duxt-site-url.md b/docs/es/4.reference/6.composables/2.paths-and-versions/4.use-duxt-site-url.md
new file mode 100644
index 00000000..553579f5
--- /dev/null
+++ b/docs/es/4.reference/6.composables/2.paths-and-versions/4.use-duxt-site-url.md
@@ -0,0 +1,23 @@
+---
+title: useDuxtSiteUrl()
+description: El origen configurado, y rutas hechas absolutas.
+icon: lucide:globe
+---
+
+## Firma
+
+```ts
+const { origin, absolute } = useDuxtSiteUrl();
+```
+
+## Devuelve
+
+| Valor | Tipo | Qué es |
+| :--------- | :--------- | :--------------------------------- |
+| `origin` | `string` | El origen configurado del sitio |
+| `absolute` | `function` | Una ruta, hecha absoluta contra él |
+
+## Notas
+
+Necesario allí donde una URL sale de la página — un elemento de feed, una
+etiqueta canonical, una imagen OG. Dentro de la página, usa una ruta.
diff --git a/docs/es/4.reference/6.composables/2.paths-and-versions/index.md b/docs/es/4.reference/6.composables/2.paths-and-versions/index.md
new file mode 100644
index 00000000..34467825
--- /dev/null
+++ b/docs/es/4.reference/6.composables/2.paths-and-versions/index.md
@@ -0,0 +1,11 @@
+---
+title: Rutas y versiones
+description: Leer una URL, escribir otra de vuelta, y saber qué versión se está leyendo.
+icon: lucide:route
+---
+
+Una ruta de documentación y una URL no son la misma cadena: la URL lleva el
+locale, el repositorio y la versión. Estos cuatro son la costura entre ambas.
+
+::page-cards
+::
diff --git a/docs/es/4.reference/6.composables/3.reading/1.use-duxt-search.md b/docs/es/4.reference/6.composables/3.reading/1.use-duxt-search.md
new file mode 100644
index 00000000..db285e24
--- /dev/null
+++ b/docs/es/4.reference/6.composables/3.reading/1.use-duxt-search.md
@@ -0,0 +1,24 @@
+---
+title: useDuxtSearch()
+description: Búsqueda en todas las fuentes, con los resultados clasificados juntos.
+icon: lucide:search
+---
+
+## Firma
+
+```ts
+const { search, init, labelled } = useDuxtSearch();
+```
+
+## Devuelve
+
+| Valor | Qué es |
+| :--------- | :------------------------------------------------------------------- |
+| `search` | Ejecutar una consulta, en todas las fuentes |
+| `init` | Construir el índice; se llama cuando el diálogo abre por primera vez |
+| `labelled` | Resultados que llevan la fuente de la que vinieron |
+
+## Notas
+
+El índice se construye en el primer uso, no al cargar la página: un lector que
+nunca busca nunca paga por él.
diff --git a/docs/es/4.reference/6.composables/3.reading/2.use-fuzzy-search.md b/docs/es/4.reference/6.composables/3.reading/2.use-fuzzy-search.md
new file mode 100644
index 00000000..d2626f09
--- /dev/null
+++ b/docs/es/4.reference/6.composables/3.reading/2.use-fuzzy-search.md
@@ -0,0 +1,21 @@
+---
+title: useFuzzySearch()
+description: Tolerancia a erratas junto al índice exacto.
+icon: lucide:spell-check
+---
+
+## Firma
+
+```ts
+const { search } = useFuzzySearch(items);
+```
+
+## Devuelve
+
+Una función de búsqueda que puntúa las coincidencias aproximadas.
+
+## Notas
+
+Solo se ejecuta cuando la búsqueda exacta no devuelve nada, y la interfaz lo
+dice — un lector que cometió una errata debería obtener la página, pero también
+debería saber que eso fue exactamente lo que pasó.
diff --git a/docs/es/4.reference/6.composables/3.reading/3.use-active-heading.md b/docs/es/4.reference/6.composables/3.reading/3.use-active-heading.md
new file mode 100644
index 00000000..d94e7e20
--- /dev/null
+++ b/docs/es/4.reference/6.composables/3.reading/3.use-active-heading.md
@@ -0,0 +1,21 @@
+---
+title: useActiveHeading()
+description: El encabezado que está ahora a la vista.
+icon: lucide:heading
+---
+
+## Firma
+
+```ts
+const active = useActiveHeading(ids);
+```
+
+## Devuelve
+
+El id del encabezado en el que está el lector ahora mismo.
+
+## Notas
+
+Observa los encabezados en lugar del desplazamiento, así que una última sección
+corta se sigue anunciando como alcanzada — el fallo habitual de una tabla de
+contenidos que va por posición de scroll.
diff --git a/docs/es/4.reference/6.composables/3.reading/4.use-duxt-page-focus.md b/docs/es/4.reference/6.composables/3.reading/4.use-duxt-page-focus.md
new file mode 100644
index 00000000..0cf91437
--- /dev/null
+++ b/docs/es/4.reference/6.composables/3.reading/4.use-duxt-page-focus.md
@@ -0,0 +1,18 @@
+---
+title: useDuxtPageFocus()
+description: Mueve el foco al encabezado tras una navegación en el cliente.
+icon: lucide:focus
+---
+
+## Firma
+
+```ts
+useDuxtPageFocus();
+```
+
+## Notas
+
+Un cambio de ruta en el cliente reemplaza el documento sin mover el foco, así
+que un lector de pantalla sigue leyendo la página anterior y el tabulador
+continúa donde lo dejó la última página. Esto pone el foco en el encabezado de
+la página nueva.
diff --git a/docs/es/4.reference/6.composables/3.reading/5.use-recent-pages.md b/docs/es/4.reference/6.composables/3.reading/5.use-recent-pages.md
new file mode 100644
index 00000000..f8a59915
--- /dev/null
+++ b/docs/es/4.reference/6.composables/3.reading/5.use-recent-pages.md
@@ -0,0 +1,16 @@
+---
+title: useRecentPages()
+description: Las últimas cinco páginas que abrió este lector.
+icon: lucide:history
+---
+
+## Firma
+
+```ts
+const { recent, remember, load } = useRecentPages();
+```
+
+## Notas
+
+Se guarda en el navegador, no en una cookie: nunca hace falta durante el
+renderizado en servidor, así que no viaja con ninguna petición.
diff --git a/docs/es/4.reference/6.composables/3.reading/6.use-package-manager.md b/docs/es/4.reference/6.composables/3.reading/6.use-package-manager.md
new file mode 100644
index 00000000..b89326f9
--- /dev/null
+++ b/docs/es/4.reference/6.composables/3.reading/6.use-package-manager.md
@@ -0,0 +1,18 @@
+---
+title: usePackageManager()
+description: El gestor de paquetes que eligió un lector.
+icon: lucide:package
+---
+
+## Firma
+
+```ts
+const manager = usePackageManager();
+```
+
+## Notas
+
+Una cookie en lugar de almacenamiento del navegador, y a propósito: el
+almacenamiento se lee después de la hidratación, así que un bloque de comando
+representaría un gestor y luego cambiaría a otro delante del lector. Es propia,
+no identifica a nadie y lleva un valor que eligió el lector.
diff --git a/docs/es/4.reference/6.composables/3.reading/7.use-duxt-toast.md b/docs/es/4.reference/6.composables/3.reading/7.use-duxt-toast.md
new file mode 100644
index 00000000..416a2885
--- /dev/null
+++ b/docs/es/4.reference/6.composables/3.reading/7.use-duxt-toast.md
@@ -0,0 +1,23 @@
+---
+title: useDuxtToast()
+description: Notificaciones, envueltas para que ningún componente importe la biblioteca.
+icon: lucide:bell
+---
+
+## Firma
+
+```ts
+const notify = useDuxtToast();
+
+notify.success(t('duxt.code.copiedToast'));
+```
+
+## Devuelve
+
+`{ success, info, warning, error, loading, dismiss }`.
+
+## Notas
+
+La envoltura es la costura: quien la consume y quiere otro sistema de
+notificaciones sustituye este único composable en lugar de cada punto de
+llamada, y los componentes propios de la capa siguen funcionando.
diff --git a/docs/es/4.reference/6.composables/3.reading/8.use-duxt-shortcuts.md b/docs/es/4.reference/6.composables/3.reading/8.use-duxt-shortcuts.md
new file mode 100644
index 00000000..7b2bc757
--- /dev/null
+++ b/docs/es/4.reference/6.composables/3.reading/8.use-duxt-shortcuts.md
@@ -0,0 +1,20 @@
+---
+title: useDuxtShortcuts()
+description: Las teclas que escucha el tema.
+icon: lucide:keyboard
+---
+
+## Firma
+
+```ts
+const shortcuts = useDuxtShortcuts();
+
+onDuxtShortcut('k', () => open());
+```
+
+## Notas
+
+El único sitio que sabe qué teclas están asignadas, así que la superposición en
+`?` y los manejadores no pueden contradecirse. `onDuxtShortcut()`, a su lado,
+asigna una tecla con las guardas que necesita toda asignación — incluida la de
+no dispararse nunca mientras el lector está escribiendo.
diff --git a/docs/es/4.reference/6.composables/3.reading/index.md b/docs/es/4.reference/6.composables/3.reading/index.md
new file mode 100644
index 00000000..163bf22b
--- /dev/null
+++ b/docs/es/4.reference/6.composables/3.reading/index.md
@@ -0,0 +1,11 @@
+---
+title: Experiencia de lectura
+description: Búsqueda, encabezados, foco, historial y las decisiones que toma un lector.
+icon: lucide:book-open
+---
+
+Todo lo que hace que el sitio se comporte como un lugar en el que alguien lee y
+no como un conjunto de documentos.
+
+::page-cards
+::
diff --git a/docs/es/4.reference/6.composables/index.md b/docs/es/4.reference/6.composables/index.md
new file mode 100644
index 00000000..916f129d
--- /dev/null
+++ b/docs/es/4.reference/6.composables/index.md
@@ -0,0 +1,14 @@
+---
+title: Composables
+description: Las funciones que la capa expone a tus propios componentes, con una página cada una.
+icon: lucide:function-square
+---
+
+Se importan automáticamente, como todo composable de una capa Nuxt — sin línea
+de import.
+
+Cada página nombra la firma, qué devuelve y lo único que no resulta obvio a
+partir del tipo de retorno.
+
+::page-cards
+::
diff --git a/docs/es/4.reference/7.endpoints/1.llms-txt.md b/docs/es/4.reference/7.endpoints/1.llms-txt.md
new file mode 100644
index 00000000..118e3a83
--- /dev/null
+++ b/docs/es/4.reference/7.endpoints/1.llms-txt.md
@@ -0,0 +1,20 @@
+---
+title: /llms.txt
+description: La documentación como índice Markdown, según llmstxt.org.
+icon: lucide:file-text
+---
+
+Un índice del sitio, en Markdown, en la ruta que nombra la convención de
+llmstxt.org.
+
+## Respuesta
+
+`text/plain`. El título y la descripción del sitio, y después cada página como
+entrada de lista enlazada con su propia descripción.
+
+## Notas
+
+Se genera solo a partir de la versión por defecto. Un modelo al que se le
+entregan todas las versiones de cada página tiene que averiguar cuál es la
+actual, y la respuesta a esa pregunta es un dato de tiempo de compilación que el
+sitio ya conoce.
diff --git a/docs/es/4.reference/7.endpoints/2.llms-full-txt.md b/docs/es/4.reference/7.endpoints/2.llms-full-txt.md
new file mode 100644
index 00000000..f94121bf
--- /dev/null
+++ b/docs/es/4.reference/7.endpoints/2.llms-full-txt.md
@@ -0,0 +1,20 @@
+---
+title: /llms-full.txt
+description: Las mismas páginas, enteras, en un único archivo Markdown.
+icon: lucide:file-stack
+---
+
+Cada página de la versión por defecto, entera, concatenada.
+
+## Respuesta
+
+`text/plain`, un único documento.
+
+## Notas
+
+El índice y el texto completo responden a dos preguntas distintas: un agente que
+puede descargar sigue [`/llms.txt`](/reference/endpoints/llms-txt) página a
+página, y uno que no puede se lleva el conjunto entero de una vez.
+
+El frontmatter se elimina de cada página — lo que se sirve se lee como la
+documentación y no como los archivos de los que vino.
diff --git a/docs/es/4.reference/7.endpoints/3.page-markdown.md b/docs/es/4.reference/7.endpoints/3.page-markdown.md
new file mode 100644
index 00000000..b99dceb8
--- /dev/null
+++ b/docs/es/4.reference/7.endpoints/3.page-markdown.md
@@ -0,0 +1,21 @@
+---
+title: .md
+description: El código fuente de cualquier página, en su propia URL más .md.
+icon: lucide:file-code
+---
+
+Añade `.md` a cualquier URL de la documentación y la respuesta es el Markdown a
+partir del cual se representó esa página.
+
+```
+/duxt/getting-started → la página
+/duxt/getting-started.md → su código fuente
+```
+
+## Notas
+
+Es un **middleware, no una ruta**, porque `.md` es un sufijo del último segmento
+y no un segmento propio — un archivo de ruta no puede coincidir con él.
+
+Esto es lo que [`DuxtCopyPage`](/reference/components/page/duxt-copy-page) le
+entrega a un modelo: el código fuente, no la página representada.
diff --git a/docs/es/4.reference/7.endpoints/4.mcp.md b/docs/es/4.reference/7.endpoints/4.mcp.md
new file mode 100644
index 00000000..5d3418e5
--- /dev/null
+++ b/docs/es/4.reference/7.endpoints/4.mcp.md
@@ -0,0 +1,22 @@
+---
+title: /mcp
+description: Un servidor MCP sobre el mismo contenido.
+icon: lucide:bot
+---
+
+Un endpoint de Model Context Protocol, para que un agente pueda consultar la
+documentación en lugar de hacerle scraping.
+
+## Herramientas
+
+| Herramienta | Qué hace |
+| :------------ | :------------------------------------ |
+| `list_pages` | El índice de contenidos |
+| `search_docs` | Encontrar una página por término |
+| `read_page` | El texto completo de una página |
+
+## Notas
+
+Las mismas colecciones que las páginas, así que las respuestas no pueden
+desviarse de lo publicado. El nombre, la descripción y las instrucciones del
+servidor vienen de las opciones del módulo.
diff --git a/docs/es/4.reference/7.endpoints/5.sitemap-and-robots.md b/docs/es/4.reference/7.endpoints/5.sitemap-and-robots.md
new file mode 100644
index 00000000..2261dce8
--- /dev/null
+++ b/docs/es/4.reference/7.endpoints/5.sitemap-and-robots.md
@@ -0,0 +1,21 @@
+---
+title: /sitemap.xml y /robots.txt
+description: Hacia dónde se apunta a un rastreador, y de qué se le mantiene fuera.
+icon: lucide:search
+---
+
+## Respuesta
+
+`/sitemap.xml` lista las páginas de la versión por defecto, por locale, con
+alternativas `hreflang`. `/robots.txt` se genera junto a él y apunta al sitemap.
+
+## Notas
+
+Solo se lista la versión por defecto. Las versiones anteriores siguen siendo
+accesibles y llevan `canonical` más `noindex` — que un resultado de búsqueda
+deje a un lector en la documentación del año pasado es el fallo que este par
+existe para evitar, y el
+[aviso de versión](/reference/components/controls/duxt-version-banner) es su
+mitad visible.
+
+Una versión marcada como `eol` abandona el sitemap por completo.
diff --git a/docs/es/4.reference/7.endpoints/6.rss-xml.md b/docs/es/4.reference/7.endpoints/6.rss-xml.md
new file mode 100644
index 00000000..414d82af
--- /dev/null
+++ b/docs/es/4.reference/7.endpoints/6.rss-xml.md
@@ -0,0 +1,18 @@
+---
+title: /rss.xml
+description: El feed, una vez que se nombra una sección.
+icon: lucide:rss
+---
+
+## Respuesta
+
+`application/rss+xml`, construido a partir de la sección que nombra
+`duxt.feed.path`.
+
+## Notas
+
+Responde **vacío en lugar de 404** antes de estar configurado: a un cliente de
+feeds hay que decirle que aquí no hay nada, no que el feed ha desaparecido.
+
+Desactivado por defecto, porque un feed es una lista de cosas que pasaron, y una
+página de referencia que se está editando no es un acontecimiento.
diff --git a/docs/es/4.reference/7.endpoints/7.devtools.md b/docs/es/4.reference/7.endpoints/7.devtools.md
new file mode 100644
index 00000000..085440dc
--- /dev/null
+++ b/docs/es/4.reference/7.endpoints/7.devtools.md
@@ -0,0 +1,27 @@
+---
+title: /_duxt/devtools
+description: La ruta desde la que se sirve el panel de Nuxt Devtools. Solo en desarrollo.
+icon: lucide:wrench
+---
+
+::callout{type="danger" title="Nunca registrado en una compilación"}
+El panel expone la configuración resuelta, las rutas del sistema de archivos que
+hay detrás y un botón que borra un directorio de caché. Nada de eso es asunto de
+nadie en producción, y por eso el módulo retorna antes de registrar nada fuera de
+un servidor de desarrollo.
+::
+
+## Respuesta
+
+`text/html`, un documento por panel: `/_duxt/devtools` es el panel Sources y cada
+uno de los demás cuelga de él — `/_duxt/devtools/paths`, `/_duxt/devtools/pages` y
+así, en el orden en que los muestra la fila de pestañas.
+
+Esos mismos documentos son los que Nuxt Devtools enmarca en su pestaña **duxt**,
+así que la ruta existe para poder abrirse en una ventana propia — una tabla ancha
+se lee mejor fuera de un cajón.
+
+## Notas
+
+Qué responde cada panel, con cada panel mostrado tal como se ve, es una sección
+propia: [Devtools](/reference/devtools).
diff --git a/docs/es/4.reference/7.endpoints/index.md b/docs/es/4.reference/7.endpoints/index.md
new file mode 100644
index 00000000..671bcfcb
--- /dev/null
+++ b/docs/es/4.reference/7.endpoints/index.md
@@ -0,0 +1,14 @@
+---
+title: Endpoints
+description: Todo lo que el sitio sirve además de las páginas, una página por cada uno.
+icon: lucide:route
+---
+
+Todo ello se genera a partir de las mismas colecciones desde las que se
+representan las páginas, así que nada de ello puede desviarse de lo publicado.
+
+::page-cards
+::
+
+Todo lo que lee un modelo, y por qué hay dos archivos llms, está en
+[Lectores automáticos](/concepts/machine-readers).
diff --git a/docs/es/4.reference/8.devtools/1.sources.md b/docs/es/4.reference/8.devtools/1.sources.md
new file mode 100644
index 00000000..6c7bbe9e
--- /dev/null
+++ b/docs/es/4.reference/8.devtools/1.sources.md
@@ -0,0 +1,42 @@
+---
+title: Fuentes
+description: En qué se convirtió `duxt.sources` — una colección por fuente, versión e idioma.
+icon: lucide:library
+---
+
+El primer panel que abrir, porque todo lo demás de la pestaña está aguas abajo de
+él. Una fila por cada **fuente resuelta**: lo que quien la consume escribió como
+un puñado de entradas, expandido en las colecciones que Content generó realmente
+y el prefijo de URL bajo el que sirve cada una.
+
+::devtools-panel{tab="sources"}
+::
+
+## Columnas
+
+| Columna | Qué dice |
+| :------------------ | :------------------------------------------------------------------- |
+| `Prefix` | El segmento de URL bajo el que se sirve esta colección; `/` para la que no tiene prefijo |
+| `Collection` | El nombre de colección generado, tal como lo toma `queryCollection` |
+| `Repository` | De dónde vienen las páginas, enlazado cuando se conoce |
+| `Folder` | La `path` dentro de ese repositorio |
+| `Ref` | La rama o etiqueta, y cuál de las dos es |
+| `Version` | El slug de la URL, `default` cuando se sirve sin segmento |
+| `Status` | `current`, `deprecated` o `eol`, directamente desde la fuente |
+| `Reserved segments` | Nombres de carpeta dentro de esta fuente que un prefijo ya ha reclamado |
+
+## Notas
+
+**Un segmento de repositorio solo aparece cuando hay más de un repositorio**, y un
+segmento de versión solo cuando hay más de una ref — un segmento que puede
+contener un único valor no distingue nada. `sourceOptions.showRepo` y
+`showVersion` los vuelven a forzar.
+
+**Los segmentos reservados son la trampa por la que existe esta columna.** Si una
+fuente se sirve bajo `/sdk` y su carpeta de documentación contiene un directorio
+`v1-9/`, gana el prefijo y esa carpeta queda inalcanzable. El panel
+[Comprobaciones](/reference/devtools/checks) lo informa como error; esta columna
+muestra qué nombres están cogidos antes de que ocurra.
+
+El pie nombra el archivo del que se leyó la lista — útil en un sitio donde
+`app.config.ts` no está donde esperabas.
diff --git a/docs/es/4.reference/8.devtools/10.redirects.md b/docs/es/4.reference/8.devtools/10.redirects.md
new file mode 100644
index 00000000..f805105f
--- /dev/null
+++ b/docs/es/4.reference/8.devtools/10.redirects.md
@@ -0,0 +1,38 @@
+---
+title: Redirecciones
+description: Cada redirección que sirve el servidor, y cuáles escribió la capa.
+icon: lucide:signpost
+---
+
+`redirectFrom` en el frontmatter de una página se convierte en route rules, una por
+cada prefijo bajo el que se sirve la página. Este panel lee esas reglas **del
+servidor en marcha** en lugar de recalcularlas: una regla generada y una regla
+servida son afirmaciones distintas, y solo la segunda redirige a alguien.
+
+::devtools-panel{tab="redirects"}
+::
+
+## Written by
+
+Leer las reglas de vuelta significa leer las de todos los módulos, no solo las de
+la capa — `@nuxtjs/sitemap` añade `/sitemap.xml` en cualquier sitio con más de un
+sitemap. Por eso cada fila dice quién la escribió:
+
+| Etiqueta | Significado |
+| :--------------- | :------------------------------------------------------------ |
+| `redirectFrom` | Generada por duxt desde el frontmatter de una página |
+| `another module` | La route rule de otro, servida por el mismo servidor |
+
+Nada en una route rule registra a su autor, así que la capa anota junto a ellas las
+rutas que generó, y el panel marca el resto.
+
+## Notas
+
+**Una regla por prefijo.** Una URL antigua estaba guardada bajo exactamente uno del
+segmento de repositorio, el de versión y el de idioma — y nada dice cuál —, así que
+se redirige cada combinación.
+
+Una página que redirige a sí misma se salta en vez de servirse: eso es un bucle, no
+una redirección.
+
+Mover una página tiene su propia guía: [Mover una página](/guides/move-a-page).
diff --git a/docs/es/4.reference/8.devtools/2.paths.md b/docs/es/4.reference/8.devtools/2.paths.md
new file mode 100644
index 00000000..4a295e6a
--- /dev/null
+++ b/docs/es/4.reference/8.devtools/2.paths.md
@@ -0,0 +1,42 @@
+---
+title: Rutas
+description: Pega una URL, mira qué fuente la reclama y qué encuentra ahí.
+icon: lucide:route
+---
+
+El depurador de rutas, y el panel en el que se encuentran la mayoría de errores
+de versión. Cada uno de los que ha tenido esta capa fue un desacuerdo entre el
+aspecto de una URL y qué fuente la reclama — `/workflows/v0.7.0` devolviendo un
+404, `/workflows/v0.7.0/v0.7.0` generándose. Ambos eran aritmética sobre prefijos
+que nada imprimía.
+
+::devtools-panel{tab="paths"}
+::
+
+## Los pasos
+
+1. **Se elimina el segmento de idioma.** `/de/sdk/guides/caching` es la página
+ `/sdk/guides/caching` en alemán — el idioma traduce la interfaz, nunca el
+ árbol de contenido, así que toda búsqueda en el tema se refiere a la ruta ya
+ despojada.
+2. **Gana el prefijo coincidente más largo.** `/sdk/v1-9` y `/sdk` coinciden
+ ambos con una página bajo el primero; quedarse con el más corto es como una
+ etiqueta acaba representando `main`.
+3. **Se consulta la colección que la reclama** por esa ruta exacta.
+4. **Se busca la misma página en las fuentes hermanas** — las demás versiones e
+ idiomas del mismo repositorio, nunca de uno distinto.
+
+## Cómo leer una ausencia
+
+Una página que falta aquí y está presente en otra columna es el caso del
+**recurso**: el selector de versión la ofrece, y al lector se le envía al
+ancestro más cercano en lugar de a un 404.
+
+Una página que falta en todas partes es una errata, y el panel lista entonces las
+rutas más cercanas de la colección — las que comparten la secuencia inicial más
+larga con lo que pegaste.
+
+::callout{type="tip"}
+Pega la URL exactamente como la muestra el navegador, con segmento de idioma y
+todo. El panel se encarga de despojarla, y verlo suceder es media respuesta.
+::
diff --git a/docs/es/4.reference/8.devtools/3.pages.md b/docs/es/4.reference/8.devtools/3.pages.md
new file mode 100644
index 00000000..f6c5223c
--- /dev/null
+++ b/docs/es/4.reference/8.devtools/3.pages.md
@@ -0,0 +1,42 @@
+---
+title: Páginas
+description: Todas las páginas de todas las colecciones, con su frontmatter y sus metadatos de git.
+icon: lucide:files
+---
+
+Con qué acabó Content, un bloque por colección. Este es el panel que responde a
+«¿está mi página siquiera en la compilación?» — y, más a menudo, a «¿por qué esta
+se ve distinta de las demás?».
+
+::devtools-panel{tab="pages"}
+::
+
+## Columnas
+
+| Columna | Qué dice |
+| :------------- | :------------------------------------------------------------------ |
+| `Path` | La URL, prefijo incluido — lo que almacena `queryCollection` |
+| `Title` | Del frontmatter; `missing` es una advertencia, no una suposición |
+| `Description` | Si existe alguna; alimenta las tarjetas, la imagen OG y llms.txt |
+| `Icon` | El nombre de Iconify que hay en el frontmatter |
+| `Last updated` | Del historial de git, no del mtime del archivo |
+| `Contributors` | A cuántas personas nombra el historial |
+| `File` | Abre el archivo fuente en tu editor |
+
+## Notas
+
+**Una colección sin historial alguno** suele ser una fuente remota sin
+`history: true`. Content clona con `--depth 1`, así que la copia de trabajo tiene
+un único commit y parecería que todas las páginas las escribió quien cortó la
+punta — la capa prefiere ningún dato a un dato equivocado.
+
+**Las colecciones más allá de la primera están plegadas.** Un sitio con cinco
+versiones tiene cinco tablas casi idénticas, y las cinco desplegadas son una
+página que nadie lee.
+
+**El filtro aparece encima de una tabla de más de doce filas.** Oculta las filas
+que no coinciden en ninguna parte de la fila, cuenta lo que queda, y una recarga
+lo devuelve todo.
+
+**Una colección vacía es un hallazgo, no una tabla vacía** — toda URL bajo ese
+prefijo es un 404, y el panel lo dice donde habrían estado las filas.
diff --git a/docs/es/4.reference/8.devtools/4.versions.md b/docs/es/4.reference/8.devtools/4.versions.md
new file mode 100644
index 00000000..ee4bcc86
--- /dev/null
+++ b/docs/es/4.reference/8.devtools/4.versions.md
@@ -0,0 +1,39 @@
+---
+title: Versiones
+description: Qué versión — y qué idioma — lleva qué página.
+icon: lucide:columns-3
+---
+
+No hay nada más que enseñe esto. El trabajo entero del recurso es el hueco entre
+dos columnas de esta cuadrícula, y sin él la única manera de encontrar uno es
+abrir una URL y ver un 404.
+
+::devtools-panel{tab="versions"}
+::
+
+## Dos ejes, no uno
+
+Una traducción no es una versión, y compararlas como si lo fueran dibuja una
+cuadrícula de columnas idénticas. Por eso el panel mantiene fijo un eje cada vez:
+
+- **Versiones de un idioma** — `v2` frente a `v1.9`, ambas en inglés.
+- **Idiomas de una versión** — `en` frente a `de`, ambos en `v2`.
+
+Un sitio con dos versiones en dos idiomas obtiene, por tanto, cuatro
+cuadrículas, cada una respondiendo a una pregunta. Un repositorio publicado una
+sola vez, en un solo idioma, no obtiene ninguna: una columna sola no compara
+nada.
+
+Los repositorios nunca se mezclan. `/sdk/guides` y `/cli/guides` son dos
+proyectos que casualmente comparten el nombre de una carpeta, y ponerlos en una
+misma cuadrícula inventaría un hueco que no lo es.
+
+## Cómo leerla
+
+`✓` significa que la colección tiene esa página; `·` significa que no. El `*`
+junto a un encabezado marca la que se sirve sin segmento propio — la versión por
+defecto, o el idioma que vive en la propia carpeta de la fuente.
+
+Un `·` en una columna de idioma es una página pendiente de traducir. Un `·` en
+la columna de una versión más antigua suele ser correcto: la guía todavía no
+existía.
diff --git a/docs/es/4.reference/8.devtools/5.checks.md b/docs/es/4.reference/8.devtools/5.checks.md
new file mode 100644
index 00000000..f75f10ca
--- /dev/null
+++ b/docs/es/4.reference/8.devtools/5.checks.md
@@ -0,0 +1,39 @@
+---
+title: Comprobaciones
+description: El validador de compilación, ejecutado contra lo que el sitio sirve ahora.
+icon: lucide:check-check
+---
+
+Las mismas comprobaciones que ejecuta `pnpm build`, desde el mismo código — la
+gracia no es una segunda opinión, sino un sitio donde los hallazgos se queden
+quietos. Un aviso impreso durante una compilación ya se ha ido con el scroll
+para cuando alguien lo lee.
+
+::devtools-panel{tab="checks"}
+::
+
+## Qué comprueba
+
+Los **errores** son cosas que dejan una URL inalcanzable:
+
+- una **colección sin páginas**, cuyo prefijo entero es un 404;
+- una **carpeta con nombre de segmento de repositorio o de versión**, a la que
+ el prefijo tapa por completo.
+
+Los **avisos** son cosas que degradan una página en silencio:
+
+- **sin `title` o sin `description`** — el índice de contenidos, las tarjetas, la
+ imagen OG y llms.txt los leen todos;
+- un **enlace a una página que nadie sirve**, resuelto exactamente como lo
+ resuelve `ProseA` al representar: primero bajo el prefijo propio de la fuente,
+ después a secas;
+- un **enlace a un ancla que esa página no tiene**.
+
+## Notas
+
+Los hallazgos nombran su archivo entre comillas, y el panel convierte el primero
+en un enlace que te abre el editor ahí.
+
+Los enlaces se comprueban **solo si son internos**. Una URL externa no le
+corresponde verificarla a esta compilación, y comprobarla metería la red dentro
+de la compilación.
diff --git a/docs/es/4.reference/8.devtools/6.search.md b/docs/es/4.reference/8.devtools/6.search.md
new file mode 100644
index 00000000..c72e4045
--- /dev/null
+++ b/docs/es/4.reference/8.devtools/6.search.md
@@ -0,0 +1,29 @@
+---
+title: Búsqueda
+description: Qué contiene el índice de búsqueda, a través de la misma consulta que ejecuta el diálogo.
+icon: lucide:search
+---
+
+La búsqueda tuvo tres fallos distintos — un filtro peleándose con otro filtro,
+resultados que eran fragmentos de encabezado, celdas de tabla pegadas sin
+separadores — y los tres eran invisibles, porque nadie podía ver las secciones a
+partir de las que se construye el índice.
+
+::devtools-panel{tab="search"}
+::
+
+## Cómo leerlo
+
+**Una fila por encabezado**, no por página. Eso es lo que guarda el índice y lo
+que el diálogo vuelve a agrupar en páginas, así que una página con ocho
+encabezados son ocho filas.
+
+La columna de **texto indexado** es exactamente contra lo que casa FTS5. Si ahí
+falta una palabra, ninguna consulta la encontrará, por muy correcta que parezca
+la página.
+
+::callout{type="tip"}
+FTS5 casa términos y prefijos, no subcadenas: `caching` encuentra `caching`,
+`cach` también lo encuentra, y `aching` no encuentra nada. Una búsqueda que
+vuelve vacía suele ser esto, no una página que falte.
+::
diff --git a/docs/es/4.reference/8.devtools/7.config.md b/docs/es/4.reference/8.devtools/7.config.md
new file mode 100644
index 00000000..bea99c36
--- /dev/null
+++ b/docs/es/4.reference/8.devtools/7.config.md
@@ -0,0 +1,43 @@
+---
+title: Configuración
+description: La configuración de `duxt` fusionada, y de qué lado de la fusión vino cada valor.
+icon: lucide:sliders-horizontal
+---
+
+Cada clave que lee el tema, con una insignia que dice de dónde vino. «Por qué
+sigue la entrada de duxt en mi barra de navegación» se resuelve con un vistazo
+aquí en vez de con una sesión entera.
+
+::devtools-panel{tab="config"}
+::
+
+## Las insignias
+
+| Insignia | Significado |
+| :--------- | :--------------------------------------------------------------- |
+| `consumer` | Lo puso tu `app.config.ts` |
+| `layer` | Es el valor por defecto de la capa, sin tocar |
+| `build` | Lo calculó la compilación y no está escrito en ninguna parte |
+
+## La regla de fusión
+
+**Los arrays se reemplazan enteros, los objetos se fusionan clave a clave.** El
+`navigation` de quien la consume es la navegación — las entradas de la capa
+desaparecen. Un `footer.note` de quien la consume deja `footer.columns` en paz.
+
+No es lo que hace Nuxt con `app.config` por su cuenta: defu concatena los
+arrays, lo que le daría a un sitio sus propias entradas más las de la capa sin
+manera de quitar las nuestras. Así que la capa no envía ninguna lista en
+`app.config` y las fusiona ella misma.
+
+## Notas
+
+**Los valores estructurados se abren.** Una fila que contiene un objeto o una
+lista muestra un recuento y una vista previa de una línea; al pulsarla imprime
+el valor entero, indentado. Se queda en la página mientras está plegada, así que
+el filtro sigue encontrando una fila por cualquier cadena que haya dentro —
+incluida una única traducción de una etiqueta.
+
+`resolvedSources` es la única clave que no escribe nadie: es el manifiesto que
+la compilación calculó a partir de `sources`, y todos los demás paneles de esta
+pestaña lo leen.
diff --git a/docs/es/4.reference/8.devtools/8.i18n.md b/docs/es/4.reference/8.devtools/8.i18n.md
new file mode 100644
index 00000000..9c946575
--- /dev/null
+++ b/docs/es/4.reference/8.devtools/8.i18n.md
@@ -0,0 +1,36 @@
+---
+title: i18n
+description: Claves de mensaje por idioma, frente al idioma base.
+icon: lucide:languages
+---
+
+Siete idiomas y un archivo JSON por espacio de nombres hacen que la deriva sea
+cuestión de tiempo — y vue-i18n responde a una clave que falta imprimiendo la
+clave, así que un hueco aflora como un botón etiquetado `duxt.nav.open` en una
+página que nadie abrió en ese idioma.
+
+::devtools-panel{tab="i18n"}
+::
+
+## Columnas
+
+| Columna | Qué dice |
+| :---------------- | :----------------------------------------------------------------- |
+| `Directory` | Una carpeta bajo `i18n/locales`; `base` marca la de comparación |
+| `Keys` | Cuántas claves define en total |
+| `Missing vs base` | Claves que tiene la base y esta no |
+| `Not in base` | Claves que tiene esta y la base no — normalmente un renombrado |
+
+## Notas
+
+**Faltar es un hecho, no un veredicto.** Un archivo de idioma puede contener a
+propósito solo sus desviaciones: `pt-BR` sobrescribe un puñado de cadenas y lee el
+resto de `pt` por el fallback al idioma base, así que una lista larga en «missing»
+ahí es el diseño funcionando.
+
+**«Not in base» se lee dos veces.** Una clave que no tiene nadie más suele ser una
+errata o un renombrado que se quedó a medias, y se imprimirá ella misma en la
+página.
+
+La línea sobre la tabla lista los idiomas que el sitio sirve de verdad, que es una
+lista distinta de las carpetas en disco: varios idiomas pueden compartir una.
diff --git a/docs/es/4.reference/8.devtools/9.cache.md b/docs/es/4.reference/8.devtools/9.cache.md
new file mode 100644
index 00000000..60dbfdb0
--- /dev/null
+++ b/docs/es/4.reference/8.devtools/9.cache.md
@@ -0,0 +1,32 @@
+---
+title: Cache
+description: La caché de descargas de Content, y una forma de tirar una entrada.
+icon: lucide:hard-drive
+---
+
+Cada fuente remota se clona en `.data/content` y se queda ahí. Nada en una
+compilación menciona ese directorio, así que nada lo sugiere cuando algo va mal.
+
+::devtools-panel{tab="cache"}
+::
+
+## Por qué importa
+
+Un directorio obsoleto costó aquí tiempo real una vez: se pidió una etiqueta como
+si fuera una rama, la descarga falló, y la carpeta a medio escribir siguió
+respondiendo para la configuración ya corregida hasta que alguien la borró a mano.
+La compilación no dijo nada, porque para ella la fuente ya estaba descargada.
+
+**Drop** elimina una entrada; la siguiente compilación la descarga de nuevo.
+
+## Notas
+
+Content indexa un directorio por repositorio **y** ref, así que una entrada de una
+ref que has renombrado no se reutiliza nunca — y tampoco se limpia nunca. Este
+panel es donde se encuentran.
+
+::callout{type="warning"}
+Tirar una entrada borra un directorio de tu disco. Siempre es solo la caché —
+nunca una fuente de verdad —, pero es un borrado real, y es la razón por la que
+esta pestaña no se registra en una compilación.
+::
diff --git a/docs/es/4.reference/8.devtools/index.md b/docs/es/4.reference/8.devtools/index.md
new file mode 100644
index 00000000..bc4a7bdf
--- /dev/null
+++ b/docs/es/4.reference/8.devtools/index.md
@@ -0,0 +1,48 @@
+---
+title: Devtools
+description: Diez paneles que muestran en qué se convirtieron tus fuentes. Solo en desarrollo.
+icon: lucide:wrench
+---
+
+`sources` es una lista compacta. Lo que el sitio sirve es un conjunto de
+colecciones, prefijos de URL, redirecciones, catálogos de mensajes y una caché de
+descargas que la compilación calculó a partir de ella — y hasta que existió esta
+pestaña, la única forma de ver algo de eso era leer el código que lo produce.
+
+## Cómo abrirlo
+
+Arranca el servidor de desarrollo y abre Nuxt Devtools (`Shift` + `Alt` + `D`, o
+el botón de la esquina). La pestaña se llama **duxt**; los paneles están detrás
+de la fila de pestañas de su parte superior. Las mismas páginas responden
+directamente bajo `/_duxt/devtools` si prefieres tenerlas en su propia ventana.
+
+::callout{type="danger" title="Nunca se registra en una compilación"}
+Los paneles exponen la configuración resuelta, las rutas del sistema de archivos
+que hay detrás y un botón que borra un directorio de caché. Nada de eso le
+incumbe a nadie en producción, y por eso `modules/devtools.ts` retorna antes de
+registrar nada fuera de un servidor de desarrollo — la ruta no existe en una
+compilación, en lugar de existir y negarse.
+::
+
+## Los paneles
+
+::page-cards
+::
+
+## Sobre las vistas previas de estas páginas
+
+Cada panel de aquí abajo está incrustado tal como se representa — no como una
+captura de pantalla. Las páginas ejecutan las propias funciones de renderizado de
+los paneles sobre un sitio de prueba y guardan el resultado, así que un panel que
+gana una columna la gana en esta documentación en el mismo commit.
+
+Ese sitio de prueba es un proyecto imaginado: `acme/sdk` publicado en dos
+versiones (`v2` desde `main`, `v1.9` desde una etiqueta) en inglés y alemán, más
+`acme/cli` en una. Tiene huecos deliberados — una guía anterior a la versión
+antigua, dos páginas que la traducción no ha alcanzado, una página sin
+frontmatter — porque un panel sin nada que informar no le enseña a nadie qué
+aspecto tiene cuando algo va mal.
+
+Las vistas previas son inertes: sus pestañas se mueven entre paneles, y todo lo
+demás — los enlaces al editor, el botón de borrado, el formulario de búsqueda —
+no hace nada.
diff --git a/docs/es/4.reference/index.md b/docs/es/4.reference/index.md
new file mode 100644
index 00000000..ca2c871a
--- /dev/null
+++ b/docs/es/4.reference/index.md
@@ -0,0 +1,13 @@
+---
+title: Referencia
+description: La superficie pública de la capa — claves de configuración, frontmatter, componentes, composables y endpoints.
+icon: lucide:list
+---
+
+Lo que quien la consume puede escribir, invocar o sobrescribir. **Un nombre
+documentado aquí forma parte de la superficie pública**: renombrarlo es un
+cambio incompatible de la capa, que es lo que hace que valga la pena depender de
+estas páginas.
+
+::page-cards
+::
diff --git a/docs/es/5.credits/index.md b/docs/es/5.credits/index.md
new file mode 100644
index 00000000..93ad0c48
--- /dev/null
+++ b/docs/es/5.credits/index.md
@@ -0,0 +1,66 @@
+---
+title: Créditos
+description: Sobre qué está construido duxt, y en qué se fijó para construirse.
+icon: lucide:heart
+---
+
+duxt es una capa fina sobre el trabajo de otras personas. Casi nada de lo que
+hace es invención propia — la obtención de las fuentes, el análisis, los
+componentes y los estilos vienen todos de proyectos que resolvieron esos
+problemas primero, y la descripción honesta de este repositorio es el pegamento
+entre ellos más un puñado de opiniones.
+
+## Construido sobre
+
+| Proyecto | Qué hace aquí |
+| :-------------------------------------------------- | :------------------------------------------------------------------------- |
+| [Vue](https://vuejs.org) | El modelo de componentes en el que está escrito todo esto |
+| [Nuxt](https://nuxt.com) | El framework, y el mecanismo de capas sobre el que se asienta toda la idea |
+| [Nuxt Content](https://content.nuxt.com) | Obtención, análisis y consulta — incluidos los repositorios nativos de git |
+| [shadcn-vue](https://www.shadcn-vue.com) | La base de componentes, copiada dentro de la capa en vez de importada |
+| [reka-ui](https://reka-ui.com) | Las primitivas que hay debajo: foco, roving tabindex, ARIA |
+| [Tailwind CSS](https://tailwindcss.com) | El sistema de estilos y la capa de tokens |
+| [Shiki](https://shiki.style) | Resaltado de sintaxis, en tiempo de compilación |
+| [Lucide](https://lucide.dev) | El conjunto de iconos |
+| [MDC](https://content.nuxt.com/docs/files/markdown) | Componentes invocables desde Markdown |
+
+La versión de cada uno está en `package.json`, que es donde le corresponde estar
+a un número — una segunda copia en prosa es una copia que se queda obsoleta en
+silencio.
+
+## Construido mirando a
+
+Ideas que duxt tomó de proyectos sobre los que no está construido. Sobre lo que
+*sí* está construido es la tabla de arriba — nada se nombra dos veces.
+
+- [**shadcn/ui**](https://ui.shadcn.com) — la idea original: componentes que
+ posees como archivos en lugar de importar como dependencia. Cada componente de
+ esta capa está aquí por ella.
+- [**shadcn-docs-nuxt**](https://shadcn-docs-nuxt.vercel.app) — el vecino más
+ cercano, y la prueba de que una plantilla de documentación sobre Nuxt Content
+ y shadcn-vue merece la pena.
+- [**Docus**](https://docus.dev) — la ergonomía original de «extiende una capa y
+ tienes un sitio de documentación» en el ecosistema de Nuxt.
+- [**Nuxt UI**](https://ui.nuxt.com) — por la parte legible por máquinas:
+ `llms.txt` y un endpoint de documentación que un agente puede llamar, tratados
+ como salida de la compilación y no como un añadido.
+- [**VitePress**](https://vitepress.dev) y
+ [**Starlight**](https://starlight.astro.build) — por lo que un tema de
+ documentación le debe a un lector por defecto: un selector de versiones que
+ sobrevive a la navegación, una tabla de contenidos que sigue el
+ desplazamiento, una búsqueda que está ahí sin configuración.
+
+::callout{type="tip" title="En qué se diferencia duxt"}
+Todos los proyectos de arriba documentan un repositorio en una versión. La razón
+de existir de duxt empieza donde eso termina: varios repositorios, varias
+versiones de cada uno, y una sola lista `sources` que genera las colecciones de
+todos ellos — véase [Fuentes](/concepts/sources).
+::
+
+## Lo que no se acredita aquí
+
+Faltan dos cosas a propósito. Las dependencias de la propia capa están listadas
+en `package.json` y no necesitan una segunda copia mantenida a mano; y quienes
+escribieron una página determinada aparecen nombrados en esa página, a partir
+del historial git que hay detrás, y no en una lista aquí que quedaría obsoleta
+en el siguiente commit.
diff --git a/docs/es/6.conventions/1.branding.md b/docs/es/6.conventions/1.branding.md
new file mode 100644
index 00000000..b95b73fe
--- /dev/null
+++ b/docs/es/6.conventions/1.branding.md
@@ -0,0 +1,85 @@
+---
+title: Identidad
+description: La marca [d]uxt — dónde se usa cada recurso, por qué se han redibujado los corchetes del icono y qué colores son fijos.
+icon: lucide:shapes
+---
+
+::callout{type="warning" title="Ilustración provisional"}
+El logotipo es trabajo provisional asistido por IA — diseñado en una sesión con un
+agente de programación con IA, no por una persona diseñadora, y un sustituto que
+se reemplazará en algún momento, sin plazo fijo. No intervino ningún generador de
+imágenes: está compuesto tipográficamente, no dibujado.
+::
+
+La marca es el nombre del paquete con su primera letra entre corchetes — `[d]uxt` —
+porque así es como se escribe el paquete allí donde se usa:
+
+```ts
+extends: ['@kirchdev/duxt'];
+```
+
+Un array con una entrada. Está compuesta en [IBM Plex Mono](https://github.com/IBM/plex)
+SemiBold y convertida a contornos, de modo que ningún recurso depende de que la
+tipografía esté instalada en ninguna parte.
+
+## Los recursos
+
+| Recurso | Ruta | Lo usa |
+| :------------------- | :----------------------------------------------------------- | :-------------------------------- |
+| Logotipo | `.github/assets/wordmark-light.svg`, `wordmark-dark.svg` | El hero del README, vía `` |
+| Iconos del sitio | `www/public/favicon.svg`, `apple-touch-icon.png` (180×180) | La pestaña del navegador |
+| Logotipo en el sitio | `www/public/wordmark.svg`, `wordmark-dark.svg` | `duxt.logo` en `www/` |
+
+El logotipo del README estrecha las celdas de los corchetes en 120 unidades
+tipográficas: Plex Mono le da a `[` una celda de 600 unidades para 311 unidades de
+tinta, y a ancho monoespaciado completo los corchetes quedan en un espacio de
+palabra propio.
+
+El favicon lleva su propia regla `prefers-color-scheme`, para que ambos colores se
+aclaren contra una franja de pestañas oscura. El PNG existe solo porque iOS ignora
+los iconos SVG.
+
+## Los corchetes del icono están redibujados; la `d`, no
+
+El corchete de Plex mide 311 unidades frente a una `d` de 486, y a 16 px ese
+reparto cierra el ojal hasta convertirlo en una mancha. El icono conserva el asta
+(104) y el brazo (88) de la tipografía, pero acorta el brazo a 185, lo que
+cuadra la marca de 1,45∶1 a 1,13∶1 y le da al ojal los píxeles que necesita.
+
+El logotipo no tiene esa restricción y sigue siendo Plex puro. Esa es toda la
+diferencia entre los dos archivos y la razón por la que no pueden generarse desde
+una sola fuente.
+
+## Colores
+
+Los corchetes llevan el color; las letras toman el color del texto circundante.
+Solo el par de corchetes es fijo.
+
+| Función | Hex | OKLCH |
+| :------------------ | :-------- | :----------------------- |
+| Corchetes (claro) | `#4F46E5` | `oklch(0.511 0.230 277)` |
+| Corchetes (oscuro) | `#7D7BF5` | `oklch(0.644 0.178 281)` |
+| Letras (claro) | `#15171E` | `oklch(0.206 0.014 273)` |
+| Letras (oscuro) | `#E8EBF2` | `oklch(0.940 0.010 267)` |
+
+## Licencias
+
+IBM Plex está bajo [SIL OFL 1.1](https://github.com/IBM/plex/blob/master/LICENSE.txt),
+y la cláusula 5 resuelve este caso explícitamente: _"The requirement for fonts to
+remain under this license does not apply to any document created using the Font
+Software."_ Un SVG con contornos es uno de esos documentos, no se redistribuye
+ningún archivo de fuente y el nombre reservado "Plex" no aparece en ningún
+recurso. No hay que pagar, atribuir ni relicenciar nada — la mención de arriba es
+cortesía.
+
+## Nada de esto se publica
+
+::callout{type="warning" title="La capa no lleva identidad"}
+`duxt.logo` no está definido por defecto, y `DuxtBrand` recae en el propio
+`duxt.title` del consumidor junto a un icono genérico. Un sitio que extiende duxt
+muestra su propio nombre en la cabecera y el pie y su propio icono en la pestaña,
+nunca este. Todos los recursos de arriba pertenecen a este repositorio y a `www/`
+— ninguno está en el paquete publicado.
+::
+
+Definir la tuya es [Dale identidad a tu sitio](/guides/brand-your-site).
diff --git a/docs/es/6.conventions/index.md b/docs/es/6.conventions/index.md
new file mode 100644
index 00000000..209dec2d
--- /dev/null
+++ b/docs/es/6.conventions/index.md
@@ -0,0 +1,18 @@
+---
+title: Convenciones
+description: Reglas propias del proyecto — las que no se deducen del código.
+icon: lucide:ruler
+---
+
+Reglas que este proyecto se impone, escritas porque el código no puede
+enunciarlas. Vinculan el trabajo dentro de este repositorio; nada de lo que hay
+aquí restringe un sitio que extienda la capa.
+
+Una regla dice qué se hace; una [decisión de arquitectura](/adr) dice por qué se
+eligió frente a las alternativas, y sigue siendo legible cuando el razonamiento ya
+no está en la cabeza de nadie. El registro de decisiones es de solo adición — por
+eso queda al lado de estas páginas y no entre ellas: estas se editan cuando
+cambian.
+
+::page-cards
+::
diff --git a/docs/es/99.adr/0001-build-duxt-as-a-layer-carrying-a-module.md b/docs/es/99.adr/0001-build-duxt-as-a-layer-carrying-a-module.md
new file mode 100644
index 00000000..99a15835
--- /dev/null
+++ b/docs/es/99.adr/0001-build-duxt-as-a-layer-carrying-a-module.md
@@ -0,0 +1,63 @@
+---
+title: Construir duxt como una capa que lleva un módulo
+description: Distribuir el tema como una capa de Nuxt cuya raíz es el paquete, y no como una plantilla de inicio.
+status: accepted
+date: 2026-09-06
+---
+
+## Contexto
+
+Un tema de documentación se puede distribuir de dos maneras. Una **plantilla de
+inicio** se genera dentro del repositorio de quien la consume, donde cada archivo
+es suyo para editarlo y ninguna mejora vuelve a llegarle nunca salvo como un diff
+que alguien aplica a mano. Una **capa** sigue siendo una dependencia: quien la
+consume la extiende, sobrescribe los archivos con los que no está de acuerdo y se
+lleva el resto de las mejoras con un cambio de versión.
+
+Parte de lo que el tema tiene que hacer no es algo que una capa pueda expresar
+como archivos. Generar colecciones, resolver una lista de fuentes en prefijos de
+URL, convertir el frontmatter en reglas de ruta y validar el resultado es trabajo
+de tiempo de compilación, y el trabajo de tiempo de compilación en Nuxt es un
+módulo.
+
+## Decisión
+
+Distribuimos duxt como una **capa de Nuxt que lleva sus propios módulos**, que se
+consume con una sola entrada `extends`. La raíz del repositorio *es* la capa: la
+configuración de Nuxt, la configuración de content y `app/` están en la raíz, y
+el manifiesto del paquete apunta a ellos, así que `extends: ['@kirchdev/duxt']`
+se resuelve sin paso de compilación. El sitio que la consume vive a su lado, en
+el mismo repositorio, y es el objetivo de desarrollo.
+
+## Consecuencias
+
+Quien la consume hereda el tema, las páginas, los componentes, los valores por
+defecto de la configuración y las colecciones, y sobrescribe cualquiera de ellos
+creando un archivo del mismo nombre. Actualizar es un cambio de versión.
+
+Nada relativo a la capa se resuelve como se lee. Una ruta escrita en la capa se
+lee desde el directorio de quien la consume, salvo que se haya resuelto contra la
+ubicación de la propia capa, y el alias `@` pertenece a quien extiende la capa,
+no a la capa — así que las importaciones de la propia capa necesitan un alias
+propio. Esto ha costado errores reales, y es el precio del montaje, no un
+descuido.
+
+Los nombres sobrescribibles se convierten en superficie pública. Un componente,
+una página o una clave de configuración que quien la consume puede sobrescribir
+es un nombre del que depende, así que renombrar uno es una publicación
+incompatible, y la superficie documentada es lo que lo fija.
+
+El sitio de desarrollo y una plantilla de inicio son artefactos distintos. El
+sitio que hay junto a la capa quiere casos límite, frontmatter feo, varias
+fuentes y una etiqueta de la que leer; alguien de fuera que clona una plantilla
+quiere lo contrario. Confundir los dos haría malo a uno de ellos.
+
+## Alternativas consideradas
+
+**Una plantilla de inicio.** Libertad total para quien la consume, ningún camino
+de actualización para nadie — la razón por la que ganó la capa.
+
+**Un módulo sin capa.** Un módulo puede registrar componentes y rutas, pero la
+sustancia del tema son archivos que quien la consume tiene que poder
+sobrescribir, y entregarlos a través de un módulo significa inyectarlos en vez
+de dejar que lo haga la propia resolución de capas de Nuxt.
diff --git a/docs/es/99.adr/0002-generate-the-collections-from-one-source-list.md b/docs/es/99.adr/0002-generate-the-collections-from-one-source-list.md
new file mode 100644
index 00000000..b9cc0851
--- /dev/null
+++ b/docs/es/99.adr/0002-generate-the-collections-from-one-source-list.md
@@ -0,0 +1,58 @@
+---
+title: Generar las colecciones a partir de una sola lista de fuentes
+description: Calcular las colecciones de Content a partir de una lista de fuentes compacta al cargar la configuración, en vez de hacer que quien la consume las declare.
+status: accepted
+date: 2026-09-06
+---
+
+## Contexto
+
+Nuxt Content obtiene una colección desde un repositorio git en una rama o una
+etiqueta, se autentica contra uno privado y cachea la descarga por hash. Eso se
+verificó leyendo el propio Content antes de construir nada de esto, y significa
+que la mitad difícil de la documentación versionada y multirrepositorio ya
+existía y no había que reconstruirla.
+
+Lo que no existía era la ergonomía. Un sitio que sirve varias versiones de varios
+proyectos declara una colección por repositorio × ref, a mano: tres versiones en
+catorce repositorios son cuarenta y dos declaraciones, y cada publicación edita
+las catorce. Content tampoco ofrece ningún hook para inyectar colecciones — pero
+carga la configuración de content de cada capa mediante c12, lo que significa que
+ese archivo es código ejecutado y no un archivo de datos, y puede calcular sus
+colecciones al cargarse.
+
+## Decisión
+
+Quien la consume declara una **lista de fuentes** compacta — una carpeta,
+opcionalmente un repositorio, opcionalmente refs — en el `app.config` del propio
+sitio, y la configuración de content de la capa calcula a partir de ella una
+colección por fuente × ref al cargarse. La misma lista la resuelve una segunda
+vez la compilación, en un manifiesto que nombra qué colección sirve qué prefijo
+de URL, y ese manifiesto es lo que lee el tema.
+
+## Consecuencias
+
+La lista tiene la longitud del número de proyectos y no del producto de proyectos
+por versiones, y la única carpeta sin versionar no necesita configuración alguna.
+
+El atajo expresa menos que una colección escrita a mano, y siempre lo hará. Eso
+solo es soportable porque Content combina la configuración de content de cada
+capa y gana la posterior: quien necesite algo que el atajo no sepa decir escribe
+su propio archivo y toma el control por completo.
+
+Varias funciones dejan de necesitar configuración propia, porque una fuente ya
+nombra un repositorio, una ref y una carpeta — los enlaces de vuelta a la fuente,
+la fecha de última actualización y la lista de personas que han contribuido se
+derivan de ahí.
+
+Los nombres de colección se vuelven datos. Un sitio con dos repositorios no tiene
+ninguna colección llamada `docs`, así que nada en la capa puede nombrar una, y el
+tema lee el nombre del manifiesto en su lugar. El código que fija a fuego un
+nombre de colección funciona en un sitio de una sola fuente y se rompe en todos
+los demás.
+
+Las rutas de las colecciones se resuelven contra la capa y no contra quien la
+consume, porque Content registra como raíz de una colección la capa que la
+declaró. Por eso la capa calcula rutas absolutas, lo cual solo es posible porque
+la configuración es código ejecutado — la misma propiedad sobre la que se asienta
+todo el atajo.
diff --git a/docs/es/99.adr/0003-decide-the-url-prefixes-at-build-time.md b/docs/es/99.adr/0003-decide-the-url-prefixes-at-build-time.md
new file mode 100644
index 00000000..2719b7e5
--- /dev/null
+++ b/docs/es/99.adr/0003-decide-the-url-prefixes-at-build-time.md
@@ -0,0 +1,51 @@
+---
+title: Decidir los prefijos de URL en tiempo de compilación
+description: Que aparezca un segmento de repositorio o de versión lo decide la lista de fuentes antes de la primera petición, nunca por petición.
+status: accepted
+date: 2026-09-06
+---
+
+## Contexto
+
+Un sitio construido a partir de varias fuentes tiene que servir un segmento de
+repositorio y un segmento de versión en sus URL, y uno construido a partir de una
+sola carpeta no debe hacerlo — nadie quiere `/my-project/main/guides/deploying`
+para un proyecto con una única carpeta de documentación sin versionar.
+
+Hacer cada segmento opcional por petición no funciona. Con ambos opcionales, el
+primer segmento de `/guides/…` podría ser una carpeta, un repositorio o una
+versión, y solo consultando los tres se sabría cuál. Esa ambigüedad no es un
+inconveniente de enrutado; hace que el significado de una URL dependa de lo que
+resulte existir.
+
+## Decisión
+
+Cada prefijo se activa **para todo el sitio, en tiempo de compilación, a partir
+de la lista de fuentes**: un segmento de repositorio en cuanto se publica más de
+un repositorio o lo fuerza un interruptor, y un segmento de versión en cuanto una
+fuente publica más de una ref o lo fuerza un interruptor. Una ref por repositorio
+se sirve sin segmento de versión alguno.
+
+## Consecuencias
+
+La forma de cada URL queda fijada antes de la primera petición, así que el
+enrutador nunca adivina y un enlace escrito en una página lo puede resolver una
+comprobación de la compilación en vez de probarlo.
+
+Una única carpeta sin versionar sirve rutas que no delatan en absoluto que
+existan repositorios o versiones, que es lo que hace que el caso más simple salga
+gratis.
+
+Convertir un sitio de una fuente en uno de dos cambia todas las URL que sirve.
+Eso es una migración, y la maquinaria de redirecciones es lo que la hace
+soportable.
+
+Queda una colisión que no se puede eliminar por diseño: una carpeta de
+documentación llamada como un repositorio o como un segmento de versión, donde
+gana el prefijo y la carpeta queda inalcanzable. La compilación la rechaza con un
+mensaje en vez de resolverla en silencio hacia un lado.
+
+Como el prefijo de una página solo lo conoce la compilación, los enlaces dentro
+de las páginas se escriben como rutas de documentación desnudas y se resuelven
+contra la fuente de la página al representarla. Una página que fija a fuego su
+propio prefijo es correcta exactamente en un sitio.
diff --git a/docs/es/99.adr/0004-render-markdown-components-with-mdc.md b/docs/es/99.adr/0004-render-markdown-components-with-mdc.md
new file mode 100644
index 00000000..b1ffcf53
--- /dev/null
+++ b/docs/es/99.adr/0004-render-markdown-components-with-mdc.md
@@ -0,0 +1,49 @@
+---
+title: Renderizar los componentes de Markdown con MDC
+description: Usar la sintaxis MDC propia de Content para los componentes dentro de Markdown en lugar de adoptar MDX.
+status: accepted
+date: 2026-09-06
+---
+
+## Contexto
+
+Un tema de documentación necesita componentes dentro de la prosa — callouts,
+código en pestañas, campos de parámetros, árboles. Había dos sintaxis
+disponibles. **MDX** compila Markdown en un módulo de componente y deja que una
+página importe y escriba JSX; **MDC** es la sintaxis de componentes en bloque y
+en línea que Content ya trae y parsea.
+
+Las páginas también las lee algo que no es un navegador. El tema publica el
+Markdown fuente de cada página para los modelos, así que un modelo recibe
+exactamente la sintaxis en la que están escritas las páginas.
+
+## Decisión
+
+Los componentes en Markdown son **MDC**. Los componentes invocables desde una
+página viven en un directorio de contenido propio, donde un archivo del mismo
+nombre de quien la consume reemplaza al de la capa.
+
+## Consecuencias
+
+No hay que instalar ni configurar nada para que una página invoque un
+componente, y una página sigue siendo un archivo Markdown en lugar de
+convertirse en un módulo.
+
+Un bloque MDC sobrevive a que se le entregue a un modelo como texto: se lee como
+una llamada a un componente con argumentos con nombre. JSX compilado no.
+
+La sintaxis es la de Content, así que sus capacidades y sus límites también son
+los de Content. Todo lo que una página quiera y MDC no pueda expresar hay que
+resolverlo como componente en lugar de como expresión en la página — lo cual es
+una restricción para quien escribe y, para un sitio de documentación, una
+deseable.
+
+Los nombres de los componentes del directorio de contenido forman parte de la
+superficie pública, ya que una página escrita contra uno de ellos es un archivo
+de quien la consume.
+
+## Alternativas consideradas
+
+**MDX.** Más expresivo, y toda esa expresividad es JavaScript en una página de
+documentación. Además necesitaría un módulo y una ruta de compilación que
+Content no tiene, para acabar con páginas que se le entregan peor a un modelo.
diff --git a/docs/es/99.adr/0005-ship-the-layer-without-owner-specific-links.md b/docs/es/99.adr/0005-ship-the-layer-without-owner-specific-links.md
new file mode 100644
index 00000000..8b09aa80
--- /dev/null
+++ b/docs/es/99.adr/0005-ship-the-layer-without-owner-specific-links.md
@@ -0,0 +1,45 @@
+---
+title: Publicar la capa sin enlaces específicos del propietario
+description: Todo valor por defecto que nombraría un proyecto concreto — repositorio, gestor de incidencias, comunidad, aviso legal — se entrega vacío.
+status: accepted
+date: 2026-09-06
+---
+
+## Contexto
+
+Un tema de documentación dibuja varias filas de enlaces: enlaces de icono en la
+barra de navegación, un bloque de comunidad junto a la tabla de contenidos,
+enlaces legales en el pie, botones en la página de inicio. Rellenarlos con el
+proyecto del propio tema hace que un sitio de demostración parezca terminado —
+y le pone en la mano a todo el que la consume un botón de «dale una estrella a
+este repositorio» que se la da al trabajo de otra persona, una comunidad que no
+es la suya y un aviso legal que en su caso es jurídicamente incorrecto.
+
+La fila legal del pie decidió el principio primero, porque un sitio alemán tiene
+que mostrar un aviso legal y está claro que no le corresponde a la plantilla
+proporcionarlo.
+
+## Decisión
+
+Todo valor por defecto que nombraría un proyecto o una organización concretos se
+entrega **vacío**. Los enlaces del propio tema viven en la configuración del
+sitio que la consume, donde son un ejemplo y no un valor por defecto. El armazón
+genérico que no nombra a nadie — un encabezado de columna, una acción «lee la
+documentación» — se queda en la capa.
+
+## Consecuencias
+
+Quien extiende la capa siendo ajeno al proyecto recibe una fila vacía en lugar
+de una equivocada, y una fila vacía falta a la vista mientras que un enlace
+equivocado parece correcto.
+
+El sitio de demostración lleva más configuración de la que necesita un consumo
+mínimo de la capa, y esa es precisamente su función: es el ejemplo resuelto de
+cada fila que quien la consume tiene que rellenar.
+
+Las claves de mensaje de la propia capa se quedan cuando sus enlaces se van, y
+esas claves son internas. Quien la consume y meta mano en ellas dependería de un
+nombre que puede renombrarse sin una versión mayor, y una clave ausente se
+imprime como la clave — así que la rotura llegaría a un lector antes que a una
+compilación. Quien la consume escribe sus propias cadenas; la [página de
+configuración](/getting-started/configuration) dice cómo.
diff --git a/docs/es/99.adr/0006-rebuild-on-a-schedule-rather-than-refresh-at-runtime.md b/docs/es/99.adr/0006-rebuild-on-a-schedule-rather-than-refresh-at-runtime.md
new file mode 100644
index 00000000..656a862d
--- /dev/null
+++ b/docs/es/99.adr/0006-rebuild-on-a-schedule-rather-than-refresh-at-runtime.md
@@ -0,0 +1,38 @@
+---
+title: Recompilar según un calendario en vez de refrescar en tiempo de ejecución
+description: Un sitio que lee otro repositorio recoge sus cambios cuando compila, y no se ofrece ningún refresco en tiempo de ejecución.
+status: accepted
+date: 2026-09-06
+---
+
+## Contexto
+
+Un sitio que lee documentación de otros repositorios tiene un deseo evidente:
+recoger un push sin desplegar. Content, sin embargo, descarga un repositorio
+remoto durante la compilación y lo compila en la base de datos que la
+compilación entrega. Refrescar eso en un servidor en marcha significaría
+reconstruir la base de datos in situ — para lo cual Content no ofrece ninguna
+vía soportada, y la única disponible es la misma base de datos de cliente que
+lee la búsqueda.
+
+## Decisión
+
+Content se lee en tiempo de compilación y nunca se refresca en tiempo de
+ejecución. Un sitio cuyas fuentes han avanzado se recompila: según un
+calendario, o disparado desde el repositorio de origen.
+
+## Consecuencias
+
+Un despliegue es inmutable y una compilación es reproducible — las páginas
+servidas son exactamente las páginas que se compilaron, y el mismo commit
+produce el mismo sitio. La salida estática sigue siendo posible, que es lo
+correcto por defecto para la documentación.
+
+La documentación va por detrás de su fuente lo que dure el intervalo de
+recompilación, y un repositorio de origen que quiera su documentación en vivo
+tiene que disparar la compilación del sitio. Ese es el coste, y se paga en
+operaciones y no en la capa.
+
+Nada en el tema puede dar por supuesto que puede releer una fuente. Una
+funcionalidad que quiera contenido más fresco del que tiene la compilación está
+pidiendo otra arquitectura, no un ajuste.
diff --git a/docs/es/99.adr/0007-serve-translations-as-collections-of-their-own.md b/docs/es/99.adr/0007-serve-translations-as-collections-of-their-own.md
new file mode 100644
index 00000000..6236829d
--- /dev/null
+++ b/docs/es/99.adr/0007-serve-translations-as-collections-of-their-own.md
@@ -0,0 +1,103 @@
+---
+title: Servir las traducciones como colecciones propias
+description: Añadir una dimensión de locale a una fuente en lugar de un segmento de locale a la ruta de contenido.
+status: accepted
+date: 2026-09-06
+---
+
+## Contexto
+
+La capa traducía su interfaz a siete locales y servía un único conjunto de
+páginas a todos ellos: `useDuxtPath()` quitaba el segmento de locale antes de
+cada consulta de contenido, así que `/de-DE/guides/deploying` y
+`/guides/deploying` resolvían al mismo archivo. Content v3 no tiene noción de
+locale — una colección es un árbol — así que las páginas traducidas necesitaban
+una decisión y no un ajuste.
+
+Lo que hacen los generadores comparables se leyó en lugar de suponerse.
+Starlight, VitePress, Docusaurus y MkDocs ponen todos las traducciones en una
+carpeta por idioma; solo Starlight tiene un recurso documentado para una página
+que le falta a un idioma. Pasado cierto tamaño, la traducción abandona la
+herramienta por completo: React lleva `de.react.dev` como repositorio propio, y
+Vue una organización `vuejs-translations` entera, porque quienes traducen
+trabajan con su propio calendario y su propia revisión. OpenCode construyó un
+agente que traducía su documentación en CI, lo ejecutó y lo apagó; diecisiete
+idiomas llevan parados desde entonces.
+
+El coste se midió antes de elegir la forma: las compilaciones sobre 1 … 200
+colecciones escalan de forma lineal, a razón de unos 2,2 s y 0,63 MB de base de
+datos cada una, sin punto de inflexión. La matriz no tiene un techo que fuerce
+la mano del diseño.
+
+## Decisión
+
+Una fuente gana una lista `locales`, y una ref también, resuelta como ya lo está
+`status` (`ref.locales ?? source.locales`). Una cadena es una carpeta dentro del
+`path` de la fuente; un objeto mueve ese idioma a su propia carpeta, repositorio
+o ref.
+
+**El locale por defecto es el árbol de `path` mismo, sin carpeta**, así que
+añadir la clave no mueve ninguna URL que un sitio ya sirva.
+
+**El locale no forma parte de la ruta de contenido.** Pertenece al enrutado de
+i18n, que de todos modos lo pone delante de la ruta. Original y traducción viven
+por tanto bajo rutas de contenido idénticas, en colecciones separadas.
+
+Una página que le falta a un idioma recae por una cadena — el locale, su idioma
+base, una región hermana, el `fallbackLocale` de vue-i18n, el original sin
+traducir — y al lector se le dice, en un aviso, qué idioma se le está mostrando.
+
+## Consecuencias
+
+Ninguna comparación de rutas del tema cambia: la navegación, las redirecciones,
+el rastro, la puntuación de la página más próxima del 404 y el selector de
+idioma siguen funcionando sobre una ruta que nunca llevó locale. El recurso es
+una consulta más para la misma ruta, en lugar de una redirección o un segundo
+esquema de resolución.
+
+`useDuxtNavigation` y la búsqueda siguen a `useDuxtCollection`, así que ambos
+pasaron a tener en cuenta el locale sin que hubiera que cambiarlos.
+
+Un sitio que no define nada obtiene exactamente lo que tenía: una colección
+llamada `docs`, una entrada en el manifiesto, una consulta por página.
+
+Ahora dos configuraciones pueden discrepar de una forma que produce una página
+vacía en lugar de un error — `content.config.ts` resuelve el locale por defecto
+sin acceso a la configuración de Nuxt. Por eso el módulo duxt comprueba
+`sourceOptions.defaultLocale` contra `i18n.defaultLocale` y falla la compilación
+cuando difieren, en lugar de inyectar uno en el otro y dejar a Content
+calculando la otra respuesta.
+
+Las traducciones multiplican colecciones, y la compilación paga por cada una de
+forma lineal. La cifra pertenece a la documentación, porque quien la consume
+decide la matriz.
+
+Los fragmentos siguen a las páginas. `_partials/` es una colección POR IDIOMA,
+nombrada como las colecciones de páginas — `duxt_partials` para el original,
+`duxt_partials_de` a su lado — y `:partial{name}` recorre la misma cadena de
+respaldo que recorrió la página. Tiene que ser la misma cadena: que una página y
+los bloques que incluye caigan en idiomas distintos es justo como se acaba con
+una página traducida a medias sin que nada lo indique.
+
+La primera versión de esta decisión compartía UNA colección de fragmentos sin
+traducir entre todos los idiomas. Era defendible mientras nada traducía un
+fragmento, y dejó de serlo en el momento en que una pasada de traducción produjo
+`docs/de/_partials/`: archivos que ninguna colección leía, y una página alemana
+que representaba una nota de instalación en inglés sin señal alguna de que
+hubiera ocurrido.
+
+## Alternativas consideradas
+
+**Un segmento de locale en el prefijo de contenido.** Simétrico con `repo` y
+`version`, y habría obligado a toda comparación de rutas del tema a aprender
+sobre locales — para una URL que i18n ya prefija, y que entonces se escribiría
+dos veces.
+
+**Un sufijo de archivo — `installation.de-DE.md` junto al original.** Sin
+multiplicación de colecciones, y falla justo en el caso que los proyectos
+grandes tienen de verdad: obliga a que la traducción esté en el mismo
+repositorio y en la misma ref que el original.
+
+**404 para una traducción ausente.** Lo que hace VitePress por omisión. Castiga
+al lector por un hueco que dejó quien escribe, y esconde a todos los demás que
+la traducción está incompleta.
diff --git a/docs/es/99.adr/0008-build-the-theme-on-owned-shadcn-vue-components.md b/docs/es/99.adr/0008-build-the-theme-on-owned-shadcn-vue-components.md
new file mode 100644
index 00000000..7e5e342d
--- /dev/null
+++ b/docs/es/99.adr/0008-build-the-theme-on-owned-shadcn-vue-components.md
@@ -0,0 +1,73 @@
+---
+title: Construir el tema sobre componentes shadcn-vue propios
+description: La capa guarda el código fuente de sus componentes de interfaz en lugar de importarlos de una biblioteca de componentes o de un tema de documentación ya hecho.
+status: accepted
+date: 2026-09-08
+---
+
+## Contexto
+
+Una capa de documentación es sobre todo interfaz: una cabecera, una barra
+lateral, una tabla de contenidos, bloques de código, un diálogo de búsqueda. Algo
+tiene que dibujarlos, y la elección decide cuánto puede cambiar un consumidor sin
+bifurcar.
+
+Toda la propuesta de la capa es que se extiende en lugar de generarse —
+`extends: ['@kirchdev/duxt']`, y todos los archivos siguen siendo
+sobrescribibles. Un tema cuya apariencia solo fuera alcanzable mediante las
+opciones que su autor pensó exponer contradiría eso en la primera cosa que un
+consumidor quisiera distinta. La resolución de capas de Nuxt ya da al consumidor
+la sobrescritura a nivel de archivo — la pregunta era qué debían ser esos
+archivos.
+
+## Decisión
+
+La capa posee el código fuente de sus componentes. shadcn-vue se usa como fuente
+de las primitivas: su CLI escribe el código de un componente en el repositorio, y
+a partir de ahí el archivo pertenece a la capa y no a una dependencia.
+`components.json` apunta el CLI al alias propio de la capa, así que añadir una
+primitiva es una sola orden, y Tailwind aporta el estilo por debajo.
+
+La paleta son propiedades personalizadas de CSS. Los componentes leen tokens y no
+guardan colores, que es lo que hace que una sobrescritura de una línea alcance
+todas las superficies.
+
+## Consecuencias
+
+Un consumidor puede cambiar cualquier parte de la interfaz, a la profundidad que
+el cambio requiera: redefinir un token, ensombrecer un componente por su nombre o
+añadir una primitiva propia. Nada de eso exige una bifurcación, y nada espera a
+que la capa exponga una opción para ello.
+
+El coste es el mantenimiento. Una corrección de una primitiva no llega con una
+subida de versión — llega cuando alguien vuelve a ejecutar el CLI para ese
+componente. La capa lo asume por las primitivas que entrega; un consumidor que
+sobrescribe una lo asume a partir de entonces. En torno a ese canje está
+construida la guía de sobrescritura, y es la razón de que exista.
+
+Los archivos copiados que el CLI no alcanza son el filo de ese mismo canje: una
+hoja de estilos sin entrada de registro solo puede actualizarse descargándola de
+nuevo, así que los cambios locales se pierden en silencio en vez de chocar
+ruidosamente.
+
+## Alternativas consideradas
+
+**Un tema de documentación ya hecho.** Docus es el encaje evidente — el tema
+propio de Nuxt Content, y todo lo que una web de documentación necesita al primer
+arranque. Se descartó por la misma razón por la que resulta atractivo: el sitio
+que produce es el de su autor, y remodelarlo significa o una opción que existe o
+una bifurcación. Una capa cuyos consumidores debían diferenciarse entre sí no
+podía aceptar ese techo.
+
+**Una biblioteca de componentes como dependencia.** Nuxt UI habría aportado las
+primitivas sin el mantenimiento, y la capa habría seguido sus versiones en lugar
+de copiar código. Se descartó porque los componentes de una dependencia solo
+pueden cambiarse hasta donde sus props lo permiten, y la capa habría tenido
+entonces que inventar un segundo mecanismo de sobrescritura para el resto — con
+dos, ninguno habría sido el evidente al que recurrir.
+
+**Componentes escritos a mano, sin upstream alguno.** Esto elimina la cuestión de
+la dependencia por completo y se descartó por su coste: las primitivas accesibles
+son difíciles justo en los puntos donde es fácil equivocarse, y un diálogo o un
+combobox escritos desde cero serían peores que uno adaptado de una fuente que ya
+lo resolvió.
diff --git a/docs/es/99.adr/0009-take-the-seo-stack-from-the-nuxt-seo-bundle.md b/docs/es/99.adr/0009-take-the-seo-stack-from-the-nuxt-seo-bundle.md
new file mode 100644
index 00000000..10410086
--- /dev/null
+++ b/docs/es/99.adr/0009-take-the-seo-stack-from-the-nuxt-seo-bundle.md
@@ -0,0 +1,94 @@
+---
+title: Tomar la base de SEO del paquete Nuxt SEO
+description: La capa instala @nuxtjs/seo y le cede las etiquetas de head y los datos estructurados que antes escribía a mano, conservando solo las reglas que dependen de versiones y traducciones.
+status: accepted
+date: 2026-09-08
+---
+
+## Contexto
+
+La capa ya distribuía tres de los módulos de Nuxt SEO — robots, sitemap e imagen
+OG —, elegidos uno a uno según fue apareciendo cada necesidad. Todo lo que ellos
+no cubren estaba escrito a mano: un enlace canónico, un bloque `og:`/`twitter:`
+por página y un `@graph` de JSON-LD montado dentro de una plantilla de cadena en
+`[...slug].vue`.
+
+Funcionaba y era invisible. Ahí está el problema: nada de ello estaba cubierto
+por una prueba, porque el SEO vive en el HTML renderizado y no en la lógica, y
+las pruebas de este repositorio cubren deliberadamente solo lógica pura. La
+página de inicio — la que más probablemente se comparte — no tenía tarjeta social
+alguna, la página de error era indexable y siete idiomas se publicaban sin un
+solo `og:locale`. Cada una de esas carencias era una omisión que nadie podía ver.
+
+La mitad escrita a mano era además la que crece. Los datos estructurados son una
+especificación de superficie amplia y con validadores propios; cada nodo añadido
+a mano es un nodo cuya forma hay que acertar leyendo la especificación.
+
+## Decisión
+
+La capa depende de `@nuxtjs/seo` y lo carga como un solo módulo, en el lugar que
+ocupaban los tres módulos nombrados — antes de `@nuxt/content`, porque así lo
+exige la integración de la sitemap con Content.
+
+El paquete es un alias, no un envoltorio: su propia documentación afirma que «no
+contiene lógica propia». Lo que aporta son los cuatro módulos que faltaban —
+`nuxt-schema-org` para el grafo, `nuxt-seo-utils` para el canónico automático y
+las etiquetas sociales derivadas, `nuxt-link-checker`, y `nuxt-site-config` como
+el único lugar del que se lee `site.url` — más el panel compartido de devtools,
+que informa sobre los que estén instalados.
+
+Se desactivan tres de los valores por defecto de `nuxt-seo-utils`, cada uno por
+una razón que la capa no puede eludir por diseño: `canonicalLowercase`, porque un
+prefijo de idioma distingue mayúsculas y `/de-DE/` no es `/de-de/`;
+`fallbackTitle`, porque un título inventado a partir de un slug taparía el
+validador de compilación que falla ante una página sin título; y
+`mergeWithSiteConfig`, porque `app.vue` es dueño de la plantilla del título.
+
+Sigue escrito a mano lo que los módulos no pueden saber: el canónico de una
+página versionada apunta a la versión actual y no a la página que se está
+renderizando, y `noindex` se sigue de que una versión sea antigua o de que una
+página se sirva en un idioma al que no fue traducida.
+
+La comprobación de enlaces informa en lugar de fallar. `modules/validate.ts` ya
+hace fallar una compilación ante un enlace que no lleva a ninguna parte, y es la
+comprobación que entiende versiones y respaldos de idioma.
+
+## Consecuencias
+
+Cada consumidor de la capa instala siete módulos donde instalaba tres. Ese es el
+precio de la decisión y lo pagan también los sitios que no usen ninguno de los
+cuatro nuevos.
+
+Las reglas que antes eran afirmaciones en un comentario son ahora aserciones en
+`scripts/check-seo.ts`, que lee las páginas construidas y falla ante un segundo
+canónico, un `hreflang` ausente, una página de error indexable o un grafo que no
+se puede analizar. Se ejecuta en `check` junto a `check:a11y`, por el mismo
+motivo: las etiquetas existen solo en el HTML renderizado.
+
+Dos de esas reglas no podían comprobarse antes, porque solo existen cuando el
+sitio conoce su propio origen, y `www` deliberadamente no declara dominio alguno.
+La comprobación le entrega al servidor construido su propia dirección mediante
+las variables de entorno que los módulos ya leen, en lugar de un dominio fijado
+en una configuración que un consumidor copiaría.
+
+Publicar una `Organization` exige un dato que la capa no debe inventar, así que
+espera a una nueva clave `duxt.organization` y permanece ausente hasta que un
+consumidor la rellene — la misma postura que la ADR-0005.
+
+## Alternativas consideradas
+
+**Conservar los tres módulos y añadir solo `nuxt-schema-org`.** El cambio más
+estrecho, descartado por el canónico: la regla de versión y el canónico
+automático deben reconciliarse de todos modos, y hacerlo sin `nuxt-seo-utils`
+significa conservar el bloque `og:`/`twitter:` escrito a mano que ya se había
+olvidado en dos páginas.
+
+**Escribir los datos estructurados a mano y conservarlos.** Funcionaba, no tenía
+dependencia y se descartó porque el grafo ya era la parte con más probabilidad de
+estar mal y menos de notarse — y porque una segunda página con un segundo nodo
+habría supuesto una segunda copia de la identidad del sitio incrustada en ella.
+
+**Dejar que el verificador de enlaces rompa la compilación.** Descartado porque
+dos guardianes sobre una misma regla hacen que decida el más laxo cuándo se rompe
+una compilación. La comprobación que entiende las versiones y los respaldos de
+idioma de esta capa es la suya propia.
diff --git a/docs/es/99.adr/index.md b/docs/es/99.adr/index.md
new file mode 100644
index 00000000..cb6a4f36
--- /dev/null
+++ b/docs/es/99.adr/index.md
@@ -0,0 +1,24 @@
+---
+title: Decisiones de arquitectura
+description: El registro de decisiones — toda decisión de arquitectura registrada para duxt.
+icon: lucide:gavel
+---
+
+Una decisión se gana un ADR cuando limita el trabajo que viene después y su
+razonamiento se perdería de otro modo: una elección entre alternativas reales,
+una convención que toda parte del proyecto tiene que seguir, un compromiso que
+parece un error hasta que se conoce el motivo. Los registros son de solo
+añadido — una decisión revertida se escribe como un ADR nuevo que sustituye al
+anterior, nunca como una edición de este.
+
+| ADR | Decisión | Estado | Fecha |
+| :------------------------------------------------------------------------- | :------------------------------------------------------------------------ | :------- | :--------- |
+| [ADR-0001](/adr/0001-build-duxt-as-a-layer-carrying-a-module) | Construir duxt como una capa que lleva un módulo | accepted | 2026-09-06 |
+| [ADR-0002](/adr/0002-generate-the-collections-from-one-source-list) | Generar las colecciones a partir de una sola lista de fuentes | accepted | 2026-09-06 |
+| [ADR-0003](/adr/0003-decide-the-url-prefixes-at-build-time) | Decidir los prefijos de URL en tiempo de compilación | accepted | 2026-09-06 |
+| [ADR-0004](/adr/0004-render-markdown-components-with-mdc) | Renderizar los componentes de Markdown con MDC | accepted | 2026-09-06 |
+| [ADR-0005](/adr/0005-ship-the-layer-without-owner-specific-links) | Publicar la capa sin enlaces específicos del propietario | accepted | 2026-09-06 |
+| [ADR-0006](/adr/0006-rebuild-on-a-schedule-rather-than-refresh-at-runtime) | Recompilar según un calendario en vez de refrescar en tiempo de ejecución | accepted | 2026-09-06 |
+| [ADR-0007](/adr/0007-serve-translations-as-collections-of-their-own) | Servir las traducciones como colecciones propias | accepted | 2026-09-06 |
+| [ADR-0008](/adr/0008-build-the-theme-on-owned-shadcn-vue-components) | Construir el tema sobre componentes shadcn-vue propios | accepted | 2026-09-08 |
+| [ADR-0009](/adr/0009-take-the-seo-stack-from-the-nuxt-seo-bundle) | Tomar la base de SEO del paquete Nuxt SEO | accepted | 2026-09-08 |
diff --git a/docs/es/_partials/install.md b/docs/es/_partials/install.md
new file mode 100644
index 00000000..0423ab9e
--- /dev/null
+++ b/docs/es/_partials/install.md
@@ -0,0 +1,6 @@
+---
+title: Instalar
+---
+
+::package-managers{command="add -D @kirchdev/duxt"}
+::
diff --git a/docs/es/index.md b/docs/es/index.md
new file mode 100644
index 00000000..6b8ff1c7
--- /dev/null
+++ b/docs/es/index.md
@@ -0,0 +1,20 @@
+---
+title: Documentación
+description: Una capa de Nuxt que convierte la carpeta docs de un repositorio en un sitio de documentación versionado y multirrepositorio.
+icon: lucide:book-open-text
+navigation: false
+---
+
+Todo lo que hace duxt, en cuatro partes. Empieza por la izquierda si es tu
+primera vez, o ve directamente a la parte que buscas.
+
+::page-cards
+::
+
+Dos cosas no están aquí a propósito. Cómo contribuir a duxt está en
+[`CONTRIBUTING.md`](https://github.com/kirchDev/duxt/blob/main/CONTRIBUTING.md),
+y las decisiones detrás de la capa están registradas en
+[Decisiones de arquitectura](/adr).
+
+Sobre qué se construye la capa, y después de qué se construyó, está en
+[Créditos](/credits).
diff --git a/docs/fr/1.getting-started/1.installation.md b/docs/fr/1.getting-started/1.installation.md
new file mode 100644
index 00000000..fe73854f
--- /dev/null
+++ b/docs/fr/1.getting-started/1.installation.md
@@ -0,0 +1,74 @@
+---
+title: Installation
+description: Ajoutez la couche, étendez-la, écrivez du Markdown.
+icon: lucide:download
+---
+
+## Ajouter la dépendance
+
+:partial{name="install"}
+
+## Étendre la couche
+
+```ts [nuxt.config.ts]
+export default defineNuxtConfig({
+ extends: ['@kirchdev/duxt']
+});
+```
+
+## Écrire votre documentation
+
+Le Markdown se place dans `docs/`, à la racine de votre dépôt — résolu par
+rapport au dépôt et non au répertoire d’exécution du site, afin qu’un site situé
+dans un sous-dossier le trouve. Un fichier devient une page, un dossier une
+section, et le frontmatter porte le titre, la description et l’icône.
+
+::file-tree{title="votre-projet/"}
+---
+tree:
+ - name: docs/
+ children:
+ - name: index.md
+ - name: 1.getting-started/
+ children:
+ - name: index.md
+ - name: 1.installation.md
+ - name: 2.guides/
+ children:
+ - name: 1.deploying.md
+ - name: nuxt.config.ts
+---
+::
+
+```md [docs/1.getting-started/index.md]
+---
+title: Introduction
+description: Ce qu’est ce projet.
+icon: lucide:rocket
+---
+
+Votre première page.
+```
+
+Un préfixe numérique ordonne l’arborescence et disparaît de l’URL :
+`1.getting-started/` est servi sur `/getting-started`. Réordonner une section ne
+déplace donc jamais une URL — seul le renommage de la partie après le point le
+fait.
+
+::callout{type="tip" title="Pas de H1 dans le corps"}
+Le `title` de la page est rendu comme ``. Un titre à vous en début de
+fichier serait le deuxième de la page.
+::
+
+## Ce dont la compilation a besoin
+
+- **Node 24.** La couche lit sa base de données via `node:sqlite` ; aucun pilote
+ SQLite natif n’est installé ni nécessaire.
+- **Un accès réseau à vos sources**, si certaines sont d’autres dépôts, et un
+ jeton pour les dépôts privés — Content le lit dans l’`auth` de la collection.
+
+## Résultat
+
+`nuxt dev` sert le site. Tout ce que le thème dessine est une donnée avec une
+valeur par défaut ; l’étape suivante est donc le seul fichier qui la remplace :
+[Configuration](/getting-started/configuration).
diff --git a/docs/fr/1.getting-started/2.configuration.md b/docs/fr/1.getting-started/2.configuration.md
new file mode 100644
index 00000000..29d1d8e8
--- /dev/null
+++ b/docs/fr/1.getting-started/2.configuration.md
@@ -0,0 +1,108 @@
+---
+title: Configuration
+description: Un fichier configure tout le site — ce qu’il contient, et comment il fusionne.
+icon: lucide:settings
+---
+
+Tout ce que le thème affiche est une donnée, et `app/app.config.ts` est
+l’endroit où un site les déclare. Il n’y a pas de second fichier de
+configuration : la même clé `duxt` porte le thème, les sources de documentation
+et les liens propres au site.
+
+```ts [app/app.config.ts]
+export default defineAppConfig({
+ duxt: {
+ title: 'mon projet',
+ version: 'v1.4.0',
+ sources: [{ path: 'docs' }]
+ }
+});
+```
+
+Chaque clé et sa valeur par défaut figurent dans la
+[référence de configuration](/reference/configuration) ; l’entrée `sources` a
+[sa propre page](/reference/sources).
+
+## Comment votre configuration fusionne
+
+Votre objet `duxt` est fusionné clé par clé par-dessus les valeurs par défaut de
+la couche, et **un tableau à vous remplace celui de la couche** au lieu de s’y
+ajouter. C’est important parce que la fusion d’`app.config` de Nuxt fait
+l’inverse : elle concatène les tableaux, ce qui vous empêcherait de retirer une
+seule entrée de la barre de navigation fournie par la couche. La couche ne
+livre donc aucune liste dans `app.config` et les fusionne elle-même.
+
+La règle pratique : remplacez `footer.copyright` et `footer.legal` reste intact ;
+déclarez `navigation` et l’unique entrée de la couche disparaît.
+
+## Deux clés sont lues à la compilation
+
+`sources` et `locales` déterminent les collections, les routes et les liens
+`hreflang`, et les trois sont fixés avant la première requête. Modifier l’une ou
+l’autre impose une recompilation ; tout le reste prend effet dès que la
+configuration est rechargée.
+
+## Certaines clés sont générées, non écrites
+
+`resolvedSources`, `layerVersion` et `layerRepository` apparaissent dans la
+configuration à l’exécution et appartiennent à la compilation.
+`resolvedSources` est le manifeste que le thème lit réellement — quelle
+collection sert quel préfixe d’URL — résolu à partir de la liste `sources` que
+vous avez écrite. Écrire l’un des trois à la main est écrasé.
+
+## Ce que la couche ne prétend pas savoir
+
+`links`, `aside.links` et `footer.legal` sont livrés **vides**. Un dépôt, un
+gestionnaire de tickets, une communauté et des mentions légales désignent chacun
+un projet précis : une valeur par défaut reprenant celles de duxt tendrait à vos
+lecteurs un « Star on GitHub » qui étoile le travail de quelqu’un d’autre.
+
+`sections` est vide pour la raison voisine : il nomme les parties de premier
+niveau de votre arborescence de documentation, et la couche ne peut pas savoir
+comment vous les avez appelées. Définissez-le une fois que votre documentation a
+des sections ; jusque-là, la rangée reste masquée et la barre latérale affiche
+tout l’arbre.
+
+```ts
+sections: [
+ { label: 'Guides', to: '/guides', icon: 'lucide:book-open' },
+ { label: 'Référence', to: '/reference', icon: 'lucide:list' }
+]
+```
+
+Écrivez les vôtres — sous forme de chaîne simple, ou d’enregistrement par locale
+si le site sert plusieurs langues :
+
+```ts
+links: [
+ {
+ icon: 'lucide:github',
+ to: 'https://github.com/vous/votre-projet',
+ label: { 'en-GB': 'Repository', 'fr-FR': 'Dépôt' }
+ }
+]
+```
+
+::callout{type="warning" title="duxt.defaults.* ne vous appartient pas"}
+Les clés i18n de la couche traduisent les valeurs par défaut qu’elle fournit.
+Elles sont internes : en renommer une n’est pas un changement cassant, et i18n
+répond à une clé absente en affichant la clé — la rupture serait donc
+silencieuse sur votre site. Utilisez un littéral, une clé à vous, ou la forme
+d’enregistrement ci-dessus.
+::
+
+## L’origine du site
+
+duxt ne peut pas deviner le domaine depuis lequel il sera servi, et quatre
+choses en ont besoin : `hreflang`, `canonical`, le sitemap et les images Open
+Graph. Indiquez-le une fois, là où Nuxt le demande déjà :
+
+```ts [nuxt.config.ts]
+i18n: { baseUrl: 'https://docs.example.com' }
+```
+
+ou via `NUXT_PUBLIC_I18N_BASE_URL` dans le déploiement. La couche le copie vers
+`site.url`, où les modules SEO vont le chercher. Laissé vide, chacun d’eux
+retombe sur une sortie relative plutôt que d’inventer un domaine — un
+`canonical` relatif se résout encore, un `canonical` deviné est activement faux.
+Voir [SEO](/concepts/seo).
diff --git a/docs/fr/1.getting-started/index.md b/docs/fr/1.getting-started/index.md
new file mode 100644
index 00000000..3fce671b
--- /dev/null
+++ b/docs/fr/1.getting-started/index.md
@@ -0,0 +1,36 @@
+---
+title: Introduction
+description: Ce qu’est duxt, et ce qui arrive avec une seule ligne de configuration.
+icon: lucide:rocket
+---
+
+duxt est une couche Nuxt. Vous l’étendez, vous mettez du Markdown dans `docs/`,
+et vous avez un site de documentation : navigation, sommaire, recherche, thème,
+`llms.txt` et un serveur MCP compris.
+
+```ts [nuxt.config.ts]
+export default defineNuxtConfig({
+ extends: ['@kirchdev/duxt']
+});
+```
+
+C’est tout le cas du dossier unique. Aucune collection, aucune mise en page,
+aucun `content.config.ts`.
+
+## Ce que ce n’est pas
+
+duxt ne source pas le contenu. [Nuxt Content v3](https://content.nuxt.com)
+télécharge déjà un dépôt git sur une branche ou un tag, s’authentifie auprès
+d’un dépôt privé et met le résultat en cache par hachage — et duxt s’en sert
+plutôt que de le réimplémenter.
+
+Ce que duxt ajoute, c’est la partie autrement retapée dans chaque dépôt de
+documentation : une collection par version et par dépôt, le schéma d’URL qui les
+porte, le sélecteur qui sait quelle page existe où, et un thème par-dessus. Tout
+cela est généré à partir d’une seule liste — voir
+[Sources](/concepts/sources).
+
+## Où aller ensuite
+
+::page-cards
+::
diff --git a/docs/fr/2.concepts/1.layers.md b/docs/fr/2.concepts/1.layers.md
new file mode 100644
index 00000000..67295416
--- /dev/null
+++ b/docs/fr/2.concepts/1.layers.md
@@ -0,0 +1,52 @@
+---
+title: Couches et surcharge
+description: Ce qu’une couche Nuxt vous donne, ce qu’elle vous retire, et quels chemins ne se résolvent pas comme ils se lisent.
+icon: lucide:layers
+---
+
+duxt est une couche, pas un modèle. Vous l’étendez, et chaque fichier qu’elle
+fournit devient surchargeable : placez un fichier du même nom dans votre projet
+et Nuxt résout le vôtre en premier. Rien n’est copié dans votre dépôt : une mise
+à jour est donc un changement de version, pas une fusion.
+
+## Ce que cela signifie en pratique
+
+| Vous voulez | Vous faites |
+| :-------------------------------------------- | :----------------------------------------------------- |
+| Changer un texte, un lien, une option | Définir la clé dans votre `app/app.config.ts` |
+| Remplacer un morceau de la mise en page | Créer `app/components/DuxtFooter.vue` dans votre projet |
+| Restyler le thème | Redéfinir une variable CSS dans votre feuille de style |
+| Ajouter une page | Écrire du Markdown dans `docs/` |
+
+L’ordre est délibéré : les trois premiers sont des engagements de plus en plus
+lourds, et un composant que vous remplacez est un composant que vous maintenez
+désormais. Voir [Surcharger le thème](/guides/override-the-theme).
+
+## Ce qu’elle vous retire
+
+Une couche ne connaît jamais le projet qui repose sur elle. Deux conséquences
+que l’on ressent :
+
+- **`@` est à vous, pas à la couche.** L’alias appartient à celui qui étend
+ duxt ; les imports de la couche utilisent donc `@duxt`. Le vôtre continue de
+ pointer vers votre `app/`.
+- **Une collection se résout par rapport à la couche, pas à vous.** Content
+ enregistre comme racine de chaque collection la couche qui l’a déclarée : un
+ `cwd` relatif dans la configuration de duxt pointerait donc à l’intérieur de
+ duxt. La couche calcule pour cette raison un chemin absolu au chargement, ce
+ qui fonctionne parce que `content.config.ts` est du code exécuté et non un
+ fichier de données — et c’est sur cette jointure que repose tout le raccourci
+ `sources`.
+
+## Pourquoi c’est construit ainsi
+
+L’alternative est un modèle de départ : `nuxi init`, et le thème devient une
+copie dans votre dépôt. Cela donne une liberté totale et aucun chemin de mise à
+jour — chaque amélioration du thème est un diff que quelqu’un doit appliquer à
+la main. Une couche inverse cela : vous héritez des améliorations et ne payez
+que là où vous n’étiez pas d’accord.
+
+Ce qui fige tout cela, c’est la surface publique. Un composant, une page ou une
+clé d’`app.config` que vous pouvez surcharger est un nom dont vous dépendez :
+en renommer un est donc un changement cassant de la couche — ce que documente
+précisément la [référence](/reference).
diff --git a/docs/fr/2.concepts/2.sources.md b/docs/fr/2.concepts/2.sources.md
new file mode 100644
index 00000000..2ba90cf6
--- /dev/null
+++ b/docs/fr/2.concepts/2.sources.md
@@ -0,0 +1,60 @@
+---
+title: Sources
+description: Une liste de sources devient chaque collection, préfixe et version que le site sert.
+icon: lucide:git-branch
+---
+
+Une **source** est un dossier de Markdown : dans ce dépôt ou dans un autre, sur
+le checkout courant ou sur des refs nommées. La liste `sources` est le seul
+endroit où on les déclare, et tout le reste en est généré.
+
+```ts [app/app.config.ts]
+sources: [
+ { path: 'docs', slug: 'acme' },
+ { repo: 'acme/api', path: 'docs', refs: ['main', 'v2.0.0', 'v1.4.0'] }
+]
+```
+
+Deux dépôts et trois refs : cinq collections, les préfixes d’URL qui les
+servent, un sélecteur de versions, les règles de redirection et les entrées du
+sitemap. Rien de tout cela n’est écrit à la main. Chaque champ figure dans la
+[référence des sources](/reference/sources).
+
+## Comment cela fonctionne
+
+Content v3 fait déjà la moitié difficile. La source d’une collection accepte une
+URL de dépôt, une branche ou un tag et des identifiants, et Content la
+télécharge puis la met en cache par hachage — multi-dépôts, dépôts privés et
+lecture depuis un tag existent déjà là. duxt n’en réimplémente rien.
+
+Ce que duxt ajoute se produit au chargement de la configuration.
+`content.config.ts` est du code exécuté et non un fichier de données : il peut
+donc lire l’`app.config.ts` du site et calculer ses collections à partir de
+cette liste — une collection par dépôt × ref, chacune avec un `cwd` absolu, un
+nom dérivé du slug et le schéma de page dont le thème a besoin. La même liste
+est résolue une seconde fois par la compilation, en **manifeste** : quelle
+collection sert quel préfixe d’URL, quelle ref est celle par défaut, d’où vient
+chaque page. C’est ce manifeste que le thème lit, et il est réécrit dans la
+configuration de l’application sous `resolvedSources`.
+
+## Pourquoi c’est construit ainsi
+
+Déclarer les collections à la main ne passe pas à l’échelle dans la seule
+direction où la documentation grandit vraiment : trois versions sur quatorze
+dépôts font quarante-deux déclarations, et chaque publication en modifie
+quatorze. Le raccourci garde la liste aussi longue que le nombre de projets.
+
+Le coût, c’est qu’une liste de sources exprime moins qu’une collection écrite à
+la main. C’est un compromis délibéré : qui a besoin de ce que le raccourci ne
+sait pas dire écrit son propre `content.config.ts` et reprend tout, puisque
+Content fusionne le fichier de chaque couche, la dernière l’emportant.
+
+## Ce qui en découle
+
+Parce qu’une source nomme un dépôt, une ref et un dossier, plusieurs
+fonctionnalités n’ont besoin d’aucune configuration propre : « Modifier cette
+page », la date de dernière modification et la liste des contributeurs en
+découlent toutes. Lire l’historique d’une source **distante** coûte un clone
+complet : cela attend donc `history: true` — un clone superficiel attribuerait
+chaque fichier à l’auteur de la pointe, ce qui est une donnée fausse plutôt
+qu’une donnée manquante.
diff --git a/docs/fr/2.concepts/3.urls-and-versions.md b/docs/fr/2.concepts/3.urls-and-versions.md
new file mode 100644
index 00000000..1bf3558d
--- /dev/null
+++ b/docs/fr/2.concepts/3.urls-and-versions.md
@@ -0,0 +1,76 @@
+---
+title: URL et versions
+description: Comment apparaissent les segments de dépôt et de version, et ce qu’on dit à un lecteur dans une ancienne version.
+icon: lucide:git-compare
+---
+
+Chaque page est servie à
+
+```text
+/[repo]/[version]/[...chemin]
+```
+
+les deux préfixes étant facultatifs — et la décision de faire apparaître chacun
+est prise **à la compilation, à partir de la liste des sources**, jamais par
+requête.
+
+- Un segment de dépôt apparaît dès qu’il y a plus d’un dépôt, ou quand
+ `showRepo` le force.
+- Un segment de version apparaît dès qu’une source publie plus d’une ref, ou
+ quand `showVersion` le force.
+
+Un dossier unique non versionné sert donc `/guides/deploying`, et rien dans
+l’URL ne trahit l’existence de dépôts ou de versions.
+
+## Pourquoi les préfixes sont décidés à la compilation
+
+Si les deux préfixes étaient facultatifs par requête, le premier segment serait
+ambigu : `/guides/…` pourrait être un dossier, un dépôt ou une version, et seule
+la consultation des trois le dirait. Comme la forme d’une URL est figée avant la
+première requête, le routeur n’a jamais à deviner.
+
+Reste une **collision de noms** tant qu’un préfixe est actif : un dossier de
+documentation nommé comme un dépôt ou une version. La compilation la rejette au
+lieu de la trancher silencieusement ; voir
+[Ce que la compilation vérifie](/concepts/build-checks).
+
+## La version par défaut
+
+Une ref par dépôt est servie sans préfixe de version : la première de la liste,
+ou celle que nomme `sourceOptions.defaultRef`. C’est l’URL vers laquelle pointer
+et celle vers laquelle les moteurs de recherche sont dirigés.
+
+## Où en est une version dans sa vie
+
+`status`, sur une source ou une ref, est un cycle de vie et non un synonyme de
+« est la version par défaut » : un site peut publier la v2 comme actuelle tandis
+que la v1 est simplement plus ancienne et la v0 réellement morte, et le lecteur
+doit savoir dans lequel des trois cas il se trouve.
+
+| Statut | Ce qu’on dit au lecteur | Dans le sitemap |
+| :----------- | :----------------------------------------------- | :--------------------- |
+| `upcoming` | Cela peut encore changer — pas de conseil de MAJ | Seulement si par défaut |
+| `current` | Rien | Seulement si par défaut |
+| `maintained` | Une version plus récente existe | Seulement si par défaut |
+| `deprecated` | Une version plus récente existe ; mettez à jour | Seulement si par défaut |
+| `eol` | Cette version est morte | Jamais |
+
+`upcoming` est le seul qui ne parle pas d’âge. Une version peut ne pas être
+celle par défaut parce qu’elle n’a pas encore eu lieu, et dire à ce lecteur de
+« mettre à jour » est exactement à l’envers. Semver ne peut pas situer une
+branche face à un tag : ce cas est donc déclaré dans la configuration plutôt que
+déduit.
+
+À côté du bandeau, une page d’une version non par défaut porte `noindex` et un
+`canonical` pointant vers la même page dans la version par défaut — sans quoi un
+moteur de recherche servirait la v0.7.0 aussi volontiers que la documentation du
+jour. Les deux moitiés vont ensemble : le bandeau est la visible, le link-rel
+l’invisible, et l’une sans l’autre laisse un public dans l’erreur.
+
+## Quand une page manque dans une version
+
+Le sélecteur ne propose qu’une version qui possède réellement la page, et le
+lien canonique se résout vers le chemin existant le plus proche. Une URL qui ne
+résout vers rien reçoit un 404 qui suggère la page la plus proche dans la
+navigation — on rate facilement une URL versionnée d’un segment, et un 404 vide
+laisse le lecteur sans rien à cliquer.
diff --git a/docs/fr/2.concepts/4.collections.md b/docs/fr/2.concepts/4.collections.md
new file mode 100644
index 00000000..9d653be0
--- /dev/null
+++ b/docs/fr/2.concepts/4.collections.md
@@ -0,0 +1,71 @@
+---
+title: Collections
+description: Ce que Content construit à partir des sources, et les quatre choses qu’ajoute le schéma.
+icon: lucide:database
+---
+
+Une collection est l’unité de sourcing de Content : un glob, un endroit d’où le
+lire et un schéma. duxt en déclare une par dépôt × ref : un site avec un dossier
+a donc une collection, et un site avec deux dépôts sur trois refs en a six.
+
+Leurs **noms sont des données**, pas une constante. Un site avec deux dépôts a
+`docs_duxt` et `docs_workflows` et aucune collection nommée `docs` — c’est
+pourquoi le thème lit le nom de la collection dans le manifeste plutôt que de le
+coder en dur, et pourquoi une requête à vous devrait faire de même :
+
+```vue
+
+```
+
+## Ce qu’ajoute le schéma
+
+Content ne stocke que ce qu’un schéma déclare : quatre éléments dont le thème a
+besoin sont donc déclarés explicitement. La liste complète est dans la
+[référence du frontmatter](/reference/frontmatter) ; les raisons sont ici :
+
+- **`icon`, `layout`, `navigation`** — des champs du thème. Sans le schéma, un
+ `icon:` dans le frontmatter est silencieusement supprimé avant même que la
+ barre latérale le voie.
+- **`rawbody`** — le Markdown tel qu’écrit. Deux choses en ont besoin : le
+ bouton qui remet une page à un modèle, et `llms-full.txt`.
+- **`redirectFrom`** — transformé en règles de route par la compilation ; voir
+ [Déplacer une page](/guides/move-a-page).
+- **`sitemap`** — ce qui met ces pages dans le sitemap. Une collection doit
+ déclarer le champ, sinon le sitemap liste les routes du site et pas une seule
+ page de documentation.
+
+## Fragments
+
+`_partials/` dans le dossier de documentation de **n’importe quelle** source
+alimente une collection partagée, et `:partial{name="install"}` dans n’importe
+quelle page de n’importe quelle source l’affiche. Content ne fournit aucune
+directive d’inclusion, et à travers plusieurs dépôts ce manque n’a aucun
+contournement — une note d’installation qui doit être identique dans trois
+projets est copiée dans trois projets et diverge.
+
+Les fragments portent la même dimension linguistique que les pages :
+`docs/_partials/` est `duxt_partials`, `docs/de/_partials/` est
+`duxt_partials_de`, et `:partial` parcourt [la même chaîne de
+repli](/concepts/localisation) que la page qui le contient. Ce doit être la même
+chaîne — qu’une page et les blocs qu’elle inclut se replient sur des langues
+différentes, c’est exactement ainsi qu’on obtient une page à moitié traduite
+sans que rien ne le signale.
+
+Les fragments sont exclus des collections de pages elles-mêmes. Sinon chaque
+fragment serait aussi une page : dans la barre latérale, dans la recherche, dans
+`llms.txt`.
+
+## Les brouillons sont des fichiers, pas des drapeaux
+
+Un brouillon est `deploying.draft.md`, pas `draft: true` dans le frontmatter. Le
+drapeau est l’écriture évidente et celle qui ne peut pas fonctionner : le
+contenu d’une collection est déclaré avant que Content ait lu le moindre fichier
+— pour une source distante, avant même qu’elle soit téléchargée — donc rien à ce
+moment ne sait ce que dit un frontmatter. Un nom de fichier, lui, est connu.
+Content retire `.draft` de l’URL : la page est donc servie sur `/deploying` dans
+le serveur de développement et simplement absente de la compilation.
diff --git a/docs/fr/2.concepts/5.build-checks.md b/docs/fr/2.concepts/5.build-checks.md
new file mode 100644
index 00000000..c0f4e522
--- /dev/null
+++ b/docs/fr/2.concepts/5.build-checks.md
@@ -0,0 +1,88 @@
+---
+title: Ce que la compilation vérifie
+description: Les échecs silencieux qu’a connus cette couche, transformés en messages.
+icon: lucide:shield-check
+---
+
+Chaque bogue qu’a réellement connu cette couche était silencieux : une
+collection que Content écartait parce que son nom n’était pas un identifiant
+JavaScript, une page en 404 parce qu’un lien avait vieilli, une navigation qui
+revenait vide. Aucun des trois ne disait quoi que ce soit — la compilation était
+verte et le site faux. Des vérifications s’exécutent donc après la compilation,
+toutes lisant la même chose : le cache d’analyse que Content laisse derrière
+lui.
+
+| Vérification | Gravité |
+| :----------------------------------------------- | :------------ |
+| Une collection sans aucune page | erreur |
+| Un dossier en conflit avec un segment d’URL | erreur |
+| Un `title` ou une `description` manquant | avertissement |
+| Un lien interne ou une ancre cassés | avertissement |
+| Ce que porte chaque langue et ce qui s’est figé | note |
+
+## Pourquoi la gravité n’est pas uniforme
+
+Une erreur coûte une page que personne ne peut atteindre et que rien sur le site
+n’expliquerait jamais — une collection vide, c’est un 404 sur chaque page d’une
+version. Un avertissement coûte de la qualité : la page s’affiche toujours, et
+une source distante peut vieillir d’une publication à l’autre sans que ce soit
+la faute de cette compilation. Faire échouer la compilation parce que le dépôt
+de quelqu’un d’autre a avancé rendrait une compilation verte dépendante d’un
+tiers.
+
+Une **note** ne coûte rien du tout : c’est un état du site, pas un défaut dans
+le site. Une page non traduite n’est pas une faute, et la signaler comme
+avertissement apprendrait à tout le monde à passer les avertissements.
+
+## Le rapport de traduction
+
+Une ligne par langue, à chaque compilation :
+
+```
+[duxt] translations
+[duxt] de: 118/121 pages, 3 behind the original
+[duxt] "docs/guides/deploying.md" has no de translation.
+[duxt] "docs/de/concepts/sources.md" has not moved since the original changed.
+```
+
+Le second chiffre exige `history: true` sur la source : les dates viennent de
+git, et une source qui n’a pas demandé son historique n’en a aucune. Sans lui le
+rapport compte la couverture et ne dit rien du décalage, plutôt que de le
+deviner à partir de l’absence.
+
+Il existe à cause d’un échec précis. OpenCode a traduit sa documentation en
+dix-sept langues avec un agent en CI, a coupé le workflow, et nulle part il
+n’était dit que les traductions avaient cessé de bouger. La couche sait déjà,
+page par page, quelles langues la portent et quand chaque fichier a changé pour
+la dernière fois ; un rapport est ce qui en fait quelque chose que quelqu’un
+voit.
+
+## Ce que les vérifications savent et pas vous
+
+- **Une collision de dossiers** n’est un problème que tant qu’un préfixe est
+ actif. Un dossier de documentation nommé `v2` est correct sur un site non
+ versionné et inaccessible sur un site versionné, car le préfixe l’emporte. La
+ compilation sait de quel cas il s’agit, et le message nomme le fichier et
+ propose la correction.
+- **Un lien est résolu comme le thème le résout.** Un chemin absolu écrit dans
+ une page est relatif à la source de cette page : il est donc essayé d’abord
+ sous le préfixe de la source, puis nu. Un vérificateur ne faisant que la forme
+ nue signalerait chaque lien correct d’un site multi-dépôts.
+- **Un lien est résolu dans sa propre langue.** Chaque langue d’une source sert
+ des chemins de contenu identiques — la locale se place devant l’URL, pas dans
+ l’arbre de contenu — et les ancres dérivent du texte des titres : un titre
+ allemand a donc une ancre allemande. Une vérification résolvant par le seul
+ chemin confronterait un lien allemand à des titres anglais et signalerait
+ chaque ancre correcte d’un site traduit. Un lien vers une page qu’une langue
+ ne porte pas se replie sur l’original, ce que fait aussi le site.
+- **Les URL externes ne sont pas vérifiées.** En vérifier une mettrait le réseau
+ dans la compilation, et un hôte tiers injoignable n’est pas une raison pour
+ que votre documentation ne soit pas publiée.
+
+## Écrivez les liens en chemins de documentation absolus
+
+`/concepts/sources`, et non `../2.concepts/2.sources.md`. Le préfixe sous lequel
+une page est servie est décidé à la compilation : écrire des chemins sans
+préfixe est donc ce qui permet au même Markdown de servir `/guides/deploying`
+sur un site et `/acme/v2.0.0/guides/deploying` sur un autre. C’est aussi la
+seule forme que voit le vérificateur de liens.
diff --git a/docs/fr/2.concepts/6.machine-readers.md b/docs/fr/2.concepts/6.machine-readers.md
new file mode 100644
index 00000000..4f36a388
--- /dev/null
+++ b/docs/fr/2.concepts/6.machine-readers.md
@@ -0,0 +1,50 @@
+---
+title: Lecteurs machines
+description: Ce qu’obtient un modèle — llms.txt, le Markdown brut de n’importe quelle page et un vrai serveur MCP.
+icon: lucide:bot
+---
+
+La documentation est lue autant par des modèles que par des personnes, et les
+quatre réponses ci-dessous sont générées à partir des mêmes collections que
+celles dont les pages sont issues — aucune ne peut donc diverger de ce qui est
+publié.
+
+| Chemin | Ce que c’est |
+| :--------------- | :------------------------------------------------------------------------ |
+| `/llms.txt` | Un index : nom du site, résumé, puis chaque page comme entrée liée |
+| `/llms-full.txt` | Les mêmes pages, entières, concaténées en un seul fichier Markdown |
+| `.md` | La source Markdown de n’importe quelle page |
+| `/mcp` | Un serveur MCP — `list_pages`, `search_docs`, `read_page` |
+
+## Du Markdown, pas du HTML
+
+Tout ici transmet le Markdown tel qu’il a été écrit, et c’est pourquoi le schéma
+de la collection demande à Content de stocker `rawbody`. Un modèle à qui l’on
+donne du HTML rendu doit défaire une mise en page pour trouver un titre ; celui
+à qui l’on donne la source lit ce que l’auteur a écrit, les blocs de code
+toujours des blocs et un bloc MDC toujours un appel de composant.
+
+`.md` est servi par un **middleware** plutôt que par une route, car `.md`
+est un suffixe du dernier segment du chemin et non un segment à part — il
+n’existe pas de motif de route Nitro signifiant « tout chemin se terminant par
+.md ».
+
+## Pourquoi les deux fichiers llms
+
+La convention de [llmstxt.org](https://llmstxt.org) en nomme deux, et ils
+répondent à des questions différentes : un modèle qui a lu l’index doit encore
+récupérer soixante pages, tandis qu’un modèle à qui l’on remet le fichier
+complet les a déjà lues. Lequel revient le moins cher dépend de la question : le
+site publie donc les deux.
+
+## Pourquoi un vrai serveur MCP
+
+`/mcp` parle le protocole via le SDK officiel plutôt que d’être un point de
+terminaison JSON de la conception propre de duxt. Un client s’y connecte comme
+il se connecte à n’importe quoi d’autre, au lieu que quelqu’un écrive un
+adaptateur autour d’une forme que seul ce thème possède.
+
+Sa recherche est un `LIKE` sur les titres et les descriptions, et non l’index
+qu’utilise le navigateur : celui-ci est construit côté client à partir du
+export de la collection, alors qu’un outil serveur dispose de la base de données
+elle-même.
diff --git a/docs/fr/2.concepts/7.seo.md b/docs/fr/2.concepts/7.seo.md
new file mode 100644
index 00000000..2c2f87ce
--- /dev/null
+++ b/docs/fr/2.concepts/7.seo.md
@@ -0,0 +1,78 @@
+---
+title: SEO
+description: Ce que chaque page émet pour les robots et les cartes sociales, quel module l'écrit, et la seule valeur que la couche ne peut pas deviner.
+icon: lucide:search
+---
+
+Chaque page émet un head complet, une description structurée d'elle-même, une
+carte sociale et une entrée dans la sitemap. L'essentiel est écrit par [Nuxt
+SEO](https://nuxtseo.com), que la couche installe comme un seul bundle ; le reste
+est la partie qui dépend des versions et des traductions, et elle appartient à la
+couche. Une valeur doit venir de vous.
+
+## Par page
+
+- **Meta** — le titre suivi du nom du site, la description, et le couple
+ `og:`/`twitter:` qui en découle. `og:type` et `og:url` sont posés par page ;
+ `og:locale` et ses alternatives viennent des langues que le site sert.
+- **JSON-LD** — un graphe par page : un `WebSite` (et votre `Organization`, une
+ fois définie) déclarés à l'échelle du site, plus un `TechArticle` et une
+ `BreadcrumbList` construite à partir du fil que dessine le fil d'Ariane.
+- **Une image Open Graph**, rendue depuis le gabarit propre à la couche — sur la
+ page d'accueil aussi. Fournissez un composant du même nom pour la remplacer.
+- **`canonical` et `robots`**, qui forment la moitié versionnée — voir
+ [URL et versions](/concepts/urls-and-versions). Le canonique d'une ancienne
+ version pointe vers la version courante ; c'est pourquoi la couche l'écrit
+ elle-même au lieu de laisser celui du module.
+
+La page d'erreur porte `noindex, follow` : une 404 indexée concurrence la page
+que le lecteur cherchait, et les suggestions qu'elle propose sont de vraies pages
+qu'un robot doit encore pouvoir atteindre.
+
+## À l’échelle du site
+
+`/sitemap.xml` liste les pages de la version par défaut, par langue. Une version
+qui n'est pas celle par défaut en est exclue puisque ses pages portent de toute
+façon `noindex`, et une version `eol` en est exclue qu'elle soit la version par
+défaut ou non : lister l'une ou l'autre revient à demander à un robot d'aller
+chercher exactement ce que la page lui dira ensuite d'écarter. `/robots.txt` est
+généré à côté.
+
+`duxt.organization` est ce qui nomme l'éditeur — voir
+[Configuration](/reference/configuration). Non défini, le site se décrit toujours
+comme un `WebSite` ; ce qu'il perd, c'est l'entité derrière, celle que lit un
+panneau de connaissances ou un résumé d'IA pour créditer une source.
+
+## Ce qui est vérifié, et ce qui est seulement signalé
+
+`pnpm check:seo` lit les pages construites et échoue sur un second `canonical`,
+un `hreflang` absent, une page d'erreur indexable, un `og:locale` au mauvais
+format, ou un graphe qui ne s'analyse pas. Il tourne dans `pnpm check` après le
+build, parce que ces balises n'existent que dans le HTML rendu.
+
+Le vérificateur de liens signale au lieu d'échouer. Un lien qui ne mène nulle
+part fait déjà échouer le build via les
+[vérifications de build](/concepts/build-checks) propres à la couche, celles qui
+comprennent les versions et les replis de langue ; l'apport du module ici, c'est
+le panneau devtools et la vérification en direct pendant la rédaction.
+
+## La seule valeur que vous devez indiquer
+
+L’origine. `hreflang`, `canonical`, le sitemap et les images Open Graph sont
+toutes des URL absolues, et une couche ne peut pas connaître le domaine sur
+lequel elle sera déployée. Définissez `i18n.baseUrl` ou
+`NUXT_PUBLIC_I18N_BASE_URL` ; la couche le copie vers `site.url`, où les modules
+SEO vont le chercher.
+
+Laissée vide, chacune retombe sur une sortie relative plutôt que d’inventer un
+domaine. C’est le choix délibéré : un `canonical` relatif se résout toujours
+correctement, alors qu’un `canonical` deviné est activement faux — il
+dirigerait un robot vers le site de quelqu’un d’autre.
+
+## Une variable de plus pour un déploiement progressif
+
+`NUXT_OG_IMAGE_SECRET` signe les URL des images Open Graph. Sans elle, un secret
+est généré par compilation, ce qui convient pour une instance et non pour deux :
+une carte dont l’URL a été signée par une instance est rejetée par la suivante,
+et un lien partagé pendant un déploiement progressif perd donc son image.
+Générez-la une fois ; elle ne tourne pas.
diff --git a/docs/fr/2.concepts/8.localisation.md b/docs/fr/2.concepts/8.localisation.md
new file mode 100644
index 00000000..40e4b9c6
--- /dev/null
+++ b/docs/fr/2.concepts/8.localisation.md
@@ -0,0 +1,99 @@
+---
+title: Localisation
+description: La couche fournit sept locales, et chaque chaîne configurée prend trois formes.
+icon: lucide:languages
+---
+
+Les chaînes du thème lui-même — libellés de navigation, recherche, bandeau de
+version, page 404 — sont livrées traduites en `en-GB`, `en-US`, `de-DE`,
+`es-ES`, `fr-FR`, `pt-PT` et `pt-BR`. Un site choisit celles qu’il sert avec
+`locales`, et `i18n.defaultLocale` décide laquelle est servie sans préfixe
+d’URL.
+
+`locales` est lu à la compilation : les locales déterminent les routes et
+`hreflang`, pas seulement ce qu’un composant dessine — modifier la liste impose
+donc une recompilation.
+
+::callout{type="warning" title="Restreindre la liste passe par cette clé, pas par celle d’i18n"}
+Le tableau `locales` d’i18n fusionne entre les couches au lieu de remplacer :
+déclarer `['de-DE']` là n’ajoute ni ne retire rien — les six autres restent
+routées, indexées et annoncées dans `hreflang`. `duxt.locales` est la clé qui la
+réduit réellement.
+::
+
+## Chaque chaîne configurée prend trois formes
+
+Tout ce que vous écrivez dans la configuration — un libellé, un titre, une
+description — peut être un littéral, une clé i18n, ou un enregistrement d’une
+chaîne par locale :
+
+```ts
+label: 'Repository' // littéral
+label: 'app.nav.repository' // votre propre clé
+label: { 'en-GB': 'Repository', 'fr-FR': 'Dépôt' } // par locale
+```
+
+Laquelle c’est se décide selon que la chaîne est une clé enregistrée. Un site
+monolingue ne rencontre donc jamais les deux autres formes, et un site avec une
+poignée de chaînes en cinq langues n’a besoin d’aucun fichier de locale — la
+forme d’enregistrement se résout par la langue de base : `pt-PT` couvre `pt-BR`
+et `en-GB` couvre `en-US`.
+
+## N’allez pas puiser dans les clés de la couche
+
+`duxt.defaults.*` traduit les valeurs par défaut que la couche fournit
+elle-même. Ces clés sont internes : en renommer une n’est pas un changement
+cassant de la couche, et i18n répond à une clé absente en affichant la clé —
+celui qui en dépendrait l’apprendrait d’un lecteur, pas d’une compilation.
+Utilisez un littéral, une clé à vous, ou la forme d’enregistrement.
+
+## Chemins et liens
+
+Seuls les chemins sont localisés. `useDuxtPath()` donne le chemin où se trouve
+la **documentation**, sans le segment de locale ; `useDuxtLink()` le réécrit en
+lien en rajoutant le segment. Une URL absolue, un lien mail ou une simple ancre
+passent intacts. Tout ce qui calcule un chemin de documentation devrait passer
+par cette paire plutôt que de lire la route directement.
+
+## Pages traduites
+
+L’interface et les pages sont deux décisions distinctes. Un site qui ne dit rien
+sert un même arbre dans toutes les locales — l’habillage change de langue, pas
+le Markdown — et c’est la bonne forme pour la plupart des documentations.
+
+`locales` sur une source ajoute la seconde moitié :
+
+```ts [app/app.config.ts]
+sources: [{ path: 'docs', locales: ['en-GB', 'fr'] }]
+```
+
+La locale par défaut est l’arbre de `path` lui-même, sans dossier propre :
+ajouter cette clé ne déplace donc jamais une page déjà publiée. Toute autre
+langue est un dossier à l’intérieur — `docs/fr/` — ou se trouve entièrement
+ailleurs ; voir [Plusieurs langues](/guides/several-languages).
+
+::callout{type="tip" title="La locale ne fait pas partie du chemin de contenu"}
+`/fr-FR/guides/deploying` et `/guides/deploying` sont le même chemin de contenu
+dans deux collections. Le segment appartient au routage d’i18n, jamais à une
+collection — c’est pourquoi changer de langue garde le lecteur sur la même page,
+et pourquoi une traduction manquante se règle par une requête de plus plutôt
+que par une redirection.
+::
+
+## Quand une page n’a pas de traduction
+
+Le lecteur reçoit la langue la plus proche qui l’a, avec un bandeau qui le dit,
+et la page reste hors index sous `noindex`, avec un `canonical` pointant vers la
+langue qui la porte réellement. La chaîne est celle que la couche applique déjà
+aux chaînes de caractères :
+
+1. la locale elle-même — `fr-FR` lit `fr-FR`
+2. sa langue de base — `fr-FR` lit un arbre `fr`, ce qui permet à un dossier
+ `pt/` de servir à la fois `pt-PT` et `pt-BR`
+3. une sœur de la même langue — `pt-BR` lit `pt-PT` avant de sortir du portugais
+4. `fallbackLocale` de vue-i18n, la valeur même par laquelle se résout une
+ chaîne absente
+5. l’original non traduit
+
+Un 404 en est délibérément absent : un trou dans une traduction appartient à qui
+l’écrit, et y répondre en cachant la page en punit le lecteur.
diff --git a/docs/fr/2.concepts/9.theming.md b/docs/fr/2.concepts/9.theming.md
new file mode 100644
index 00000000..05bae20f
--- /dev/null
+++ b/docs/fr/2.concepts/9.theming.md
@@ -0,0 +1,109 @@
+---
+title: Thématisation
+description: Comment le thème est construit — des composants possédés, une couche de jetons et un mode sombre qui bascule sur une classe.
+icon: lucide:palette
+---
+
+Le thème n'est pas une dépendance que vous configurez. C'est un ensemble de
+fichiers que la couche possède, bâti sur [shadcn-vue](https://www.shadcn-vue.com)
+et Tailwind 4 — et la raison de cette forme décide de ce que coûte une
+surcharge.
+
+Changer quelque chose, c'est [Surcharger le thème](/guides/override-the-theme).
+Cette page explique pourquoi les coutures sont là où elles sont.
+
+## Les composants sont copiés, pas importés
+
+shadcn-vue n'est pas une bibliothèque de composants que l'on installe. Son CLI
+écrit le code source d'un composant dans votre projet, et à partir de là le
+fichier est le vôtre. La couche garde ses primitives dans `app/components/ui/`, et
+`components.json` pointe le CLI sur cet alias : en ajouter une tient en une seule
+commande.
+
+Deux conséquences en découlent, et elles vont en sens inverse :
+
+- **Vous pouvez tout changer, sans fork.** Un composant que vous voulez différent
+ est un fichier que vous modifiez — pas d'amont contre lequel lutter, pas d'API
+ de thème dans laquelle chercher une faille.
+- **Rien ne se met à jour tout seul.** Un correctif amont sur une primitive vous
+ parvient quand vous relancez le CLI pour elle, pas quand une dépendance monte de
+ version. La couche assume ce coût pour ses propres primitives ; un composant que
+ vous surchargez dans votre projet, vous l'assumez.
+
+Ce compromis est toute la raison du troisième niveau du guide de surcharge : le
+plus petit changement qui fait le travail est le bon, car un composant que vous
+remplacez est un composant que vous maintenez désormais.
+
+::callout{type="info" title="D'où vient la décision"}
+Pourquoi shadcn-vue plutôt qu'un thème de documentation tout fait est consigné
+dans l'[ADR 0008](/adr/0008-build-the-theme-on-owned-shadcn-vue-components).
+::
+
+## Une couche de jetons, deux feuilles de style
+
+La palette est faite de propriétés personnalisées CSS en `oklch`, définies une
+fois sur `:root` et redéfinies sur `.dark`. Chaque composant lit un jeton ; aucun
+ne détient une couleur. C'est ce qui rend possible une surcharge d'une ligne — vous
+redéfinissez `--primary` dans votre propre feuille de style et toutes les surfaces
+qui l'utilisent suivent.
+
+Deux fichiers la portent, et ils ne sont pas interchangeables :
+
+| Fichier | Contient |
+| :---------------------------- | :---------------------------------------------------------------------------------------- |
+| `app/assets/css/duxt.css` | La palette, l'entrée Tailwind, le préréglage de la couche — le fichier que vous lisez |
+| `app/assets/css/typeset.css` | Le typeset de shadcn, repris tel quel — le fichier que vous **ne** modifiez **pas** |
+
+Aucune entrée de registre ne se cache derrière `typeset.css` : le CLI ne peut donc
+ni le récupérer ni le mettre à jour. Mettre à jour signifie retélécharger le
+fichier, et c'est pourquoi les changements locaux ont leur place dans le
+préréglage de `duxt.css` — une modification faite dans `typeset.css` est une
+modification que la prochaine mise à jour écarte en silence.
+
+## Le mode sombre est une classe, pas une media query
+
+`@custom-variant dark (&:is(.dark *))` — le thème bascule sur une classe `dark`, et
+`@nuxtjs/color-mode` pose exactement cette classe parce que la couche le configure
+avec `classSuffix: ''`.
+
+C'est le détail sur lequel on trébuche en surchargeant une couleur. Un jeton
+défini uniquement sous `@media (prefers-color-scheme: dark)` n'est jamais lu, et un
+jeton défini une seule fois sur `:root` sert dans les deux modes — donc il est faux
+dans l'un des deux. Redéfinissez dans les deux blocs, ou acceptez qu'un mode garde
+la valeur de la couche.
+
+## La palette s'écarte du défaut de shadcn, délibérément
+
+`--muted-foreground` est ici plus sombre que ne le livre le jeu neutre de shadcn.
+Ce jeton porte les descriptions de page, le sommaire et le fil d'Ariane, et à la
+valeur de shadcn il mesure en dessous du 4,5:1 exigé par WCAG AA sur les surfaces
+où il atterrit réellement.
+
+Aucune vérification passant par un navigateur ne l'attrape : un passage
+d'accessibilité sur les pages construites n'a pas de couleur calculée à mesurer. La
+couche mesure donc les jetons eux-mêmes, dans `tests/contrast.test.ts`, en
+appariant chaque couleur de premier plan avec les fonds sur lesquels elle est
+vraiment utilisée. Surchargez un jeton de premier plan et vous reprenez cette
+garantie — le test lit le fichier de la couche, pas le vôtre.
+
+## Il faut dire à Tailwind où se trouve la couche
+
+`@source '../../../app'` figure en haut de `duxt.css` et n'est pas décoratif.
+Tailwind cherche les noms de classe depuis la **racine de compilation**, c'est-à-dire
+le dossier du projet consommateur. La couche vit en dehors, dans `node_modules` :
+sans cette ligne, chaque utilitaire employé par un composant de la couche
+manquerait au CSS généré — un site rendu sans aucun style.
+
+Le chemin est relatif à la feuille de style, il tient donc partout où le paquet est
+installé.
+
+## Ce que cela donne
+
+- Une couleur, un rayon, une police : redéfinissez le jeton dans votre feuille de style.
+- Une mise en page qui ne convient pas à votre site : occultez le composant par son nom.
+- Une primitive que la couche n'apporte pas : ajoutez-la avec le CLI.
+
+Chaque marche vers le bas coûte plus d'entretien que la précédente. Le
+[guide de surcharge](/guides/override-the-theme) parcourt les trois, et
+[Donner son identité à votre site](/guides/brand-your-site) traite la part qui
+relève de l'identité et non du thème — le nom, la marque et les icônes.
diff --git a/docs/fr/2.concepts/index.md b/docs/fr/2.concepts/index.md
new file mode 100644
index 00000000..3df9e52a
--- /dev/null
+++ b/docs/fr/2.concepts/index.md
@@ -0,0 +1,12 @@
+---
+title: Concepts
+description: Comment fonctionne la couche — le modèle de sources, le schéma d’URL, les collections et les vérifications.
+icon: lucide:compass
+---
+
+Les pages de démarrage vous mènent de rien à un site qui tourne. Celles-ci
+expliquent ce que sont les pièces, où se trouvent les jointures, et pourquoi
+chacune est construite ainsi.
+
+::page-cards
+::
diff --git a/docs/fr/3.guides/1.several-repositories.md b/docs/fr/3.guides/1.several-repositories.md
new file mode 100644
index 00000000..75339314
--- /dev/null
+++ b/docs/fr/3.guides/1.several-repositories.md
@@ -0,0 +1,62 @@
+---
+title: Plusieurs dépôts
+description: Servir la documentation de plusieurs projets depuis un seul site.
+icon: lucide:folder-git
+---
+
+Un site, plusieurs projets. Chaque dépôt source ne contient que du Markdown —
+pas de Nuxt, pas de `package.json`, pas de dépendances — ce qui est la seule
+organisation viable pour un projet Go, PHP ou Rust.
+
+## Étapes
+
+::steps
+1. **Nommez chaque dépôt comme une source.** Un segment de dépôt apparaît dans
+ chaque URL dès qu’il y en a plus d’un : donnez donc à chacun un `slug` que
+ vous acceptez de garder, il fait partie de l’URL.
+
+ ```ts [app/app.config.ts]
+ sources: [
+ { path: 'docs', slug: 'acme', origin: { repo: 'acme/site', ref: 'main' } },
+ { repo: 'acme/api', path: 'docs' },
+ { repo: 'acme/cli', path: 'docs' }
+ ]
+ ```
+
+2. **Utilisez `origin`, pas `repo`, pour ce dépôt-ci.** `repo` est ce qui fait
+ télécharger une source par Content : y nommer le vôtre ferait cloner à la
+ compilation le checkout dans lequel elle se trouve déjà. `origin` dit
+ seulement où vivent les pages, pour le lien « Modifier cette page ».
+
+3. **Authentifiez les dépôts privés.** Content lit un jeton dans l’`auth` de la
+ collection ; rien là-dedans n’est propre à duxt.
+
+4. **Écrivez vos propres `sections`.** La navigation fournie par la couche
+ suppose une source unique sans préfixe. Avec un segment de dépôt dans chaque
+ chemin, ses entrées ne se résolvent plus : nommez donc les sections
+ vous-même, en pointant vers `/acme/getting-started` plutôt que
+ `/getting-started`.
+
+5. **Compilez une fois et lisez les messages.** Une collection vide fait échouer
+ la compilation, et c’est l’échec que cette configuration produit le plus
+ souvent : un `path` qui n’existe pas dans l’autre dépôt ressemble exactement à
+ un dépôt sans documentation.
+
+::
+
+## Ce que vous obtenez gratuitement
+
+La recherche couvre toutes les sources et classe les résultats ensemble, la
+source où se trouve le lecteur venant en premier — la version qu’il lit, ou sa
+version par défaut, une par dépôt, afin qu’une page n’apparaisse jamais une fois
+par version. `_partials/` de n’importe quelle source alimente une collection
+partagée : une note d’installation s’écrit donc une fois et s’inclut dans les
+trois projets.
+
+## Liste de contrôle
+
+- [ ] Chaque source a un `slug` que vous acceptez de voir dans l’URL
+- [ ] Ce dépôt utilise `origin`, pas `repo`
+- [ ] Les `sections` pointent vers des chemins incluant le segment de dépôt
+- [ ] La compilation ne signale aucune collection vide
+- [ ] Une recherche depuis un dépôt trouve une page dans un autre
diff --git a/docs/fr/3.guides/2.several-versions.md b/docs/fr/3.guides/2.several-versions.md
new file mode 100644
index 00000000..96bfd1a9
--- /dev/null
+++ b/docs/fr/3.guides/2.several-versions.md
@@ -0,0 +1,75 @@
+---
+title: Plusieurs versions
+description: Servir la même documentation sur plusieurs refs, avec un sélecteur et un bandeau.
+icon: lucide:git-compare
+---
+
+Une ref devient une version. Pointez une source vers des tags et chacun est
+publié, avec un segment de version dans l’URL et une entrée dans le sélecteur.
+
+## Étapes
+
+::steps
+1. **Listez les refs.** Une chaîne simple est une **branche**. Un tag doit le
+ dire — git garde les deux dans des espaces de noms séparés, et demander un
+ tag sous `refs/heads` fait échouer la compilation.
+
+ ```ts [app/app.config.ts]
+ sources: [
+ {
+ repo: 'acme/api',
+ path: 'docs',
+ refs: [
+ { branch: 'main', status: 'upcoming' },
+ { tag: 'v2.0.0' },
+ { tag: 'v1.4.0' },
+ { tag: 'v0.9.0', status: 'eol' }
+ ]
+ }
+ ],
+ sourceOptions: { defaultRef: 'v2.0.0' }
+ ```
+
+2. **Nommez la version par défaut.** `defaultRef` est la ref servie sans préfixe
+ de version et celle vers laquelle les moteurs de recherche sont dirigés. Non
+ définie, c’est la première de la liste — ce qui est rarement souhaitable
+ quand la liste commence par une branche.
+
+3. **Indiquez le cycle de vie là où il ne peut pas être déduit.** Semver ordonne
+ les tags : « plus ancienne que la version par défaut » n’a besoin d’aucune
+ aide. Il ne peut pas situer une branche face à un tag : `main` est
+ `upcoming`, et sans le dire on demande à son lecteur de passer à une version
+ plus ancienne que celle qu’il lit.
+
+4. **Utilisez `latest` si vous ne voulez pas modifier cette liste à chaque
+ publication.** Il se résout à la compilation vers le tag semver le plus
+ récent de ce dépôt.
+
+ ```ts
+ refs: [{ branch: 'main', status: 'upcoming' }, 'latest']
+ ```
+
+5. **Signalez l’exigence de version d’une page dans le texte**, là où un lecteur
+ qui regarde une version ne voit pas les autres :
+
+ ```md
+ ### Nouvelles tentatives :since{version="v2.0.0"}
+ ```
+
+::
+
+## Ce qu’il advient des anciennes versions
+
+Une version non par défaut porte `noindex` et un `canonical` pointant vers la
+même page dans celle par défaut, et une version `eol` quitte entièrement le
+sitemap. C’est ce qui empêche un moteur de recherche de proposer `v0.9.0` là où
+le lecteur voulait la documentation du jour — voir
+[URL et versions](/concepts/urls-and-versions).
+
+## Liste de contrôle
+
+- [ ] Chaque tag est écrit `{ tag: … }`, pas en chaîne simple
+- [ ] `defaultRef` nomme la version sur laquelle les lecteurs doivent arriver
+- [ ] Une branche de la liste porte un `status` explicite
+- [ ] Le sélecteur affiche chaque version, avec un badge sur les mortes
+- [ ] La page d’une version morte est absente de `/sitemap.xml`
diff --git a/docs/fr/3.guides/3.several-languages.md b/docs/fr/3.guides/3.several-languages.md
new file mode 100644
index 00000000..83f59656
--- /dev/null
+++ b/docs/fr/3.guides/3.several-languages.md
@@ -0,0 +1,127 @@
+---
+title: Plusieurs langues
+description: Servir l’interface en plusieurs langues, et traduire les pages elles-mêmes.
+icon: lucide:languages
+---
+
+Les chaînes du thème sont traduites dans sept locales. Un site sert le
+sous-ensemble qu’il veut, traduit les chaînes qu’il a lui-même configurées et —
+s’il le souhaite — les pages aussi.
+
+## Étapes
+
+::steps
+1. **Restreignez la liste avec `duxt.locales`.**
+
+ ```ts [app/app.config.ts]
+ locales: ['en-GB', 'fr-FR']
+ ```
+
+ Pas le tableau `locales` d’i18n : celui-là fusionne entre les couches au lieu
+ de remplacer, donc en déclarer deux là laisse les cinq autres routées et
+ annoncées dans `hreflang`.
+
+2. **Choisissez celle sans préfixe** dans `nuxt.config.ts` avec
+ `i18n.defaultLocale`. Elle est déterminante pour les URL : la changer plus
+ tard déplace toutes les pages dans les deux sens à la fois.
+
+3. **Traduisez vos propres chaînes.** Pour une poignée, la forme
+ d’enregistrement coûte moins qu’un fichier de locale par langue :
+
+ ```ts
+ sections: [
+ {
+ label: { 'en-GB': 'Guides', 'fr-FR': 'Guides' },
+ to: '/guides',
+ icon: 'lucide:book-open'
+ }
+ ]
+ ```
+
+ Au-delà d’une poignée, enregistrez vos propres clés i18n et écrivez la clé.
+ N’allez pas chercher `duxt.defaults.*` — ce sont les clés internes de la
+ couche, et un renommage là-bas casserait votre site en silence.
+
+4. **Indiquez l’origine.** `hreflang` n’est pas valide en relatif : il faut donc
+ `i18n.baseUrl` ou `NUXT_PUBLIC_I18N_BASE_URL`.
+
+5. **Traduisez les pages, si vous le voulez.** Un dossier par langue à
+ l’intérieur de la source ; la locale par défaut reste où elle est et ne
+ déplace aucune URL :
+
+ ```ts [app/app.config.ts]
+ sources: [{ path: 'docs', locales: ['en-GB', 'fr'] }]
+ ```
+
+ ::file-tree{title="docs/"}
+ ---
+ tree:
+ - name: index.md
+ - name: 1.getting-started/
+ children:
+ - name: index.md
+ - name: fr/
+ children:
+ - name: index.md
+ ---
+ ::
+
+ Traduisez aussi peu de pages que vous voulez — tout le reste retombe sur
+ l’original, avec un bandeau qui en explique la raison au lecteur.
+
+6. **Ouvrez une page dans chaque locale** et vérifiez que l’interface a changé
+ de langue, que le chemin se résout toujours et qu’aucun libellé configuré ne
+ s’affiche en clé brute.
+::
+
+## Seulement certaines versions, ou un autre dépôt
+
+`locales` sur une ref l’emporte sur celui de la source, ce qui est la forme
+habituelle : la version courante est traduite et les deux précédentes non.
+
+```ts
+{
+ path: 'docs',
+ locales: ['en-GB', 'fr'],
+ refs: [
+ { tag: 'v2.0.0' }, // les deux langues
+ { tag: 'v1.0.0', locales: ['en-GB'] } // l’original seulement
+ ]
+}
+```
+
+La forme objet déplace une langue entièrement ailleurs — son propre dépôt, avec
+ses propres mainteneurs et son propre rythme :
+
+```ts
+locales: ['en-GB', { locale: 'fr', repo: 'acme/docs-fr', path: 'docs' }]
+```
+
+C’est ce que font React et Vue en dehors de leur outillage, avec `de.react.dev`
+et l’organisation `vuejs-translations`, parce que les traducteurs travaillent
+selon leur propre calendrier et leur propre relecture. Ici, c’est une entrée de
+source plutôt qu’un second site — un index de recherche, un sélecteur de langue.
+
+::callout{type="warning" title="Ce que coûte une traduction à la compilation"}
+Environ **deux secondes de compilation et 0,6 Mo de base de données par langue ×
+version × dépôt**, mesuré linéaire jusqu’à au moins 200 collections. Traduire
+chaque version de chaque dépôt multiplie les trois ; ne traduire que la version
+courante est ce que font la plupart des projets.
+::
+
+## Quand une page n’a pas de traduction
+
+Le lecteur reçoit la langue la plus proche qui l’a, avec un bandeau, et la page
+porte `noindex` ainsi qu’un canonical pointant vers la langue dans laquelle elle
+est réellement. La chaîne — locale, langue de base, région sœur,
+`fallbackLocale`, original — est dans [Localisation](/concepts/localisation).
+
+## Liste de contrôle
+
+- [ ] `duxt.locales` liste exactement les locales que vous servez
+- [ ] `/sitemap.xml` contient une entrée par page et par locale, et aucune autre
+- [ ] Les liens `hreflang` sont absolus
+- [ ] Aucun libellé configuré ne s’affiche en clé brute
+- [ ] Changer de locale garde le lecteur sur la même page
+- [ ] Une page non traduite affiche le bandeau de repli, pas un 404
+- [ ] `duxt.sourceOptions.defaultLocale` correspond à `i18n.defaultLocale`
diff --git a/docs/fr/3.guides/4.override-the-theme.md b/docs/fr/3.guides/4.override-the-theme.md
new file mode 100644
index 00000000..2717a351
--- /dev/null
+++ b/docs/fr/3.guides/4.override-the-theme.md
@@ -0,0 +1,73 @@
+---
+title: Surcharger le thème
+description: Changer une couleur, remplacer un composant, ajouter une primitive shadcn-vue.
+icon: lucide:palette
+---
+
+La couche livre le thème sous forme de fichiers que vous pouvez masquer. Trois
+niveaux, du plus petit au plus grand — et le plus petit qui fait l’affaire est
+le bon, car un composant que vous remplacez est un composant que vous maintenez
+désormais.
+
+## Étapes
+
+:::steps
+1. **Une couleur : redéfinissez le token.** La palette est l’ensemble neutre de
+ shadcn sous forme de variables CSS. Redéfinissez-en une dans votre feuille de
+ style ; aucun fork du fichier de la couche n’est nécessaire.
+
+ ```css [app/assets/css/main.css]
+ :root {
+ --primary: oklch(0.55 0.2 265);
+ }
+ .dark {
+ --primary: oklch(0.7 0.18 265);
+ }
+ ```
+
+ Les deux blocs comptent : le thème bascule sur une **classe** `dark`, pas sur
+ une media query — un token défini une seule fois est donc faux dans l’un des
+ deux modes.
+
+ ::callout{type="warning" title="Vérifiez le contraste que vous venez de changer"}
+ `--muted-foreground` porte les descriptions de page, le sommaire et le fil
+ d’Ariane. La valeur de la couche est volontairement plus sombre que celle de
+ shadcn par défaut, qui mesure en dessous de 4,5:1 sur les surfaces où ce
+ token atterrit réellement.
+ ::
+
+2. **Un morceau de la mise en page : créez le fichier.** Nuxt résout d’abord
+ votre composant du même nom : remplacer `DuxtFooter`, c’est donc créer
+ `app/components/DuxtFooter.vue` dans votre projet. Les noms, props et slots
+ sont dans la [référence des composants](/reference/components) — un nom
+ documenté fait partie de la surface publique et ne sera pas renommé sans une
+ version majeure.
+
+3. **Un slot, là où il y en a un.** `DuxtPageFeedback` émet `feedback` et expose
+ son état via un slot : vous pouvez y accrocher votre propre formulaire sans
+ remplacer le composant — détaillé dans
+ [Recueillir des retours](/guides/collect-page-feedback).
+
+4. **Une nouvelle primitive : utilisez la CLI.** La couche possède ses
+ composants shadcn-vue au lieu de les importer, et `components.json` oriente
+ déjà la CLI vers le bon alias :
+
+ ```bash
+ pnpm dlx shadcn-vue@latest add tooltip
+ ```
+
+5. **Un composant pour le Markdown.** Tout ce qui se trouve dans
+ `app/components/content/` est appelable depuis une page avec la syntaxe de
+ bloc MDC. Votre fichier du même nom remplace celui de la couche — voir la
+ [référence MDC](/reference/mdc-components).
+
+:::
+
+## Liste de contrôle
+
+- [ ] Rien n’a été copié hors de la couche qu’un token aurait pu changer
+- [ ] Chaque token redéfini a une valeur en clair et en sombre
+- [ ] Les composants remplacés portent exactement le nom que la référence leur
+ donne
+- [ ] `pnpm build` rend toujours chaque page (une surcharge est l’endroit où le
+ SSR casse)
diff --git a/docs/fr/3.guides/5.move-a-page.md b/docs/fr/3.guides/5.move-a-page.md
new file mode 100644
index 00000000..ab3f79c4
--- /dev/null
+++ b/docs/fr/3.guides/5.move-a-page.md
@@ -0,0 +1,55 @@
+---
+title: Déplacer une page
+description: redirectFrom dans le frontmatter, transformé en règles de route sous chaque préfixe.
+icon: lucide:signpost
+---
+
+Une page déplacée est le seul lien cassé qu’un site de documentation se crée
+lui-même. Mettez l’ancienne URL dans le frontmatter de la nouvelle page, et la
+redirection part dans le même commit que le déplacement.
+
+## Étapes
+
+::steps
+1. **Déplacez le fichier** et renommez-le comme vous l’entendiez.
+
+2. **Listez les anciens chemins sur la page elle-même**, sans aucun préfixe :
+
+ ```md [docs/3.guides/6.deploy-a-site.md]
+ ---
+ title: Déployer un site
+ description: Où un site duxt peut tourner.
+ redirectFrom:
+ - /getting-started/deploy
+ - /deployment
+ ---
+ ```
+
+3. **Recompilez.** La compilation transforme chaque entrée en règles de route —
+ sous chaque préfixe de dépôt, de version et de locale où la page est servie.
+
+4. **Vérifiez-en une**, préfixe compris, sur le site compilé.
+
+::
+
+## Pourquoi le frontmatter est le bon endroit
+
+Le correctif a toujours vécu hors du site : une règle nginx, un fichier
+`_redirects` Netlify, une liste Cloudflare — écrits par celui qui déploie le
+site et jamais par celui qui a déplacé la page. Et seule la couche peut le
+résoudre une fois pour toutes, car seul le manifeste résolu sait quels préfixes
+existent ; celui qui écrirait les règles à la main devrait redécouvrir tout le
+schéma page par page.
+
+::callout{type="tip" title="Réordonner ne nécessite jamais de redirection"}
+Le préfixe numérique est retiré de l’URL : renommer `3.guides/` en `4.guides/`
+ne déplace rien. Seule la partie après le point constitue l’URL.
+::
+
+## Liste de contrôle
+
+- [ ] Chaque ancien chemin est listé, écrit sans préfixe
+- [ ] La compilation ne signale aucun lien interne cassé
+- [ ] Une ancienne URL redirige aussi sous le préfixe de version, pas seulement
+ nue
+- [ ] Rien n’a été ajouté à la configuration du serveur web
diff --git a/docs/fr/3.guides/6.deploy-a-site.md b/docs/fr/3.guides/6.deploy-a-site.md
new file mode 100644
index 00000000..95ff77d4
--- /dev/null
+++ b/docs/fr/3.guides/6.deploy-a-site.md
@@ -0,0 +1,83 @@
+---
+title: Déployer un site
+description: Où un site duxt peut tourner, ce qu’il lui faut là-bas, et pourquoi le contenu n’est pas rafraîchi à l’exécution.
+icon: lucide:cloud-upload
+---
+
+Un site duxt est une application Nuxt. Tout ce qui exécute Nuxt l’exécute.
+
+## Étapes
+
+:::steps
+1. **Préférez le statique.** `nuxt generate` produit un dossier de HTML — pas de
+ serveur, pas de coût d’exécution, chaque page mise en cache en périphérie.
+ C’est le bon défaut pour de la documentation.
+
+ ::package-managers{command="run generate"}
+ ::
+
+ `nuxt build` produit un serveur Node, dont vous n’avez besoin que si une page
+ doit réagir à une requête.
+
+2. **Donnez à la compilation ce qu’il lui faut.** Node 24 (la base de données
+ est lue via `node:sqlite`, aucun pilote natif n’est donc installé), un accès
+ réseau à toute source distante, et un jeton pour les dépôts privés.
+
+3. **Indiquez l’origine.** `NUXT_PUBLIC_I18N_BASE_URL`, ou `i18n.baseUrl` dans
+ la configuration. Sans elle, `hreflang`, `canonical`, le sitemap et les
+ cartes sociales retombent sur une sortie relative.
+
+4. **Définissez un secret d’image OG si vous déployez plus d’une instance.**
+ `NUXT_OG_IMAGE_SECRET`, généré une fois. Sans lui, chaque compilation signe
+ les URL d’image avec un secret qui lui est propre, et une carte signée par
+ une instance est rejetée par la suivante.
+
+5. **Planifiez une recompilation.** Un site qui lit un autre dépôt en récupère
+ les changements quand il compile, pas quand ils sont poussés.
+
+:::
+
+## Pourquoi il n’y a pas de rafraîchissement à l’exécution
+
+Content télécharge un dépôt distant à la compilation et le compile dans la base
+de données que la compilation livre. Rafraîchir cela à l’exécution reviendrait à
+reconstruire la base à l’intérieur d’un serveur en fonctionnement — Content
+n’offre aucune voie prise en charge pour cela, et la seule disponible est la
+base de données client que lit la recherche. Une recompilation planifiée, ou
+déclenchée depuis le dépôt source, garde le déploiement immuable et le contenu
+reproductible.
+
+## Quand une compilation annonce `database is locked`
+
+Content conserve son cache d’analyse dans `.data/content/contents.sqlite`, et
+une compilation que le noyau ou un `Ctrl-C` a laissée derrière lui peut
+continuer à le retenir. Chaque compilation suivante échoue alors avec
+`database is locked` — un message qui ne nomme ni le fichier ni le processus.
+
+duxt place cette base de données en [mode WAL](https://www.sqlite.org/wal.html)
+avant que Content ne l’ouvre, ce qui permet à un lecteur et à un rédacteur de
+coexister et supprime entièrement la version quotidienne du problème. Ce qu’il
+ne peut pas supprimer, c’est un second processus toujours en cours :
+
+```bash
+pgrep -af 'nuxt (build|dev|generate)' # le processus orphelin, s’il y en a un
+kill
+```
+
+Si rien n’apparaît et que l’erreur persiste, supprimez `.data/content/` : c’est
+un cache, et la compilation suivante le reconstruit à partir des sources.
+
+## Ce que le site sert en plus des pages
+
+`/llms.txt`, `/llms-full.txt`, le source `.md` de n’importe quelle page, `/mcp`,
+`/sitemap.xml`, `/robots.txt` et — une fois configuré — `/rss.xml`. La liste
+complète avec ses réserves est dans la
+[référence des points de terminaison](/reference/endpoints).
+
+## Liste de contrôle
+
+- [ ] `nuxt generate` se termine sans erreur du validateur
+- [ ] L’origine est définie dans le déploiement, pas seulement en local
+- [ ] `/sitemap.xml` liste des URL absolues sur le vrai domaine
+- [ ] Une recompilation est planifiée, ou déclenchée depuis chaque dépôt source
+- [ ] `NUXT_OG_IMAGE_SECRET` est défini si plus d’une instance sert le site
diff --git a/docs/fr/3.guides/7.collect-page-feedback.md b/docs/fr/3.guides/7.collect-page-feedback.md
new file mode 100644
index 00000000..979b5230
--- /dev/null
+++ b/docs/fr/3.guides/7.collect-page-feedback.md
@@ -0,0 +1,125 @@
+---
+title: Recueillir des retours
+description: Enveloppez DuxtPageFeedback et gardez la réponse là où vit déjà votre analytique.
+icon: lucide:thumbs-up
+---
+
+Chaque page de documentation se termine par « Cette page vous a-t-elle été
+utile ? », et la couche ne fait rien de la réponse : elle la garde dans l’état
+du composant le temps de la visite, émet un événement et ne stocke rien.
+Envelopper le composant est la façon dont la réponse vous parvient.
+
+## Étapes
+
+::steps
+
+1. **Enveloppez le composant de la couche sous son propre nom.** Nuxt résout
+ votre fichier en premier : `app/components/DuxtPageFeedback.vue` dans votre
+ projet remplace donc celui de la couche — et l’alias `@duxt` atteint toujours
+ l’original par son chemin, si bien que l’enveloppe conserve le balisage, les
+ traductions et la réinitialisation à la navigation de la couche :
+
+ ```vue [app/components/DuxtPageFeedback.vue]
+
+
+
+
+
+ ```
+
+2. **Donnez à la réponse un endroit où atterrir.** Une route à vous, pour que le
+ navigateur du lecteur ne parle jamais à un tiers que vous n’avez pas choisi :
+
+ ```ts [server/api/feedback.post.ts]
+ export default defineEventHandler(async (event) => {
+ const { path, helpful } = await readBody(event);
+
+ // Votre stockage, votre analytique, votre file d’attente.
+ await recordFeedback({ path, helpful });
+
+ return { ok: true };
+ });
+ ```
+
+ Un appel d’analytique côté client fonctionne pareil — remplacez le `$fetch`
+ de l’enveloppe par ce qu’expose le SDK de votre fournisseur.
+
+3. **Recompilez et répondez une fois à la question**, puis vérifiez que la
+ réponse est arrivée avec le chemin attendu — sous un préfixe de version, et
+ pas seulement à l’URL nue.
+
+::
+
+## Afficher votre propre contrôle
+
+L’événement conserve les boutons de la couche. Pour les remplacer, prenez plutôt
+le slot par défaut : il expose l’état même à partir duquel le composant s’affiche,
+si bien que la réinitialisation à la navigation et l’état « merci » restent
+l’affaire de la couche.
+
+```vue [app/components/DuxtPageFeedback.vue]
+
+
+
+
+
+
+ Dites-nous ce qui manque
+
+
+ Envoyer
+
+ Merci.
+
+
+```
+
+`answer(helpful)` fixe l’état et émet l’événement : un slot et un écouteur se
+composent donc — accrochez votre formulaire au slot et gardez le gestionnaire
+`@feedback` de l’étape 1 pour livrer ce qu’il a recueilli.
+
+::callout{type="warning" title="Deux réponses ne font pas deux lecteurs"}
+Rien dans le composant n’identifie un visiteur, et cela reste ainsi
+délibérément. Si vous comptez les réponses par page, comptez-les comme des
+réponses — un navigateur qui recharge et répond à nouveau est une deuxième
+réponse, pas un deuxième lecteur, et transformer l’une en l’autre est une
+question de consentement à laquelle votre politique de confidentialité doit
+répondre d’abord.
+::
+
+## Pourquoi la couche ne stocke rien
+
+Un thème de documentation qui téléphonerait à la maison par défaut devrait
+choisir une destination pour les lecteurs de quelqu’un d’autre, et il n’en
+existe aucune défendable : la couche ne sait ni où vit votre analytique ni ce
+que promet votre politique de confidentialité. Livrer la question avec un
+événement et sans backend garde la décision là où elle appartient — la même
+raison pour laquelle la rangée légale du pied de page et les liens vers le dépôt
+sont livrés vides
+([ADR 0005](/adr/0005-ship-the-layer-without-owner-specific-links)).
+
+## Liste de contrôle
+
+- [ ] L’enveloppe affiche le composant de la couche au lieu de le réimplémenter
+- [ ] La réponse est envoyée avec le chemin sur lequel elle a été donnée,
+ préfixes compris
+- [ ] La destination est la vôtre — une route, une file ou un SDK que vous avez
+ choisi
+- [ ] Rien dans la charge utile n’identifie le lecteur
diff --git a/docs/fr/3.guides/8.brand-your-site.md b/docs/fr/3.guides/8.brand-your-site.md
new file mode 100644
index 00000000..a38146ae
--- /dev/null
+++ b/docs/fr/3.guides/8.brand-your-site.md
@@ -0,0 +1,93 @@
+---
+title: Donner son identité à votre site
+description: Définir le nom, le logo et les icônes, pour que le site se lise comme le vôtre et non comme celui de la couche.
+icon: lucide:badge-check
+---
+
+La couche est livrée sans identité, volontairement : pas de nom, pas de marque,
+pas d'icônes. Un site qui l'étendrait en affichant l'identité de duxt ferait la
+publicité du projet de quelqu'un d'autre — chacun de ces éléments vous revient
+donc. En attendant, l'en-tête retombe sur une icône de livre générique.
+
+## Étapes
+
+:::steps
+
+1. **Le nom.** `title` est le mot dans la barre de navigation, dans le gabarit du
+ titre SEO, sur la 404, dans le flux RSS et comme titre de `llms.txt`. Une
+ valeur, sept lecteurs.
+
+ ```ts [app/app.config.ts]
+ export default defineAppConfig({
+ duxt: {
+ title: 'Acme SDK'
+ }
+ });
+ ```
+
+ Identique dans toutes les langues : une chaîne simple convient. Là où ce n'est
+ pas le cas, la forme d'enregistrement fonctionne ici comme ailleurs — voir
+ [Plusieurs langues](/guides/several-languages).
+
+2. **Le logo, si vous en avez un.** `logo.src` remplace le couple icône et nom par
+ une image. `srcDark` est permuté par CSS sous la classe `dark`, pas par script —
+ un choix scripté rend la marque claire dans le HTML du serveur puis la bascule
+ après l'hydratation, ce qui se voit comme un clignotement.
+
+ ```ts [app/app.config.ts]
+ logo: {
+ src: '/wordmark.svg',
+ srcDark: '/wordmark-dark.svg',
+ alt: 'Acme SDK'
+ }
+ ```
+
+ `alt` retombe sur `title` : ne le définissez que là où la marque dit quelque
+ chose que le nom ne dit pas.
+
+ ::callout{type="info" title="La hauteur est fixe, la largeur suit"}
+ Chaque logo a son propre rapport, c'est pourquoi le composant fixe la hauteur
+ et laisse la largeur suivre. Une marque pensée pour une boîte carrée paraîtra
+ étriquée ; une bien plus large que haute ne sera pas écrasée.
+ ::
+
+3. **Les icônes du navigateur.** Ce n'est pas de la configuration duxt, mais de
+ simples entrées head de Nuxt — car un favicon appartient à un site, jamais à un
+ thème.
+
+ ```ts [nuxt.config.ts]
+ app: {
+ head: {
+ link: [
+ { rel: 'icon', type: 'image/svg+xml', href: '/favicon.svg' },
+ { rel: 'apple-touch-icon', href: '/apple-touch-icon.png' }
+ ]
+ }
+ }
+ ```
+
+ Une icône SVG peut porter sa propre règle `prefers-color-scheme` : un fichier
+ couvre donc une barre d'onglets claire et une sombre. Le PNG (180×180) n'existe
+ que parce qu'iOS ignore les icônes SVG.
+
+4. **La ligne d'attribution, si vous n'en voulez pas.** Le pied de page porte une
+ ligne « Powered by duxt » avec la version de la couche. `poweredBy: false` la
+ retire — une couche à qui l'on ne peut pas demander de cesser de se nommer est
+ un logiciel publicitaire.
+
+:::
+
+## Ce qui reste à la couche
+
+Couleurs, espacements et composants sont une autre question, et une autre page :
+[Surcharger le thème](/guides/override-the-theme). Cette page couvre l'identité —
+le nom et la marque. Les tenir séparées en vaut la peine, car une couleur de
+marque se révèle le plus souvent être un jeton, et non un fork du thème.
+
+## Liste de contrôle
+
+- [ ] `title` est défini et plus rien dans l'en-tête ne se lit « Documentation »
+- [ ] `logo.srcDark` est défini, ou la marque est lisible sur les deux fonds
+- [ ] Le favicon et l'apple-touch-icon se résolvent — servis tous deux depuis `public/`
+- [ ] `llms.txt` et `rss.xml` nomment votre site, pas un espace réservé
+- [ ] `poweredBy` est un choix délibéré, pas un défaut que personne n'a regardé
diff --git a/docs/fr/3.guides/index.md b/docs/fr/3.guides/index.md
new file mode 100644
index 00000000..4ac16b87
--- /dev/null
+++ b/docs/fr/3.guides/index.md
@@ -0,0 +1,12 @@
+---
+title: Guides
+description: Une tâche par page, du début à la fin — plusieurs dépôts, plusieurs versions, plusieurs langues et un déploiement.
+icon: lucide:book-open
+---
+
+Chaque page ici accomplit une tâche et se termine par une liste de contrôle. Le
+modèle qui les sous-tend est dans les [Concepts](/concepts) ; chaque clé
+qu’elles définissent est dans la [Référence](/reference).
+
+::page-cards
+::
diff --git a/docs/fr/4.reference/1.configuration.md b/docs/fr/4.reference/1.configuration.md
new file mode 100644
index 00000000..89e49d41
--- /dev/null
+++ b/docs/fr/4.reference/1.configuration.md
@@ -0,0 +1,198 @@
+---
+title: Configuration
+description: Chaque clé sous duxt dans app.config.ts — ce qu’elle contrôle, et quand elle est lue.
+icon: lucide:settings
+---
+
+Tout ce qui suit vit sous `duxt` dans votre `app/app.config.ts`. Le
+fonctionnement de la fusion, et pourquoi les tableaux remplacent au lieu de
+s’ajouter, est dans [Configuration](/getting-started/configuration).
+
+## Site
+
+| Clé | Par défaut | Ce qu’elle contrôle |
+| :----------------- | :------------------- | :-------------------------------------------------------- |
+| `title` | **aucun** | Le nom dans la barre de navigation et dans le titre SEO |
+| `logo` | **aucun** | Un logo à la place du couple icône et nom — voir ci-dessous |
+| `version` | **aucune** | Le badge à côté |
+| `organization` | **aucune** | Qui publie le site, pour schema.org — voir ci-dessous |
+| `locales` | les sept | Quelles langues de la couche ce site sert |
+| `breadcrumb` | `true` | `false` retire le fil au-dessus du titre de page |
+| `pageIcon` | **aucune** | Icône des pages qui n'en déclarent aucune — celle de la section l'emporte |
+| `packageManagers` | pnpm, npm, yarn, bun | Quels gestionnaires propose un bloc de commande, dans cet ordre |
+| `poweredBy` | activé | `false` retire la ligne « Powered by duxt » du pied de page |
+
+### `logo`
+
+| Champ | Type | Notes |
+| :-------- | :------- | :------------------------------------------------------------ |
+| `src` | `string` | La marque. Sans valeur, l'icône générique reste près de `title` |
+| `srcDark` | `string` | Permuté par CSS sous la classe `dark`, pas par script |
+| `alt` | texte | Retombe sur `title` |
+
+En définir un, c'est [Donner son identité à votre site](/guides/brand-your-site).
+
+### `organization`
+
+Le nœud `Organization` que les moteurs de recherche et les résumés d'IA lisent
+pour nommer une source. Absent tant que vous ne le définissez pas : duxt rend la
+documentation d'autrui et ne devine pas de qui.
+
+| Champ | Type | Notes |
+| :----- | :------- | :---------------------------------------------------------------- |
+| `name` | texte | Le nom de l'éditeur. Sans lui, rien n'est publié |
+| `url` | `string` | Son site. Se rabat sur `site.url` |
+| `logo` | `string` | Absolu, ou un chemin depuis la racine ; carré et d'au moins 112px |
+
+
+## Navigation et liens
+
+Une entrée de section est un lien, plus un champ qui lui est propre :
+
+| Champ | Type | Notes |
+| :--------- | :------- | :------------------------------------------------------------------ |
+| `pageIcon` | `string` | Icône des pages de cette section qui n'en déclarent aucune. Prime sur `duxt.pageIcon` ; le frontmatter d'une page l'emporte toujours |
+
+Utile là où une page ne peut porter aucune icône — le frontmatter d'un ADR est
+figé à `title`, `description`, `status` et `date`, si bien que le journal des
+décisions s'afficherait sans.
+
+| Clé | Par défaut | Ce qu’elle contrôle |
+| :----------- | :--------------- | :------------------------------------------------------------ |
+| `navigation` | une entrée | Liens de la barre ; une entrée avec `children` devient un menu |
+| `sections` | **vide** | La deuxième rangée — les parties de premier niveau de la doc |
+| `links` | **vide** | Liens à icône à droite de la barre |
+| `aside` | le titre seul | `title` et `links` sous le sommaire |
+| `footer` | **vide** | `copyright` et `legal` |
+| `landing` | une action | La page à `/` |
+
+La plupart sont livrés vides, pour deux raisons différentes. `links`,
+`aside.links`, `footer.legal`, `title`, `version` et le texte de la page
+d’accueil désignent un projet précis — un dépôt, une communauté, des mentions
+légales, un nom, une version publiée, une phrase sur ce qu’il fait — et
+appartiennent à qui exploite le site. `sections` nomme les
+parties d’une **arborescence** de documentation, et l’arborescence est la vôtre :
+quatre onglets pointant vers des noms de dossier devinés par la couche ne
+mèneraient nulle part. Définissez-le une fois que votre documentation a des
+sections ; d’ici là, la rangée ne s’affiche pas et la barre latérale montre
+l’arbre entier, ce qui est la bonne forme pour une documentation sans sections.
+
+### Page d’accueil
+
+La page à `/` dessine trois bandes, et chacune est absente tant que sa clé n’est
+pas définie.
+
+| Clé | Par défaut | Ce qu’elle dessine |
+| :------------ | :---------------------------- | :--------------------------------------------------- |
+| `badge` | **aucun** | La pastille au-dessus du titre — du texte, ou un objet badge |
+| `headline` | **aucun** | Le `h1` ; à défaut, `title` |
+| `description` | **aucune** | Le paragraphe en dessous, et la meta description de la page |
+| `actions` | une, « Lire la documentation » | Les boutons du bloc principal ; une action sans `to` pointe vers la première section |
+| `command` | **aucune** | Une commande d’installation copiable sous les boutons |
+| `preview` | **aucun** | Une page de ce site, intégrée dans une fenêtre de navigateur |
+| `features` | **vide** | La grille de cartes |
+
+`command` est une simple `string`, pas du texte : une commande shell est
+identique dans toutes les langues, et une commande traduite par erreur ne
+s’exécute pas. La couche n’en livre aucune — elle ne sait pas comment s’appelle
+votre projet, pour la même raison que `links` est vide.
+
+Il n’y a délibérément pas d’appel à l’action final. Les boutons du bloc
+principal sont l’appel ; les répéter sous une grille de cartes demande à un lecteur qui
+vient d’arriver de décider deux fois.
+
+`badge` est livré vide pour la même raison que `links` : une pastille au-dessus
+du titre dit quelque chose de l’état d’un projet — « beta », « v2 est sortie » —
+et la couche ne sait rien de l’état du vôtre. Une chaîne est la forme courte ;
+l’objet ajoute une icône, une couleur et une destination.
+
+| Champ de `badge` | Type | Remarques |
+| :--------------- | :-------- | :--------------------------------------------------------- |
+| `label` | texte | Obligatoire. `{version}` est remplacé par la `version` du site |
+| `icon` | `string` | N’importe quel nom Iconify |
+| `variant` | `string` | `default`, `secondary`, `outline`, `success`, `destructive` |
+| `to` | `string` | Fait de toute la pastille un lien |
+| `external` | `boolean` | Le nouvel onglet |
+
+```ts [app/app.config.ts]
+badge: {
+ label: '{version} publiée',
+ icon: 'lucide:rocket',
+ variant: 'success',
+ to: 'https://github.com/acme/sdk/releases/latest',
+ external: true
+}
+```
+
+`preview` est une fenêtre vivante, pas une image. Elle intègre une page de **ce**
+site dans un cadre de navigateur que le lecteur peut faire défiler, parcourir et
+dont il peut changer le thème sans quitter la page d’accueil. Le cadre n’est
+monté qu’une fois la bande visible, et jamais pendant le rendu serveur — une
+iframe dans le HTML initial, c’est un second chargement complet en concurrence
+avec le premier.
+
+| Champ de `preview` | Type | Remarques |
+| :----------------- | :------- | :---------------------------------------------------------- |
+| `to` | `string` | La page à intégrer ; par défaut la première section |
+| `height` | `string` | Toute longueur CSS. Par défaut `32rem`, `24rem` sous `sm` |
+| `src` | `string` | Une capture À LA PLACE de la page vivante |
+| `srcDark` | `string` | Sert le mode sombre ; sans lui, `src` sert les deux |
+| `alt` | texte | Le texte alternatif de l’image et le nom accessible du cadre |
+
+::callout{type="tip" title="Une capture est l’option la moins coûteuse"}
+Le cadre vivant charge l’application une seconde fois. Un site qui préfère ne
+pas payer cela définit `src` et obtient la même fenêtre autour d’une image fixe.
+::
+
+### Forme d’un lien
+
+| Champ | Type | Remarques |
+| :------------ | :------------------ | :------------------------------------------------ |
+| `label` | texte | Obligatoire |
+| `to` | `string` | Un chemin de documentation est localisé, pas une URL |
+| `icon` | `string` | N’importe quel nom Iconify, p. ex. `lucide:github` |
+| `description` | texte | Affichée dans un menu déroulant de la barre |
+| `external` | `boolean` | Le nouvel onglet et la flèche — jamais le routage |
+| `children` | `DuxtLink[]` | Transforme une entrée de barre en menu déroulant |
+| `variant` | variante de bouton | `landing.actions` uniquement |
+
+Tout champ marqué _texte_ accepte un littéral, une clé i18n ou un enregistrement
+par locale — voir [Localisation](/concepts/localisation).
+
+## Sources
+
+| Clé | Lue à | Ce qu’elle contrôle |
+| :-------------- | :--------------- | :------------------------------------------- |
+| `sources` | la compilation | Les sources de documentation |
+| `sourceOptions` | la compilation | Comment ces sources deviennent des préfixes |
+| `versions` | l’exécution | Remplace les versions déduites quand elles ont besoin de libellés ou de descriptions |
+
+Les deux clés de source ont [leur propre page](/reference/sources). `locales`
+est la troisième clé lue à la compilation : modifier l’une des trois impose une
+recompilation.
+
+## Le flux
+
+`/rss.xml` est vide tant que `feed.path` ne nomme pas une section :
+
+```ts
+feed: { path: '/changelog', title: 'mon projet — publications' }
+```
+
+Désactivé par défaut, à dessein. Un flux est une liste de choses qui **se sont
+produites**, et une page de référence modifiée n’est pas un événement — un site
+qui publie chaque modification comme élément apprend à ses lecteurs à se
+désabonner. Les éléments sont ordonnés par la `date` propre à la page, à défaut
+par le dernier commit qui l’a touchée, et seule la version par défaut de chaque
+source contribue : un changelog versionné ne répète donc pas chaque entrée une
+fois par version.
+
+## Généré, pas écrit
+
+| Clé | Écrit par | Contient |
+| :----------------- | :--------------- | :----------------------------------------------- |
+| `resolvedSources` | la compilation | Le manifeste : quelle collection sert quel préfixe |
+| `layerVersion` | la compilation | La version de duxt, pour le pied de page |
+| `layerRepository` | la compilation | Le dépôt de duxt, pour le pied de page |
+
+Définir l’un des trois à la main est écrasé à la compilation suivante.
diff --git a/docs/fr/4.reference/2.sources.md b/docs/fr/4.reference/2.sources.md
new file mode 100644
index 00000000..75adb763
--- /dev/null
+++ b/docs/fr/4.reference/2.sources.md
@@ -0,0 +1,126 @@
+---
+title: Sources
+description: Les champs d’une entrée de source, ses refs, et les options qui façonnent les URL.
+icon: lucide:git-branch
+---
+
+`duxt.sources` est une liste d’entrées de source ; `duxt.sourceOptions` dit
+comment elles deviennent des URL. Les deux sont lues à la compilation. Ce
+qu’elles génèrent, et pourquoi, est dans [Sources](/concepts/sources).
+
+## Une entrée de source
+
+| Champ | Type | Remarques |
+| :-------- | :----------------------- | :---------------------------------------------------------------- |
+| `path` | `string` | Dossier contenant le Markdown, relatif à la racine du dépôt |
+| `repo` | `string` | `owner/name` ou une URL git. **Omis signifie ce dépôt-ci** |
+| `refs` | liste de refs | Refs à publier comme versions. Omis signifie le checkout courant |
+| `label` | texte | Affiché dans le sélecteur et utilisé dans l’URL ; par défaut la ref |
+| `slug` | `string` | Le segment d’URL du dépôt ; par défaut son nom |
+| `status` | statut | Cycle de vie de chaque version que cette entrée publie |
+| `origin` | `{ repo, ref? }` | Où vivent les pages lues sur le disque, pour les liens de retour |
+| `history` | `boolean` | Lire l’historique git de cette source |
+| `locales` | liste de locales | Langues dans lesquelles cette source existe — voir ci-dessous |
+
+Deux champs se confondent facilement et coûtent cher quand on se trompe :
+
+- **`repo` télécharge, `origin` ne fait que lier.** Nommer votre propre dépôt
+ dans `repo` fait cloner à la compilation le checkout dans lequel elle se
+ trouve déjà.
+- **`history` coûte un clone complet.** Content clone un dépôt distant avec
+ `--depth 1` : son checkout ne contient qu’un commit et chaque fichier paraît
+ écrit par la personne qui a coupé la pointe — données fausses, pas données
+ manquantes. L’activer complète le clone une fois. Une source lue sur le disque
+ est déjà un checkout complet et est lue quoi qu’il arrive.
+
+## Refs
+
+Une chaîne nue est une **branche**. Un tag doit le dire : git garde les branches
+et les tags dans des espaces de noms séparés, et demander un tag sous
+`refs/heads` fait échouer la compilation avec `Could not find refs/heads/…`.
+
+```ts
+refs: [
+ 'main', // une branche
+ { branch: 'next', status: 'upcoming' }, // la même, avec un cycle de vie
+ { tag: 'v2.0.0', label: 'v2' }, // un tag
+ 'latest' // le tag semver le plus récent, résolu à la compilation
+]
+```
+
+`'latest'` est réservé. Une branche réellement nommée `latest` exige la forme
+`{ branch }`.
+
+## Locales
+
+Omis, la source a une langue et une collection. Listées, chaque entrée devient
+une collection à part.
+
+```ts
+locales: [
+ 'en-GB', // la valeur par défaut : docs/ lui-même
+ 'de', // docs/de/
+ { locale: 'fr', path: 'translations/fr' }, // ailleurs dans ce dépôt
+ { locale: 'es', repo: 'acme/docs-es', path: 'docs' } // un autre dépôt
+]
+```
+
+Une chaîne est un dossier à l’intérieur du `path` de la source. La forme objet
+remplace `path`, `repo` et `ref` pour cette langue seule — et c’est ce qui
+permet à une traduction de vivre dans son propre dépôt, avec ses mainteneurs et
+son calendrier de publication.
+
+**La locale par défaut est l’arbre de `path` lui-même et ne prend aucun
+dossier.** Laquelle c’est vient de `sourceOptions.defaultLocale`, et elle doit
+concorder avec `i18n.defaultLocale` — la compilation échoue si elles diffèrent,
+car `content.config.ts` résout les collections sans accès à la configuration
+Nuxt.
+
+Une ref peut porter ses propres `locales`, remplaçant ceux de la source
+exactement comme le fait `status`. C’est la forme habituelle : la version
+courante est traduite, les précédentes non.
+
+```ts
+refs: [
+ { tag: 'v2.0.0' }, // hérite des locales de la source
+ { tag: 'v1.0.0', locales: ['en-GB'] } // l’original seulement
+]
+```
+
+Nommez un dossier par **langue** là où la région n’ajoute rien : `docs/pt/` sert
+à la fois `pt-PT` et `pt-BR`, parce qu’une page retombe sur sa langue de base.
+La même règle que suivent les fichiers de locale de la couche.
+
+## Statut
+
+| Valeur | Signifie |
+| :----------- | :---------------------------------------------------- |
+| `upcoming` | Pas encore publiée — peut encore changer |
+| `current` | La documentation à lire |
+| `maintained` | Plus ancienne, toujours prise en charge |
+| `deprecated` | Plus ancienne, et le lecteur devrait mettre à jour |
+| `eol` | Morte — signalée, et hors du sitemap |
+
+Ce que chacune coûte à une page est dans
+[URL et versions](/concepts/urls-and-versions).
+
+## Options de source
+
+| Champ | Par défaut | Ce qu’il fait |
+| :-------------- | :------------------ | :---------------------------------------------------------- |
+| `showRepo` | désactivé | Force un segment de dépôt avec un dépôt unique |
+| `showVersion` | désactivé | Force un segment de version avec une version unique |
+| `defaultRef` | la première ref | La ref servie sans préfixe de version |
+| `defaultLocale` | la première locale | La locale servie depuis `path` lui-même, sans dossier |
+
+## Le manifeste résolu
+
+La compilation transforme la liste ci-dessus en `duxt.resolvedSources`, et c’est
+ce que lit le thème. `useDuxtCollection()` l’expose ; chaque entrée porte le nom
+de la collection, le `prefix` d’URL, `repo`, `version`, `isDefault`, `locale`,
+`isDefaultLocale`, `status`, le dépôt et la ref d’où viennent les pages, et si
+son `history` a été lu.
+
+La locale est délibérément absente de `prefix` : i18n la place déjà devant le
+chemin, si bien que toutes les langues d’une source partagent un préfixe et ne
+diffèrent que par la collection.
diff --git a/docs/fr/4.reference/3.frontmatter.md b/docs/fr/4.reference/3.frontmatter.md
new file mode 100644
index 00000000..1f615c6b
--- /dev/null
+++ b/docs/fr/4.reference/3.frontmatter.md
@@ -0,0 +1,53 @@
+---
+title: Frontmatter
+description: Les champs qu’une page peut porter au-delà du titre et de la description, et les deux que la compilation remplit.
+icon: lucide:file-code
+---
+
+Content ne stocke que ce que déclare le schéma d’une collection : voici donc la
+liste complète — un champ non déclaré est écarté silencieusement, avant même que
+la barre latérale ou la recherche le voie.
+
+## Ce que vous écrivez
+
+| Champ | Type | Ce qu’il fait |
+| :------------- | :--------- | :------------------------------------------------------------------- |
+| `title` | `string` | Rendu comme le `` de la page, et son titre SEO et social |
+| `description` | `string` | Le résumé — barre latérale, cartes, image OG, `llms.txt` |
+| `icon` | `string` | Affiché à côté de l’entrée dans la barre latérale, la rangée et les cartes |
+| `navigation` | `boolean` | `false` masque la page dans la navigation |
+| `layout` | `string` | `landing` rend la page sans la mise en page documentaire |
+| `redirectFrom` | `string[]` | Anciennes URL de cette page — voir [Déplacer une page](/guides/move-a-page) |
+| `date` | `string` | La date de cette page, pour le flux |
+
+`title` et `description` sont les deux dont la compilation signale l’absence.
+Aucun ne casse une page, et tous deux dégradent silencieusement le sommaire, la
+carte sociale et `llms.txt`.
+
+::callout{type="tip" title="Pas de H1 dans le corps"}
+`title` est rendu comme titre. Un titre `#` en début de fichier est le deuxième
+`` de la page.
+::
+
+## Ce que la compilation remplit
+
+| Champ | Rempli par | Contient |
+| :------------- | :-------------------------- | :--------------------------------------------- |
+| `lastUpdated` | le module git-meta | Le dernier commit ayant touché le fichier |
+| `contributors` | le module git-meta | Nom, nombre de commits et pseudo, par auteur |
+| `rawbody` | le schéma de la collection | Le Markdown tel qu’écrit |
+| `sitemap` | l’intégration sitemap | Ce qui met la page dans `/sitemap.xml` |
+
+En écrire un à la main ne sert à rien : la compilation les écrase. Les deux
+champs git ont besoin d’un historique réel, une source distante a donc besoin de
+`history: true` — voir la [référence des sources](/reference/sources).
+
+## Le nom du fichier porte deux choses de plus
+
+- **Un préfixe numérique ordonne l’arborescence** et disparaît de l’URL :
+ `3.guides/2.deploy.md` est servi sur `/guides/deploy`. Réordonner ne déplace
+ jamais une URL.
+- **`.draft.md` est un brouillon.** Il est servi dans le serveur de
+ développement et absent de la compilation. Il n’y a pas de `draft: true`, car
+ le contenu d’une collection est déclaré avant qu’un fichier ait été lu — voir
+ [Collections](/concepts/collections).
diff --git a/docs/fr/4.reference/4.mdc-components/1.callout.md b/docs/fr/4.reference/4.mdc-components/1.callout.md
new file mode 100644
index 00000000..4589ab40
--- /dev/null
+++ b/docs/fr/4.reference/4.mdc-components/1.callout.md
@@ -0,0 +1,74 @@
+---
+title: Callout
+description: Un encadré où le lecteur est censé s’arrêter — une astuce, un avertissement, un danger.
+icon: lucide:message-square-warning
+---
+
+Un encadré, dans l’une de quatre variantes. Le corps est du Markdown : les
+liens, le `code` et les listes fonctionnent tous à l’intérieur.
+
+## Utilisation
+
+::preview
+:::callout{type="tip" title="Rien d’autre n’est nécessaire"}
+Le corps est du Markdown lui aussi — les liens, le `code` et les listes
+fonctionnent tous à l’intérieur.
+:::
+
+#code
+```mdc
+::callout{type="tip" title="Rien d’autre n’est nécessaire"}
+Le corps est du Markdown lui aussi — les liens, le `code` et les listes
+fonctionnent tous à l’intérieur.
+::
+```
+::
+
+## Types
+
+::preview
+:::callout{type="info"}
+`info` — la valeur par défaut, pour le contexte que la page suppose acquis.
+:::
+
+:::callout{type="tip"}
+`tip` — quelque chose qui rend la tâche plus facile.
+:::
+
+:::callout{type="warning"}
+`warning` — une manière de se tromper.
+:::
+
+:::callout{type="danger"}
+`danger` — perte de données, ou un changement qui ne peut pas être annulé.
+:::
+
+#code
+```mdc
+::callout{type="info"}
+`info` — la valeur par défaut, pour le contexte que la page suppose acquis.
+::
+```
+::
+
+## API
+
+### Props
+
+| Prop | Type | Par défaut | Remarques |
+| :------ | :------------------------------------ | :--------- | :-------------------------------------------------- |
+| `type` | `info` · `tip` · `warning` · `danger` | `info` | Choisit la couleur, l’icône et le rôle |
+| `title` | `string` | — | Première ligne en gras ; le corps suit |
+| `icon` | `string` | par type | Tout nom Iconify, surchargeant la valeur par défaut |
+
+### Slots
+
+| Slot | Ce qu’il contient |
+| :-------- | :--------------------- |
+| `default` | Le corps, en Markdown |
+
+## Remarques
+
+Un callout `danger` porte `role="alert"` ; les autres non. Une alerte interrompt
+un lecteur d’écran au milieu d’une phrase, ce qui est juste pour « ceci supprime
+vos données » et faux pour une astuce.
diff --git a/docs/fr/4.reference/4.mdc-components/10.package-managers.md b/docs/fr/4.reference/4.mdc-components/10.package-managers.md
new file mode 100644
index 00000000..c29734fc
--- /dev/null
+++ b/docs/fr/4.reference/4.mdc-components/10.package-managers.md
@@ -0,0 +1,45 @@
+---
+title: Gestionnaires de paquets
+description: Une commande, un onglet par gestionnaire de paquets, mémorisé d’une page à l’autre.
+icon: lucide:package
+---
+
+Une commande affichée pour chaque gestionnaire de paquets que le site propose,
+avec le choix du lecteur mémorisé pour tout le site.
+
+## Utilisation
+
+Écrivez la commande **sans** le gestionnaire : le `install` de npm et
+l’orthographe de `dlx` propre à chaque gestionnaire sont substitués.
+
+::preview
+:::package-managers{command="add -D @kirchdev/duxt"}
+:::
+
+#code
+```mdc
+::package-managers{command="add -D @kirchdev/duxt"}
+::
+```
+::
+
+## API
+
+### Props
+
+| Prop | Type | Par défaut | Remarques |
+| :--------- | :--------- | :--------------------- | :------------------------------------------- |
+| `command` | `string` | requis | Sans le gestionnaire — `add -D pkg` |
+| `managers` | `string[]` | `duxt.packageManagers` | Surcharge la liste du site pour un seul bloc |
+
+## Remarques
+
+Le choix est un cookie, et non un stockage navigateur, et c’est délibéré : le
+stockage est lu après l’hydratation, si bien que le bloc afficherait un
+gestionnaire puis basculerait vers un autre sous les yeux du lecteur. Il vient
+du site lui-même, n’identifie personne et porte une valeur que le lecteur a
+choisie — voir
+[`usePackageManager()`](/reference/composables/reading/use-package-manager).
+
+Un bloc peut lister moins de gestionnaires que le choix du lecteur n’en couvre ;
+il retombe sur son propre premier onglet plutôt que de ne rien afficher.
diff --git a/docs/fr/4.reference/4.mdc-components/11.file-tree.md b/docs/fr/4.reference/4.mdc-components/11.file-tree.md
new file mode 100644
index 00000000..fbfbb1c9
--- /dev/null
+++ b/docs/fr/4.reference/4.mdc-components/11.file-tree.md
@@ -0,0 +1,61 @@
+---
+title: Arborescence de fichiers
+description: Une structure de dossiers, avec une icône par type de fichier.
+icon: lucide:folder-tree
+---
+
+Un listing de répertoire sous forme d’arborescence, avec l’icône de chaque type
+de fichier déduite de son extension.
+
+## Utilisation
+
+::preview
+:::file-tree{title="your-project/"}
+---
+tree:
+ - name: docs/
+ children:
+ - name: index.md
+ - name: 1.getting-started/
+ children:
+ - name: index.md
+ - name: 1.installation.md
+ - name: app/
+ children:
+ - name: app.config.ts
+ - name: nuxt.config.ts
+---
+:::
+
+#code
+```mdc
+::file-tree{title="your-project/"}
+---
+tree:
+ - name: docs/
+ children:
+ - name: index.md
+ - name: nuxt.config.ts
+---
+::
+```
+::
+
+## API
+
+### Props
+
+| Prop | Type | Par défaut | Remarques |
+| :------ | :-------- | :--------- | :----------------------------------------- |
+| `tree` | `Entry[]` | — | Fourni en YAML dans le corps du bloc |
+| `title` | `string` | — | L’étiquette racine au-dessus de l’arborescence |
+
+Une entrée est `{ name, children? }`. Un nom qui se termine par `/`, ou qui
+porte des enfants, est un répertoire.
+
+## Remarques
+
+Construit sur l’arborescence de reka-ui : il a donc le focus glissant (roving
+focus), la navigation aux flèches et les rôles ARIA. La clé d’un nœud est son
+chemin plutôt que son nom : les noms se répètent d’une branche à l’autre, les
+chemins non.
diff --git a/docs/fr/4.reference/4.mdc-components/12.preview.md b/docs/fr/4.reference/4.mdc-components/12.preview.md
new file mode 100644
index 00000000..c25b2b3a
--- /dev/null
+++ b/docs/fr/4.reference/4.mdc-components/12.preview.md
@@ -0,0 +1,63 @@
+---
+title: Aperçu
+description: Un composant rendu tel que la page le rend, avec sa source à côté.
+icon: lucide:eye
+---
+
+L’exemple est le vrai composant dans le vrai thème — mêmes tokens, même mode
+sombre — si bien qu’un lecteur voit ce que fait un bloc plutôt qu’une
+description de celui-ci. Cette page, et toutes les autres pages de ce dossier,
+sont construites avec lui.
+
+## Utilisation
+
+::preview
+:::callout{type="tip"}
+Rendu par la page que vous êtes en train de lire.
+:::
+
+#code
+```mdc
+::callout{type="tip"}
+Rendu par la page que vous êtes en train de lire.
+::
+```
+::
+
+````mdc
+::preview
+:::callout{type="tip"}
+Rendu par la page que vous êtes en train de lire.
+:::
+
+#code
+```mdc
+::callout{type="tip"}
+Rendu par la page que vous êtes en train de lire.
+::
+```
+::
+````
+
+## API
+
+Aucune prop.
+
+### Slots
+
+| Slot | Ce qu’il contient |
+| :-------- | :------------------------------------------------------------ |
+| `default` | L’exemple, rendu |
+| `code` | La source, dans un bloc de code. Omettez-le et aucune barre d’onglets n’apparaît |
+
+## Remarques
+
+La source dans `#code` est écrite à la main plutôt que dérivée de l’exemple
+au-dessus, et c’est le seul vrai coût du bloc. Quand le composant s’exécute, ses
+enfants sont des nœuds rendus et le Markdown qui les a produits a disparu ; les
+re-sérialiser reviendrait à deviner une syntaxe d’où ils ne sont jamais venus.
+
+::callout{type="warning" title="Deux copies divergent"}
+Rien ne vérifie que l’onglet source correspond à ce qui est rendu à côté. Gardez
+l’exemple assez court pour que modifier l’un sans l’autre saute aux yeux.
+::
diff --git a/docs/fr/4.reference/4.mdc-components/13.page-cards.md b/docs/fr/4.reference/4.mdc-components/13.page-cards.md
new file mode 100644
index 00000000..35964ed1
--- /dev/null
+++ b/docs/fr/4.reference/4.mdc-components/13.page-cards.md
@@ -0,0 +1,32 @@
+---
+title: Cartes de page
+description: Les enfants d’une section, en cartes.
+icon: lucide:layout-grid
+---
+
+Les enfants d’une section, dessinés en cartes avec leur icône, leur titre et
+leur description — ce par quoi se termine chaque `index.md` de cette
+documentation.
+
+## Utilisation
+
+```mdc
+::page-cards
+::
+```
+
+Sans prop, il liste les enfants de la page courante, et c’est pourquoi il est
+montré ici par son nom plutôt que rendu : cette page n’en a aucun.
+
+## API
+
+### Props
+
+| Prop | Type | Par défaut | Remarques |
+| :----- | :------- | :--------------- | :------------------------------------- |
+| `path` | `string` | la page courante | Lister les enfants d’une autre section |
+
+## Remarques
+
+Le titre, la description et l’icône viennent du frontmatter de chaque enfant :
+une carte ne peut donc pas dériver de la page qu’elle désigne.
diff --git a/docs/fr/4.reference/4.mdc-components/14.partial.md b/docs/fr/4.reference/4.mdc-components/14.partial.md
new file mode 100644
index 00000000..904125c2
--- /dev/null
+++ b/docs/fr/4.reference/4.mdc-components/14.partial.md
@@ -0,0 +1,36 @@
+---
+title: Partial
+description: Un bloc écrit une fois et rendu dans chaque source qui en a besoin.
+icon: lucide:puzzle
+---
+
+En ligne, et le seul composant qui sort de la source de la page elle-même :
+`_partials/` dans **n’importe quelle** source alimente une seule collection
+partagée.
+
+## Utilisation
+
+```mdc
+:partial{name="install"}
+```
+
+## API
+
+### Props
+
+| Prop | Type | Par défaut | Remarques |
+| :----- | :------- | :--------- | :--------------------------------------- |
+| `name` | `string` | — | Le fichier dans `_partials/`, sans `.md` |
+
+## Remarques
+
+Content ne livre aucune directive d’inclusion, et d’un dépôt à l’autre c’est un
+manque sans contournement : une note d’installation qui doit se lire à
+l’identique dans trois projets est copiée dans trois projets et dérive. Un bloc
+défini dans un dépôt est appelable depuis les pages d’un autre.
+
+Traduit comme une page. Une source qui déclare `locales` obtient une collection
+de fragments par langue — `docs/de/_partials/install.md` est le `install`
+allemand — et le composant se replie exactement comme la page qui l’entoure,
+jusqu’à l’original non traduit. N’écrivez que les langues dont vous disposez ;
+le reste se résout vers le haut.
diff --git a/docs/fr/4.reference/4.mdc-components/15.since.md b/docs/fr/4.reference/4.mdc-components/15.since.md
new file mode 100644
index 00000000..f037c081
--- /dev/null
+++ b/docs/fr/4.reference/4.mdc-components/15.since.md
@@ -0,0 +1,34 @@
+---
+title: Since
+description: La version dans laquelle une chose est arrivée, à côté d’un titre.
+icon: lucide:milestone
+---
+
+En ligne. Marque la version dans laquelle une chose est arrivée — utilisable à
+l’intérieur d’un titre.
+
+## Utilisation
+
+### Réessais :since{version="v2.0.0"}
+
+```mdc
+### Réessais :since{version="v2.0.0"}
+```
+
+## API
+
+### Props
+
+| Prop | Type | Par défaut | Remarques |
+| :-------- | :------- | :--------- | :--------------- |
+| `version` | `string` | — | Affiché tel quel |
+
+## Remarques
+
+Il se rattache au modèle de versions au lieu de le répéter : ce que dit le badge,
+c’est la version dans laquelle une chose est ARRIVÉE, et c’est un fait sur l’API,
+pas sur la page où elle est documentée — il reste donc juste dans chaque version
+de la documentation qui porte la phrase.
+
+Pour tout le reste, `:badge[…]` appelle directement le Badge du thème lui-même ;
+MDC résout par son nom n’importe quel composant enregistré globalement.
diff --git a/docs/fr/4.reference/4.mdc-components/16.mermaid.md b/docs/fr/4.reference/4.mdc-components/16.mermaid.md
new file mode 100644
index 00000000..475345b5
--- /dev/null
+++ b/docs/fr/4.reference/4.mdc-components/16.mermaid.md
@@ -0,0 +1,45 @@
+---
+title: Mermaid
+description: Un diagramme depuis un bloc ou une prop.
+icon: lucide:workflow
+---
+
+Un bloc ```` ```mermaid ```` est un diagramme ; `::mermaid` avec une prop `code`
+est le même composant.
+
+## Utilisation
+
+```mermaid
+flowchart LR
+ A[app.config.ts] --> B[resolveSources]
+ B --> C[collections]
+ B --> D[resolvedSources]
+ C --> E[Content]
+ D --> F[theme]
+```
+
+`````md
+```mermaid
+flowchart LR
+ A[app.config.ts] --> B[resolveSources]
+ B --> C[collections]
+```
+`````
+
+## API
+
+### Props
+
+| Prop | Type | Par défaut | Remarques |
+| :----- | :------- | :--------- | :--------------------------------------------------- |
+| `code` | `string` | — | Le code du diagramme, quand on n’utilise pas de bloc |
+
+## Remarques
+
+Chargé à la demande et seulement dans le navigateur : mermaid pèse environ un
+demi-mégaoctet et embarque son propre analyseur, et un site où trois pages sur
+soixante portent un diagramme ne doit pas faire payer les cinquante-sept autres.
+
+Cela veut dire aussi que le diagramme est rendu côté client — la sortie du
+serveur est le texte source, et c’est le repli honnête pour un lecteur sans
+JavaScript.
diff --git a/docs/fr/4.reference/4.mdc-components/17.prose/1.prose-a.md b/docs/fr/4.reference/4.mdc-components/17.prose/1.prose-a.md
new file mode 100644
index 00000000..99a784fd
--- /dev/null
+++ b/docs/fr/4.reference/4.mdc-components/17.prose/1.prose-a.md
@@ -0,0 +1,36 @@
+---
+title: ProseA
+description: Un lien, résolu selon la source dans laquelle il a été écrit.
+icon: lucide:link
+---
+
+Chaque lien d’une page Markdown passe par ce composant.
+
+## Ce qu’il ajoute
+
+Un chemin de documentation est réécrit pour porter le préfixe de la source d’où
+vient la page, plus la locale du lecteur. Une URL externe est laissée telle
+quelle et reçoit le nouvel onglet et la flèche.
+
+## Pourquoi
+
+Markdown n’a aucune idée qu’il est servi sous un préfixe. `/getting-started`
+écrit dans le `docs/` de ce dépôt est correct sur un site à une seule source et
+un 404 sur un site à deux, où la même page vit sur `/duxt/getting-started` — et
+l’auteur du Markdown, qui peut être un tout autre dépôt, ne peut pas savoir
+lequel des deux.
+
+Pire, le même fichier est servi sous plusieurs préfixes à la fois dès qu’il est
+publié en plus d’une version : un lien ne peut pas être réécrit à la
+compilation, car il n’y a pas de réponse juste unique. Il est résolu à chaque
+rendu, selon la source depuis laquelle la page est lue — ainsi un lien à
+l’intérieur de `v0.7.0` garde le lecteur dans `v0.7.0`.
+
+## API
+
+### Props
+
+| Prop | Type | Remarques |
+| :------- | :-------- | :--------------------------------------- |
+| `href` | `string` | Tel qu’écrit dans le Markdown |
+| `target` | `string` | Défini pour un lien externe |
diff --git a/docs/fr/4.reference/4.mdc-components/17.prose/2.prose-headings.md b/docs/fr/4.reference/4.mdc-components/17.prose/2.prose-headings.md
new file mode 100644
index 00000000..9fb6fc83
--- /dev/null
+++ b/docs/fr/4.reference/4.mdc-components/17.prose/2.prose-headings.md
@@ -0,0 +1,34 @@
+---
+title: ProseH2, ProseH3, ProseH4
+description: Des titres, chacun avec une ancre de permalien.
+icon: lucide:heading
+---
+
+Trois composants, un seul comportement : `ProseH2`, `ProseH3` et `ProseH4`
+rendent les titres à partir desquels est construit le sommaire d’une page.
+
+## Ce qu’ils ajoutent
+
+Une ancre de permalien à côté du titre, visible au survol et atteignable au
+clavier. L’`id` est le slug de Content lui-même, si bien que l’ancre, le
+sommaire et la navigation dans la page s’accordent tous.
+
+## API
+
+### Props
+
+| Prop | Type | Remarques |
+| :--- | :------- | :----------------------------------------- |
+| `id` | `string` | Le slug que Content a généré pour le texte |
+
+### Slots
+
+| Slot | Ce qui y va |
+| :-------- | :---------------- |
+| `default` | Le texte du titre |
+
+## Remarques
+
+`ProseH1` n’est délibérément pas surchargé : le titre de la page est rendu par
+la page, pas par le Markdown, si bien qu’un titre `#` dans le corps serait un
+deuxième `h1` sur la page.
diff --git a/docs/fr/4.reference/4.mdc-components/17.prose/3.prose-img.md b/docs/fr/4.reference/4.mdc-components/17.prose/3.prose-img.md
new file mode 100644
index 00000000..daf11304
--- /dev/null
+++ b/docs/fr/4.reference/4.mdc-components/17.prose/3.prose-img.md
@@ -0,0 +1,41 @@
+---
+title: ProseImg
+description: "Une image : qui suit le thème, et zoomable."
+icon: lucide:image
+---
+
+Chaque image d’une page Markdown.
+
+## Ce qu’il ajoute
+
+**Deux variantes.** La capture d’un éditeur sombre sur une page blanche — ou
+l’inverse — est la chose la plus laide qu’une documentation produise
+couramment, et le CSS n’y peut rien parce que ce sont les pixels qui sont faux,
+pas le cadre. Une page peut nommer un second fichier :
+
+```md
+{dark="/shots/sidebar-dark.png"}
+```
+
+**Cliquer pour zoomer**, sauf si l’image demande le contraire :
+
+```md
+{zoom="false"}
+```
+
+## API
+
+### Props
+
+| Prop | Type | Par défaut | Remarques |
+| :----- | :-------- | :--------- | :-------------------------------------------- |
+| `src` | `string` | — | Tel qu’écrit |
+| `alt` | `string` | — | Tel qu’écrit |
+| `dark` | `string` | — | Un second fichier, servi sous le thème sombre |
+| `zoom` | `boolean` | `true` | `false` laisse l’image inerte |
+
+## Remarques
+
+La variante sombre est nommée explicitement plutôt que devinée par convention.
+Dériver `-dark` d’un nom de fichier fait qu’un fichier manquant devient une
+image cassée, et l’auteur ne l’apprend jamais.
diff --git a/docs/fr/4.reference/4.mdc-components/17.prose/4.prose-pre.md b/docs/fr/4.reference/4.mdc-components/17.prose/4.prose-pre.md
new file mode 100644
index 00000000..554cab42
--- /dev/null
+++ b/docs/fr/4.reference/4.mdc-components/17.prose/4.prose-pre.md
@@ -0,0 +1,46 @@
+---
+title: ProsePre
+description: "Le bloc de code : nom de fichier, icône de langage, bouton de copie."
+icon: lucide:terminal
+---
+
+Chaque bloc de code délimité du site, et le composant qui transforme un bloc
+mermaid en schéma.
+
+## Ce qu’il ajoute
+
+La carte autour du code — le nom de fichier, une icône pour son langage et un
+bouton de copie — plus des numéros de ligne optionnels.
+
+## Numéros de ligne
+
+À activer bloc par bloc, écrit dans son meta :
+
+`````md
+```ts line-numbers
+```
+`````
+
+Pas actifs par défaut : la plupart des extraits d’une documentation font quatre
+lignes, et une gouttière de numéros à côté n’est que du bruit. Les numéros sont
+dessinés par un compteur CSS, si bien qu’un lecteur qui copie le bloc obtient le
+code et non la numérotation.
+
+## Annotations Shiki
+
+Deux fonctionnent sur le bloc lui-même, au-delà des quatre que livre Content :
+
+| Écrit | Fait |
+| :--------------------- | :-------------------------- |
+| ```` ```ts {2,4-6} ```` | Met en évidence ces lignes |
+| ```` ```ts /useDuxtConfig/ ```` | Met en évidence ce mot |
+
+## API
+
+### Props
+
+| Prop | Type | Remarques |
+| :--------- | :------- | :--------------------------------------- |
+| `code` | `string` | La source brute, pour le bouton de copie |
+| `language` | `string` | Issu du bloc |
+| `filename` | `string` | Issu du `[…]` du bloc |
diff --git a/docs/fr/4.reference/4.mdc-components/17.prose/5.prose-table.md b/docs/fr/4.reference/4.mdc-components/17.prose/5.prose-table.md
new file mode 100644
index 00000000..faf3e555
--- /dev/null
+++ b/docs/fr/4.reference/4.mdc-components/17.prose/5.prose-table.md
@@ -0,0 +1,20 @@
+---
+title: ProseTable
+description: Un tableau qui défile à la place de la page.
+icon: lucide:table
+---
+
+Chaque tableau Markdown.
+
+## Ce qu’il ajoute
+
+Un conteneur de défilement autour du tableau, pour qu’un tableau large défile
+dans sa propre boîte au lieu de faire défiler toute la page latéralement.
+
+## API
+
+### Slots
+
+| Slot | Ce qu’il contient |
+| :-------- | :---------------- |
+| `default` | Le tableau |
diff --git a/docs/fr/4.reference/4.mdc-components/17.prose/index.md b/docs/fr/4.reference/4.mdc-components/17.prose/index.md
new file mode 100644
index 00000000..9fe30d92
--- /dev/null
+++ b/docs/fr/4.reference/4.mdc-components/17.prose/index.md
@@ -0,0 +1,16 @@
+---
+title: Surcharges de prose
+description: Ce que la couche change au Markdown ordinaire — liens, titres, images, code et tableaux.
+icon: lucide:type
+---
+
+Ces composants ne sont jamais appelés par leur nom. Content rend le Markdown
+ordinaire à travers eux : en remplacer un change donc l’allure d’un paragraphe,
+d’un lien ou d’un bloc de code ordinaires sur toutes les pages.
+
+Ils sont listés ici pour la même raison que les blocs voisins : un nom documenté
+fait partie de la surface publique de la couche, et votre propre fichier du même
+nom remplace le nôtre.
+
+::page-cards
+::
diff --git a/docs/fr/4.reference/4.mdc-components/2.steps.md b/docs/fr/4.reference/4.mdc-components/2.steps.md
new file mode 100644
index 00000000..8bd1b1b5
--- /dev/null
+++ b/docs/fr/4.reference/4.mdc-components/2.steps.md
@@ -0,0 +1,48 @@
+---
+title: Étapes
+description: Une liste ordonnée dessinée en étapes numérotées sur un rail.
+icon: lucide:list-ordered
+---
+
+Enveloppe une **liste ordonnée** dans une mise en page à étapes. Il numérote la
+liste qu’il enveloppe, quelle qu’elle soit, si bien que le Markdown reste une
+liste ordinaire.
+
+## Utilisation
+
+::preview
+:::steps
+1. Ajoutez la dépendance.
+2. Étendez la couche.
+3. Écrivez du Markdown.
+:::
+
+#code
+```mdc
+::steps
+1. Ajoutez la dépendance.
+2. Étendez la couche.
+3. Écrivez du Markdown.
+::
+```
+::
+
+## API
+
+Aucune prop. Le bloc met en forme son `` enfant.
+
+### Slots
+
+| Slot | Ce qu’il contient |
+| :-------- | :------------------------------------ |
+| `default` | Une liste ordonnée, éléments Markdown |
+
+## Remarques
+
+À sortir dès qu’une étape porte un bloc de code : dans une liste ordonnée
+ordinaire, le bloc écarte le texte de son propre numéro, et cette mise en page
+garde le numéro épinglé à côté.
+
+Les marqueurs sont un compteur CSS sur les éléments de liste, pas du contenu —
+un lecteur qui copie les étapes obtient le texte sans la numérotation dessinée
+deux fois.
diff --git a/docs/fr/4.reference/4.mdc-components/3.code-group.md b/docs/fr/4.reference/4.mdc-components/3.code-group.md
new file mode 100644
index 00000000..68623cad
--- /dev/null
+++ b/docs/fr/4.reference/4.mdc-components/3.code-group.md
@@ -0,0 +1,65 @@
+---
+title: Groupe de code
+description: Plusieurs blocs de code en un seul bloc à onglets, étiquetés par nom de fichier.
+icon: lucide:file-code
+---
+
+Plusieurs blocs de code délimités affichés comme un seul bloc, avec un onglet
+chacun. L’étiquette de chaque onglet est le nom de fichier du bloc lui-même,
+parce que c’est entre cela que le lecteur choisit et que c’est déjà écrit.
+
+## Utilisation
+
+::preview
+:::code-group
+```ts [nuxt.config.ts]
+export default defineNuxtConfig({
+ extends: ['@kirchdev/duxt']
+});
+```
+
+```json [package.json]
+{
+ "devDependencies": {
+ "@kirchdev/duxt": "latest"
+ }
+}
+```
+:::
+
+#code
+`````mdc
+::code-group
+```ts [nuxt.config.ts]
+export default defineNuxtConfig({
+ extends: ['@kirchdev/duxt']
+});
+```
+
+```json [package.json]
+{
+ "devDependencies": { "@kirchdev/duxt": "latest" }
+}
+```
+::
+`````
+::
+
+## API
+
+Aucune prop. Un bloc sans nom de fichier reçoit son langage comme étiquette.
+
+### Slots
+
+| Slot | Ce qu’il contient |
+| :-------- | :------------------------- |
+| `default` | Une suite de blocs de code |
+
+## Remarques
+
+Les enfants sont lus dans les VNodes du slot `default` plutôt qu’enregistrés par
+un composant enfant : un bloc de code est un `ProsePre`, pas quelque chose à qui
+ce bloc peut demander de s’annoncer, et son nom de fichier est une prop de ce
+nœud.
+
+Pour tout ce qui n’est pas du code, utilisez [Tab group](/reference/mdc-components/tab-group).
diff --git a/docs/fr/4.reference/4.mdc-components/4.tab-group.md b/docs/fr/4.reference/4.mdc-components/4.tab-group.md
new file mode 100644
index 00000000..32e9e23f
--- /dev/null
+++ b/docs/fr/4.reference/4.mdc-components/4.tab-group.md
@@ -0,0 +1,60 @@
+---
+title: Groupe d'onglets
+description: Des onglets pour tout ce qui n’est pas du code.
+icon: lucide:panels-top-left
+---
+
+`::tab-group` autour de blocs [`::tab`](/reference/mdc-components/tab) — un
+panneau par onglet, navigable au clavier.
+
+## Utilisation
+
+::preview
+::::tab-group
+:::tab{label="macOS"}
+Tout ce dont la couche a besoin est livré avec Node.
+:::
+
+:::tab{label="Windows"}
+La même chose, et rien dans la compilation ne dépend de la plateforme.
+:::
+::::
+
+#code
+```mdc
+:::tab-group
+ ::tab{label="macOS"}
+ Tout ce dont la couche a besoin est livré avec Node.
+ ::
+
+ ::tab{label="Windows"}
+ La même chose, et rien dans la compilation ne dépend de la plateforme.
+ ::
+:::
+```
+::
+
+## API
+
+Pas de props. Les étiquettes viennent des enfants.
+
+### Slots
+
+| Slot | Ce qu’il contient |
+| :-------- | :---------------- |
+| `default` | Des blocs `::tab` |
+
+## Remarques
+
+**Pas `::tabs`.** Le kit d’interface du thème enregistre déjà un `Tabs` — celui
+de shadcn — et deux composants portant le même nom, ce n’est pas une surcharge
+qu’une compilation résout en votre faveur : Nuxt en garde un seul, et il a gardé
+celui de shadcn, si bien que `::tabs` rendait une primitive sans triggers et
+sans état. Un composant de contenu ne peut pas prendre un nom qui appartient au
+kit d’interface.
+
+Construit sur les primitives de reka-ui plutôt que sur le wrapper de shadcn,
+parce que les étiquettes ne sont pas connues tant que les enfants ne se sont pas
+enregistrés eux-mêmes : MDC les fournit comme contenu de slot, pas comme prop.
+La navigation au clavier, le focus glissant et les rôles ARIA viennent avec
+elles.
diff --git a/docs/fr/4.reference/4.mdc-components/5.tab.md b/docs/fr/4.reference/4.mdc-components/5.tab.md
new file mode 100644
index 00000000..d4584e64
--- /dev/null
+++ b/docs/fr/4.reference/4.mdc-components/5.tab.md
@@ -0,0 +1,38 @@
+---
+title: Tab
+description: Un panneau d’un groupe d’onglets.
+icon: lucide:square
+---
+
+Un panneau à l’intérieur de [`::tab-group`](/reference/mdc-components/tab-group).
+Il n’a aucun sens à lui seul.
+
+## Utilisation
+
+```mdc
+:::tab-group
+ ::tab{label="pnpm"}
+ La couche est une dépendance de développement.
+ ::
+:::
+```
+
+## API
+
+### Props
+
+| Prop | Type | Par défaut | Remarques |
+| :------ | :------- | :--------- | :------------------------------ |
+| `label` | `string` | — | Le texte du trigger de l’onglet |
+
+### Slots
+
+| Slot | Ce qu’il contient |
+| :-------- | :---------------------- |
+| `default` | Le panneau, en Markdown |
+
+## Remarques
+
+Le composant enregistre sa propre étiquette auprès du parent au moment du setup,
+et c’est ce qui permet de construire la liste des triggers à partir d’enfants
+que MDC n’a pas encore rendus.
diff --git a/docs/fr/4.reference/4.mdc-components/6.accordion.md b/docs/fr/4.reference/4.mdc-components/6.accordion.md
new file mode 100644
index 00000000..c17759e7
--- /dev/null
+++ b/docs/fr/4.reference/4.mdc-components/6.accordion.md
@@ -0,0 +1,52 @@
+---
+title: Accordion
+description: Des sections repliables — une FAQ, ou des réponses parmi lesquelles le lecteur choisit.
+icon: lucide:chevrons-up-down
+---
+
+`::accordion` autour de blocs
+[`::accordion-item`](/reference/mdc-components/accordion-item).
+
+## Utilisation
+
+::preview
+::::accordion
+:::accordion-item{label="Un consommateur écrit-il content.config.ts ?" icon="lucide:file-question"}
+Non. `duxt.sources` dans `app.config.ts` est la seule déclaration ; le
+`content.config.ts` de la couche elle-même le lit.
+:::
+
+:::accordion-item{label="Faut-il un pilote SQLite natif ?" icon="lucide:database"}
+Non. La base de données est lue via `node:sqlite`, et c’est pourquoi la
+compilation a besoin de Node 24.
+:::
+::::
+
+#code
+```mdc
+:::accordion
+ ::accordion-item{label="Un consommateur écrit-il content.config.ts ?" icon="lucide:file-question"}
+ Non. `duxt.sources` dans `app.config.ts` est la seule déclaration.
+ ::
+:::
+```
+::
+
+## API
+
+Pas de props.
+
+### Slots
+
+| Slot | Ce qu’il contient |
+| :-------- | :--------------------------- |
+| `default` | Des blocs `::accordion-item` |
+
+## Remarques
+
+`type="multiple"` à dessein : une FAQ où ouvrir une réponse referme celle que
+vous aviez lue à moitié est une moins bonne FAQ.
+
+Sur les primitives de reka-ui, comme l’arborescence et les onglets, pour la même
+raison — un dépliant fait à la main n’a aucune gestion du clavier, aucun
+`aria-expanded` et aucun lien entre le trigger et la région qu’il ouvre.
diff --git a/docs/fr/4.reference/4.mdc-components/7.accordion-item.md b/docs/fr/4.reference/4.mdc-components/7.accordion-item.md
new file mode 100644
index 00000000..3de6013f
--- /dev/null
+++ b/docs/fr/4.reference/4.mdc-components/7.accordion-item.md
@@ -0,0 +1,32 @@
+---
+title: Élément d'accordéon
+description: Une section repliable.
+icon: lucide:chevron-down
+---
+
+Une section à l’intérieur de [`::accordion`](/reference/mdc-components/accordion).
+
+## Utilisation
+
+```mdc
+:::accordion
+ ::accordion-item{label="Pourquoi une couche et pas un module ?" icon="lucide:layers"}
+ Un module ne peut livrer ni pages, ni thème, ni valeur par défaut d’app.config.
+ ::
+:::
+```
+
+## API
+
+### Props
+
+| Prop | Type | Par défaut | Remarques |
+| :------ | :------- | :--------- | :---------------------------------- |
+| `label` | `string` | — | Le texte du trigger |
+| `icon` | `string` | — | Tout nom Iconify, avant l’étiquette |
+
+### Slots
+
+| Slot | Ce qu’il contient |
+| :-------- | :----------------------- |
+| `default` | La réponse, en Markdown |
diff --git a/docs/fr/4.reference/4.mdc-components/8.field-group.md b/docs/fr/4.reference/4.mdc-components/8.field-group.md
new file mode 100644
index 00000000..1e74afe7
--- /dev/null
+++ b/docs/fr/4.reference/4.mdc-components/8.field-group.md
@@ -0,0 +1,43 @@
+---
+title: Groupe de champs
+description: Une liste de champs, en un seul bloc.
+icon: lucide:list-tree
+---
+
+Regroupe des blocs [`::field`](/reference/mdc-components/field) pour qu’ils se
+lisent comme une seule liste à séparateurs plutôt que comme des cartes
+distinctes.
+
+## Utilisation
+
+::preview
+::::field-group
+:::field{name="path" type="string" required}
+Dossier contenant le Markdown, relatif à la racine du dépôt.
+:::
+
+:::field{name="history" type="boolean" default="false"}
+Lire l’historique git de cette source pour « Dernière mise à jour » et les
+contributeurs.
+:::
+::::
+
+#code
+```mdc
+:::field-group
+ ::field{name="path" type="string" required}
+ Dossier contenant le Markdown, relatif à la racine du dépôt.
+ ::
+:::
+```
+::
+
+## API
+
+Aucune prop.
+
+### Slots
+
+| Slot | Ce qu’il contient |
+| :-------- | :------------------ |
+| `default` | Des blocs `::field` |
diff --git a/docs/fr/4.reference/4.mdc-components/9.field.md b/docs/fr/4.reference/4.mdc-components/9.field.md
new file mode 100644
index 00000000..20260391
--- /dev/null
+++ b/docs/fr/4.reference/4.mdc-components/9.field.md
@@ -0,0 +1,50 @@
+---
+title: Champ
+description: Un paramètre, une propriété ou une option, avec son type et sa valeur par défaut.
+icon: lucide:tag
+---
+
+Le composant le plus utilisé qui manque à un thème de documentation : un
+paramètre d’API, avec plus d’explication qu’une ligne de tableau n’en contient.
+
+## Utilisation
+
+::preview
+:::field{name="showRepo" type="boolean" default="false"}
+Force un segment de dépôt même avec un dépôt unique.
+:::
+
+#code
+```mdc
+::field{name="showRepo" type="boolean" default="false"}
+Force un segment de dépôt même avec un dépôt unique.
+::
+```
+::
+
+## API
+
+### Props
+
+| Prop | Type | Par défaut | Remarques |
+| :--------- | :-------- | :--------- | :----------------------------------------------- |
+| `name` | `string` | — | Le nom du paramètre, en mono |
+| `type` | `string` | — | Dessiné à côté, en mono |
+| `default` | `string` | — | Aligné à droite, sous la forme `= value` |
+| `required` | `boolean` | `false` | Dessine un badge au lieu d’une valeur par défaut |
+
+### Slots
+
+| Slot | Ce qu’il contient |
+| :-------- | :-------------------------- |
+| `default` | La description, en Markdown |
+
+## Remarques
+
+Une liste de définitions serait le choix sémantique, et c’est le mauvais ici : le
+nom, le type, la valeur par défaut et le fait qu’il soit requis sont quatre
+choses différentes, et `` ne leur donne qu’un seul emplacement à se partager.
+
+Un tableau reste le meilleur choix pour une longue liste de champs d’une ligne —
+voir [Sources](/reference/sources), où huit champs sont faits pour être comparés
+d’un coup d’œil plutôt que lus un par un.
diff --git a/docs/fr/4.reference/4.mdc-components/index.md b/docs/fr/4.reference/4.mdc-components/index.md
new file mode 100644
index 00000000..61c39395
--- /dev/null
+++ b/docs/fr/4.reference/4.mdc-components/index.md
@@ -0,0 +1,22 @@
+---
+title: Composants MDC
+description: Les blocs qu’une page Markdown peut appeler, une page chacun, avec leurs props.
+icon: lucide:blocks
+---
+
+Tout ce qui se trouve dans `app/components/content/` est appelable depuis le
+Markdown avec la syntaxe MDC — aucun module, pas de MDX. Ceux-ci sont livrés avec
+la couche ; votre propre fichier du même nom en remplace un.
+
+Chaque page ici est bâtie de la même façon : ce qu’est le bloc, un exemple rendu
+comme ce site le rend avec, à côté, le Markdown qui l’a produit, puis ses props,
+ses slots et ses événements.
+
+::page-cards
+::
+
+::callout{type="tip" title="L’imbrication prend un deux-points de plus"}
+Un bloc à l’intérieur d’un bloc s’ouvre avec un deux-points de plus que son
+parent — `:::accordion` autour de `::accordion-item`. La clôture correspond à
+l’ouverture.
+::
diff --git a/docs/fr/4.reference/5.components/1.layout/1.duxt-header.md b/docs/fr/4.reference/5.components/1.layout/1.duxt-header.md
new file mode 100644
index 00000000..9e91e700
--- /dev/null
+++ b/docs/fr/4.reference/5.components/1.layout/1.duxt-header.md
@@ -0,0 +1,27 @@
+---
+title: DuxtHeader
+description: "La barre de navigation : titre, navigation, recherche et liens en icônes."
+icon: lucide:panel-top
+---
+
+La barre en haut de chaque page — le titre du site, les entrées de navigation, la
+recherche, les sélecteurs de version et de locale, et la bascule de thème.
+
+## Utilisation
+
+```vue
+
+```
+
+## API
+
+Aucune prop. Tout vient de la configuration : `title`, `navigation`, `links`,
+`version` et `locales`.
+
+## Remarques
+
+Une entrée sans `to` ni `children` signifie « la documentation ». L’entrée
+« Docs » de la couche est livrée sans `to` à dessein : où vit la documentation
+dépend des préfixes du consommateur, que la couche ne peut pas connaître. Elle se
+résout vers la première section, et c’est ce qui fait fonctionner la barre de
+navigation par défaut aussi bien sur un site versionné que sur un site plat.
diff --git a/docs/fr/4.reference/5.components/1.layout/2.duxt-footer.md b/docs/fr/4.reference/5.components/1.layout/2.duxt-footer.md
new file mode 100644
index 00000000..ef33333b
--- /dev/null
+++ b/docs/fr/4.reference/5.components/1.layout/2.duxt-footer.md
@@ -0,0 +1,31 @@
+---
+title: DuxtFooter
+description: "Le pied de page : le nom du site, ses liens légaux, et la ligne propre à la couche."
+icon: lucide:panel-bottom
+---
+
+Une ligne, et rien de plus que ce qu’un pied de page doit : à qui est ce site, et
+les liens que la loi réclame. La navigation vit dans l’en-tête et dans la barre
+latérale.
+
+## Utilisation
+
+```vue
+
+```
+
+## API
+
+Aucune prop. Lit `footer.copyright`, `footer.legal` et `poweredBy`.
+
+## Remarques
+
+La ligne « Powered by duxt » lit sa version et son dépôt dans le `package.json`
+de duxt lui-même à la compilation — release-please fait monter ce fichier, et une
+deuxième copie du numéro est une copie fausse dès la première publication.
+
+Elle est désactivable, parce qu’une couche à qui l’on ne peut pas dire d’arrêter
+de se nommer est un adware : `poweredBy: false` la retire.
+
+La rangée légale est livrée **vide**. Des mentions légales ne reviennent jamais
+au gabarit.
diff --git a/docs/fr/4.reference/5.components/1.layout/index.md b/docs/fr/4.reference/5.components/1.layout/index.md
new file mode 100644
index 00000000..a426d023
--- /dev/null
+++ b/docs/fr/4.reference/5.components/1.layout/index.md
@@ -0,0 +1,18 @@
+---
+title: Mise en page
+description: Les deux composants qui encadrent une page — la barre de navigation et le pied de page.
+icon: lucide:layout-panel-top
+---
+
+Ce qui entoure la documentation sur chaque page. Ni l’un ni l’autre ne prend de
+prop : ils lisent
+[`useDuxtConfig()`](/reference/composables/config-and-content/use-duxt-config),
+donc ce qu’ils dessinent est ce que dit la configuration.
+
+La rangée de sections sous la barre de navigation est
+[`DuxtSections`](/reference/components/navigation/duxt-sections), classée avec la
+navigation — elle nomme les parties de l’arborescence de documentation, ce qui
+est naviguer, pas encadrer.
+
+::page-cards
+::
diff --git a/docs/fr/4.reference/5.components/2.navigation/1.duxt-sections.md b/docs/fr/4.reference/5.components/2.navigation/1.duxt-sections.md
new file mode 100644
index 00000000..213d7820
--- /dev/null
+++ b/docs/fr/4.reference/5.components/2.navigation/1.duxt-sections.md
@@ -0,0 +1,27 @@
+---
+title: DuxtSections
+description: La deuxième rangée de la barre de navigation — les parties de premier niveau de la documentation.
+icon: lucide:rows-3
+---
+
+La rangée de sections sous l’en-tête, sur les pages de documentation seulement.
+
+## Utilisation
+
+```vue
+
+```
+
+## API
+
+Aucune prop. Lit `sections` ; ne rend rien quand cette liste est vide.
+
+## Remarques
+
+Elle se tient avec la documentation plutôt que dans l’en-tête global, parce que
+la page d’accueil n’a aucune section à montrer.
+
+Une section est rarement la page elle-même — c’est la branche dans laquelle la
+page se trouve. La correspondance exacte annonce donc `aria-current="page"` et un
+ancêtre annonce `true`, ce qui est justement la distinction que `aria-current`
+existe pour faire.
diff --git a/docs/fr/4.reference/5.components/2.navigation/2.duxt-navigation.md b/docs/fr/4.reference/5.components/2.navigation/2.duxt-navigation.md
new file mode 100644
index 00000000..0fafb6c0
--- /dev/null
+++ b/docs/fr/4.reference/5.components/2.navigation/2.duxt-navigation.md
@@ -0,0 +1,34 @@
+---
+title: DuxtNavigation
+description: L’arbre de la barre latérale.
+icon: lucide:list-tree
+---
+
+La liste imbriquée des pages de la section courante.
+
+## Utilisation
+
+```vue
+
+
+
+
+
+```
+
+## API
+
+### Props
+
+| Prop | Type | Remarques |
+| :------ | :------------------------- | :---------------------------------------------- |
+| `items` | `ContentNavigationItem[]` | Obligatoire — une branche de l’arbre de Content |
+
+## Remarques
+
+L’arbre vient de [`useDuxtNavigation()`](/reference/composables/config-and-content/use-duxt-navigation),
+qui interroge la collection servant la route courante — un site versionné obtient
+donc l’arbre de cette version, pas celui de la version par défaut.
diff --git a/docs/fr/4.reference/5.components/2.navigation/3.duxt-navigation-link.md b/docs/fr/4.reference/5.components/2.navigation/3.duxt-navigation-link.md
new file mode 100644
index 00000000..2331a169
--- /dev/null
+++ b/docs/fr/4.reference/5.components/2.navigation/3.duxt-navigation-link.md
@@ -0,0 +1,28 @@
+---
+title: DuxtNavigationLink
+description: Une entrée de la barre latérale.
+icon: lucide:link-2
+---
+
+Une entrée seule, avec son icône, son état actif et — pour une branche — ses
+enfants.
+
+## Utilisation
+
+```vue
+
+```
+
+## API
+
+### Props
+
+| Prop | Type | Remarques |
+| :----- | :----------------------- | :--------------------------------- |
+| `item` | `ContentNavigationItem` | Obligatoire — un nœud de l’arbre |
+
+## Remarques
+
+Vaut la peine d’être remplacé à lui seul quand seule l’apparence d’une entrée ne
+va pas : le remplacer laisse l’arbre, l’état actif et le repliement à
+[`DuxtNavigation`](/reference/components/navigation/duxt-navigation).
diff --git a/docs/fr/4.reference/5.components/2.navigation/4.duxt-breadcrumb.md b/docs/fr/4.reference/5.components/2.navigation/4.duxt-breadcrumb.md
new file mode 100644
index 00000000..97750ea3
--- /dev/null
+++ b/docs/fr/4.reference/5.components/2.navigation/4.duxt-breadcrumb.md
@@ -0,0 +1,27 @@
+---
+title: DuxtBreadcrumb
+description: Le fil au-dessus du titre de page.
+icon: lucide:chevrons-right
+---
+
+La section, puis les groupes éventuels entre elle et la page.
+
+## Utilisation
+
+```vue
+
+```
+
+## API
+
+### Props
+
+| Prop | Type | Remarques |
+| :----- | :------- | :---------------------------------------------------- |
+| `path` | `string` | Obligatoire — le chemin de documentation, sans préfixe |
+
+## Remarques
+
+`breadcrumb: false` dans la configuration le retire sur tout le site : un arbre
+de documentation plat obtient un fil qui ne fait que répéter le nom de sa propre
+section.
diff --git a/docs/fr/4.reference/5.components/2.navigation/5.duxt-toc.md b/docs/fr/4.reference/5.components/2.navigation/5.duxt-toc.md
new file mode 100644
index 00000000..8c6ab01a
--- /dev/null
+++ b/docs/fr/4.reference/5.components/2.navigation/5.duxt-toc.md
@@ -0,0 +1,32 @@
+---
+title: DuxtToc
+description: Le sommaire, et l’encart en dessous.
+icon: lucide:list
+---
+
+Les titres de la page courante, suivis au fil du défilement du lecteur, plus les
+liens `aside` configurés en dessous.
+
+## Utilisation
+
+```vue
+
+```
+
+## API
+
+### Props
+
+| Prop | Type | Remarques |
+| :------ | :---------- | :--------------------------------------------- |
+| `links` | `TocLink[]` | Obligatoire — les liens de sommaire de Content |
+
+## Remarques
+
+Quel titre est le courant vient de
+[`useActiveHeading()`](/reference/composables/reading/use-active-heading), qui
+observe les titres plutôt que le décalage de défilement — une dernière section
+courte est quand même signalée comme atteinte.
+
+Le bloc `aside.links` en dessous est livré **vide** : un lien de dépôt, un
+gestionnaire de tickets ou une communauté appartiennent à qui exploite le site.
diff --git a/docs/fr/4.reference/5.components/2.navigation/6.duxt-page-nav.md b/docs/fr/4.reference/5.components/2.navigation/6.duxt-page-nav.md
new file mode 100644
index 00000000..ffaf5afb
--- /dev/null
+++ b/docs/fr/4.reference/5.components/2.navigation/6.duxt-page-nav.md
@@ -0,0 +1,30 @@
+---
+title: DuxtPageNav
+description: Précédent et suivant, bornés par la section.
+icon: lucide:arrow-left-right
+---
+
+Les deux liens au pied d’une page.
+
+## Utilisation
+
+```vue
+
+```
+
+## API
+
+### Props
+
+| Prop | Type | Remarques |
+| :----- | :------- | :---------------------------------------------------- |
+| `path` | `string` | Obligatoire — le chemin de documentation, sans préfixe |
+
+## Remarques
+
+La navigation s’arrête à la frontière de la section à dessein : passer dans une
+autre partie de la documentation est le travail de la rangée des sections, pas
+celui d’un lien qui dit « suivant ».
+
+Les groupes sont aplatis dans leurs enfants, si bien que « suivant » est la page
+suivante qu’un lecteur lirait, pas le dossier suivant.
diff --git a/docs/fr/4.reference/5.components/2.navigation/index.md b/docs/fr/4.reference/5.components/2.navigation/index.md
new file mode 100644
index 00000000..5413bb5d
--- /dev/null
+++ b/docs/fr/4.reference/5.components/2.navigation/index.md
@@ -0,0 +1,16 @@
+---
+title: Navigation
+description: Se déplacer — la rangée des sections, la barre latérale, le fil d’Ariane, le sommaire et précédent/suivant.
+icon: lucide:compass
+---
+
+Six composants sur un seul arbre. La rangée des sections nomme ses parties de
+premier niveau, la barre latérale parcourt la branche où se trouve le lecteur,
+et les trois derniers répondent à « où suis-je » et « qu’est-ce qui vient
+ensuite ».
+
+La plupart d’entre eux prennent une prop, parce que chacun dessine une partie
+précise de l’arbre plutôt que la configuration entière.
+
+::page-cards
+::
diff --git a/docs/fr/4.reference/5.components/3.controls/1.duxt-search.md b/docs/fr/4.reference/5.components/3.controls/1.duxt-search.md
new file mode 100644
index 00000000..5d756065
--- /dev/null
+++ b/docs/fr/4.reference/5.components/3.controls/1.duxt-search.md
@@ -0,0 +1,27 @@
+---
+title: DuxtSearch
+description: La recherche sur toutes les sources, avec des résultats classés ensemble.
+icon: lucide:search
+---
+
+Le champ de recherche et sa boîte de dialogue, ouverts par le bouton ou par
+⌘K .
+
+## Utilisation
+
+```vue
+
+```
+
+## API
+
+Aucune prop. Construit sur
+[`useDuxtSearch()`](/reference/composables/reading/use-duxt-search).
+
+## Remarques
+
+Les résultats couvrent toutes les sources et sont classés ensemble : un terme
+qui apparaît dans deux dépôts reçoit une réponse des deux. Quand rien ne
+correspond exactement, la liste retombe sur les approximations de
+[`useFuzzySearch()`](/reference/composables/reading/use-fuzzy-search) et le dit —
+une faute de frappe doit renvoyer la page, pas une boîte vide.
diff --git a/docs/fr/4.reference/5.components/3.controls/2.duxt-version.md b/docs/fr/4.reference/5.components/3.controls/2.duxt-version.md
new file mode 100644
index 00000000..342c4fee
--- /dev/null
+++ b/docs/fr/4.reference/5.components/3.controls/2.duxt-version.md
@@ -0,0 +1,32 @@
+---
+title: DuxtVersion
+description: Le badge de version, et le sélecteur quand il y en a plusieurs.
+icon: lucide:layers
+---
+
+Un seul élément à côté du titre : un badge quand il n’y a rien à choisir, le
+même badge en déclencheur de menu déroulant quand il y a de quoi choisir.
+
+## Utilisation
+
+```vue
+
+```
+
+## API
+
+Aucune prop. Lit `versions`, ou le manifeste de sources résolu quand `versions`
+n’est pas défini.
+
+## Remarques
+
+Auparavant, la version du projet tenait en badge à côté du titre pendant que les
+versions de la documentation vivaient dans un menu déroulant séparé tout à
+droite — deux contrôles qui disaient des choses différentes, et celui de droite
+n’apparaissait que sur les sources qui ont des versions, si bien que les icônes
+à côté de lui se déplaçaient quand vous passiez d’un dépôt à l’autre.
+
+Changer de version conserve la page : c’est le préfixe qui est échangé, et non
+le lecteur qui est renvoyé à la racine de la version. Chaque entrée porte son
+cycle de vie, parce qu’une liste où trois entrées se ressemblent ne dit rien de
+celles qu’on peut encore lire sans risque.
diff --git a/docs/fr/4.reference/5.components/3.controls/3.duxt-version-banner.md b/docs/fr/4.reference/5.components/3.controls/3.duxt-version-banner.md
new file mode 100644
index 00000000..cc1eeaf9
--- /dev/null
+++ b/docs/fr/4.reference/5.components/3.controls/3.duxt-version-banner.md
@@ -0,0 +1,31 @@
+---
+title: DuxtVersionBanner
+description: Le bandeau qui dit au lecteur que cette version n’est pas la version courante.
+icon: lucide:triangle-alert
+---
+
+Affiché au-dessus de la page quand la version lue n’est pas celle par défaut.
+
+## Utilisation
+
+```vue
+
+```
+
+## API
+
+Aucune prop. Lit
+[`useDuxtVersion()`](/reference/composables/paths-and-versions/use-duxt-version).
+
+## Remarques
+
+La moitié visible du problème des versions. `canonical` et `noindex` disent à un
+robot quelle version servir ; au lecteur, ils ne disent rien. Quelqu’un arrivé
+sur la v0.7.0 depuis un résultat de recherche, ou depuis un lien dans un vieux
+ticket, voit une documentation qui ressemble exactement à celle de la version
+courante. Chaque moitié prise seule laisse un public dans l’erreur : les balises
+du head et ce bandeau sont donc livrés ensemble.
+
+Sur une version `upcoming`, on dit au lecteur que la documentation peut encore
+changer, et on ne lui dit **pas** de mettre à jour — c’est le seul état qu’on ne
+doit jamais atteindre en devinant.
diff --git a/docs/fr/4.reference/5.components/3.controls/4.duxt-locale.md b/docs/fr/4.reference/5.components/3.controls/4.duxt-locale.md
new file mode 100644
index 00000000..cc7d80db
--- /dev/null
+++ b/docs/fr/4.reference/5.components/3.controls/4.duxt-locale.md
@@ -0,0 +1,28 @@
+---
+title: DuxtLocale
+description: Le sélecteur de langue.
+icon: lucide:languages
+---
+
+Rien du tout quand le site sert une seule langue, une liste déroulante quand il
+en sert plusieurs.
+
+## Utilisation
+
+```vue
+
+```
+
+## API
+
+Aucune prop. Lit `locales`.
+
+## Remarques
+
+Construit comme [`DuxtVersion`](/reference/components/controls/duxt-version) à
+côté de lui, si bien qu’un consommateur à une seule locale ne voit jamais que
+duxt parle sept langues.
+
+Il utilise `switchLocalePath` plutôt qu’un lien vers la racine de la locale :
+changer de langue sur une page traitant du déploiement doit atterrir sur cette
+même page dans la nouvelle langue, pas renvoyer le lecteur à la porte d’entrée.
diff --git a/docs/fr/4.reference/5.components/3.controls/5.duxt-progress.md b/docs/fr/4.reference/5.components/3.controls/5.duxt-progress.md
new file mode 100644
index 00000000..addf95d0
--- /dev/null
+++ b/docs/fr/4.reference/5.components/3.controls/5.duxt-progress.md
@@ -0,0 +1,28 @@
+---
+title: DuxtProgress
+description: Jusqu’où le lecteur est descendu dans la page.
+icon: lucide:loader
+---
+
+Un filet de deux pixels sous l’en-tête.
+
+## Utilisation
+
+```vue
+
+```
+
+## API
+
+Aucune prop.
+
+## Remarques
+
+Un filet plutôt qu’un nombre : sur une longue page de référence, la question
+utile est « suis-je près de la fin », et un pourcentage y répond moins bien
+qu’une ligne. Il est `aria-hidden`, parce qu’il ne dit rien qu’un lecteur d’écran
+ne puisse déjà demander au document.
+
+Mesuré par rapport au document plutôt qu’à un conteneur de défilement, si bien
+qu’il reste juste aussi bien sur la mise en page de la documentation que sur la
+page d’accueil.
diff --git a/docs/fr/4.reference/5.components/3.controls/6.duxt-shortcuts.md b/docs/fr/4.reference/5.components/3.controls/6.duxt-shortcuts.md
new file mode 100644
index 00000000..d393cff5
--- /dev/null
+++ b/docs/fr/4.reference/5.components/3.controls/6.duxt-shortcuts.md
@@ -0,0 +1,25 @@
+---
+title: DuxtShortcuts
+description: La fiche des raccourcis clavier, sur ?.
+icon: lucide:keyboard
+---
+
+La superposition qui liste chaque touche que le thème associe, ouverte avec
+? .
+
+## Utilisation
+
+```vue
+
+```
+
+## API
+
+Aucune prop.
+
+## Remarques
+
+Sa liste est `duxtShortcuts` — le même tableau auquel les gestionnaires se
+réfèrent — si bien que la fiche ne peut pas finir par annoncer une touche que
+rien n’associe. Voir
+[`useDuxtShortcuts()`](/reference/composables/reading/use-duxt-shortcuts).
diff --git a/docs/fr/4.reference/5.components/3.controls/7.duxt-skip-link.md b/docs/fr/4.reference/5.components/3.controls/7.duxt-skip-link.md
new file mode 100644
index 00000000..17fc1acb
--- /dev/null
+++ b/docs/fr/4.reference/5.components/3.controls/7.duxt-skip-link.md
@@ -0,0 +1,28 @@
+---
+title: DuxtSkipLink
+description: Le premier élément focalisable de la page.
+icon: lucide:skip-forward
+---
+
+« Aller au contenu », invisible jusqu’à ce qu’il reçoive le focus.
+
+## Utilisation
+
+```vue
+
+```
+
+## API
+
+Aucune prop.
+
+## Remarques
+
+Sans lui, chaque changement de page oblige à parcourir à la tabulation l’en-tête,
+la rangée des sections et toute la barre latérale avant le premier mot du texte.
+`sr-only` le réduit à un seul pixel, et l’état de focus le remet à l’écran
+au-dessus de tout le reste.
+
+Le clic est géré plutôt que laissé au hash, parce que le navigateur ne déplace le
+focus pour un lien de hash que dans certains navigateurs — le gestionnaire le
+déplace dans tous.
diff --git a/docs/fr/4.reference/5.components/3.controls/index.md b/docs/fr/4.reference/5.components/3.controls/index.md
new file mode 100644
index 00000000..503f5537
--- /dev/null
+++ b/docs/fr/4.reference/5.components/3.controls/index.md
@@ -0,0 +1,19 @@
+---
+title: Contrôles
+description: Ce qu’actionne un lecteur — la recherche, les sélecteurs, la fiche des raccourcis et deux indicateurs.
+icon: lucide:sliders-horizontal
+---
+
+Chacun d’eux est un contrôle que le lecteur actionne, ou un état qu’il lit,
+plutôt qu’une partie de la structure de la page. Aucun ne prend de prop : ils
+lisent
+[`useDuxtConfig()`](/reference/composables/config-and-content/use-duxt-config)
+et ne dessinent rien quand la configuration ne leur donne rien à offrir.
+
+C’est ce dernier point qui vaut d’être connu avant d’en remplacer un. Un
+sélecteur à un seul choix n’est pas un sélecteur : `DuxtVersion` dessine donc un
+badge et `DuxtLocale` ne dessine rien du tout — un site à une seule locale ne
+voit jamais que duxt parle sept langues.
+
+::page-cards
+::
diff --git a/docs/fr/4.reference/5.components/4.page/1.duxt-page-info.md b/docs/fr/4.reference/5.components/4.page/1.duxt-page-info.md
new file mode 100644
index 00000000..3b3417ab
--- /dev/null
+++ b/docs/fr/4.reference/5.components/4.page/1.duxt-page-info.md
@@ -0,0 +1,35 @@
+---
+title: DuxtPageInfo
+description: "D’où vient la page : lien d’édition, dernière mise à jour, contributeurs."
+icon: lucide:git-commit-horizontal
+---
+
+La provenance, sous le sommaire.
+
+## Utilisation
+
+```vue
+
+```
+
+## API
+
+### Props
+
+| Prop | Type | Remarques |
+| :----- | :------- | :--------------------------------------------------- |
+| `page` | `object` | Obligatoire — le document Content en cours de rendu |
+
+## Remarques
+
+Pas au pied de l’article, où il se trouvait d’abord et où il se lisait comme une
+pensée après coup, sous les liens précédent/suivant. « Modifier cette page », la
+date du dernier changement et qui l’a écrite ne font qu’une seule question — la
+provenance — et la colonne de droite est déjà celle qui répond à ce qu’une page
+EST plutôt qu’à ce qu’elle dit.
+
+Tout ici est dérivé. Le lien d’édition pointe vers le dépôt de la source et la
+ref d’où la page a été lue, si bien qu’une page venant d’un tag renvoie vers sa
+source plutôt que vers un formulaire d’édition qui modifierait la mauvaise
+branche. « Dernière mise à jour » et les contributeurs nécessitent
+`history: true` sur la source.
diff --git a/docs/fr/4.reference/5.components/4.page/2.duxt-copy-page.md b/docs/fr/4.reference/5.components/4.page/2.duxt-copy-page.md
new file mode 100644
index 00000000..d2b4fee0
--- /dev/null
+++ b/docs/fr/4.reference/5.components/4.page/2.duxt-copy-page.md
@@ -0,0 +1,40 @@
+---
+title: DuxtCopyPage
+description: Donnez cette page à un modèle, ou lisez sa source.
+icon: lucide:clipboard-copy
+---
+
+Un contrôle scindé à côté du titre de la page : copier le Markdown, le consulter,
+ou l’ouvrir dans un modèle conversationnel.
+
+## Utilisation
+
+```vue
+
+```
+
+## API
+
+### Props
+
+| Prop | Type | Remarques |
+| :-------- | :------- | :---------------------------------------------- |
+| `path` | `string` | Obligatoire — le chemin de la documentation |
+| `title` | `string` | Utilisé dans l’invite remise à un modèle |
+| `rawbody` | `string` | La source Markdown, pour le bouton de copie |
+
+## Remarques
+
+Un contrôle scindé plutôt que quatre boutons — le cas courant est « copier », et
+les trois autres sont la même intention par une autre porte.
+
+Ce qu’ils remettent tous, c’est le **Markdown**, pas la page rendue : un modèle à
+qui l’on donne du HTML doit défaire une mise en page pour trouver un titre, et un
+modèle à qui l’on donne la source lit ce que l’auteur a écrit. Le frontmatter est
+retiré, si bien que ce qui est copié se lit comme la page plutôt que comme le
+fichier.
+
+Les liens externes ont besoin d’une URL absolue, et l’origine est lue depuis le
+navigateur au moment du clic plutôt que depuis la configuration — la seule valeur
+qui soit certainement juste, y compris sur un déploiement de prévisualisation
+dont personne n’a noté le domaine.
diff --git a/docs/fr/4.reference/5.components/4.page/3.duxt-page-feedback.md b/docs/fr/4.reference/5.components/4.page/3.duxt-page-feedback.md
new file mode 100644
index 00000000..700d0536
--- /dev/null
+++ b/docs/fr/4.reference/5.components/4.page/3.duxt-page-feedback.md
@@ -0,0 +1,40 @@
+---
+title: DuxtPageFeedback
+description: "« Cette page vous a-t-elle été utile ? » — un événement, et pas de backend."
+icon: lucide:thumbs-up
+---
+
+Le bloc au pied de chaque page.
+
+## Utilisation
+
+```vue
+ track(helpful)" />
+```
+
+## API
+
+### Emits
+
+| Événement | Payload | Quand |
+| :--------- | :------------------ | :------------------------- |
+| `feedback` | `helpful: boolean` | Le lecteur répond |
+
+### Slots
+
+| Slot | Props | Ce qu’il remplace |
+| :-------- | :--------------------- | :------------------------- |
+| `default` | `{ answered, answer }` | La question et les boutons |
+
+## Remarques
+
+Un slot avec un événement et pas de backend, et c’est là tout le design. La
+couche ne stocke rien et n’envoie rien : ce n’est pas son affaire de savoir où
+vit l’analytique d’un consommateur, et un thème de documentation qui fait
+remonter des données par défaut n’est pas un thème à publier. La réponse vit dans
+l’état propre du composant le temps de la visite et nulle part ailleurs — un
+changement de route la réinitialise, car une nouvelle page est une nouvelle
+question.
+
+Le brancher a son propre guide :
+[Recueillir des retours](/guides/collect-page-feedback).
diff --git a/docs/fr/4.reference/5.components/4.page/4.duxt-code-block.md b/docs/fr/4.reference/5.components/4.page/4.duxt-code-block.md
new file mode 100644
index 00000000..4bdde780
--- /dev/null
+++ b/docs/fr/4.reference/5.components/4.page/4.duxt-code-block.md
@@ -0,0 +1,40 @@
+---
+title: DuxtCodeBlock
+description: La carte autour d’un bloc de code.
+icon: lucide:code
+---
+
+Le cadre dans lequel chaque bloc délimité est dessiné : nom de fichier, icône de
+langage, bouton de copie.
+
+## Utilisation
+
+Appelé par [`ProsePre`](/reference/mdc-components/prose/prose-pre) plutôt qu’à la
+main, mais utilisable directement :
+
+```vue
+
+```
+
+## API
+
+### Props
+
+| Prop | Type | Remarques |
+| :--------- | :------- | :------------------------------------------------------- |
+| `code` | `string` | La source brute, pour le bouton de copie et comme corps |
+| `language` | `string` | Choisit l’icône |
+| `filename` | `string` | Le libellé dans l’en-tête |
+
+### Slots
+
+| Slot | Ce qu’il contient |
+| :-------- | :-------------------------------------- |
+| `default` | Le balisage colorisé, quand il y en a |
+
+## Remarques
+
+Sans nom de fichier, le bouton de copie flotte dans le coin et apparaît au
+survol ; avec un nom, il siège dans l’en-tête, où il est toujours visible. Un
+bloc qui affiche en permanence un bouton dans le coin de la page fait plus de
+bruit qu’un bloc qui le montre quand un lecteur va le chercher.
diff --git a/docs/fr/4.reference/5.components/4.page/index.md b/docs/fr/4.reference/5.components/4.page/index.md
new file mode 100644
index 00000000..66b0a089
--- /dev/null
+++ b/docs/fr/4.reference/5.components/4.page/index.md
@@ -0,0 +1,11 @@
+---
+title: Page
+description: Les composants qui appartiennent à la page elle-même.
+icon: lucide:file-text
+---
+
+La provenance, le contrôle de copie, le bloc de retours et la carte autour d’un
+bloc de code.
+
+::page-cards
+::
diff --git a/docs/fr/4.reference/5.components/index.md b/docs/fr/4.reference/5.components/index.md
new file mode 100644
index 00000000..4b5d978e
--- /dev/null
+++ b/docs/fr/4.reference/5.components/index.md
@@ -0,0 +1,24 @@
+---
+title: Composants
+description: Les composants propres à la couche, une page chacun — à quoi sert chacun, et ce qu’il prend.
+icon: lucide:component
+---
+
+Chaque composant ici est **remplaçable** : un fichier du même nom dans votre
+projet remplace celui de la couche, sans aucune configuration. Un nom documenté
+ici fait partie de la surface publique, il ne sera donc pas renommé sans une
+version majeure.
+
+Les pages sont groupées par ce que fait un composant : les deux qui encadrent une
+page, les six qui parcourent son arbre, les contrôles qu’actionne un lecteur, et
+les composants qui appartiennent à la page elle-même.
+
+::page-cards
+::
+
+::callout{type="tip" title="Remplacer le moins possible"}
+Avant de remplacer un composant, vérifiez si une clé de configuration, un slot ou
+un token CSS ne fait pas déjà ce dont vous avez besoin — voir
+[Surcharger le thème](/guides/override-the-theme). Un fichier remplacé cesse de
+recevoir les correctifs de la couche.
+::
diff --git a/docs/fr/4.reference/6.composables/1.config-and-content/1.use-duxt-config.md b/docs/fr/4.reference/6.composables/1.config-and-content/1.use-duxt-config.md
new file mode 100644
index 00000000..98310464
--- /dev/null
+++ b/docs/fr/4.reference/6.composables/1.config-and-content/1.use-duxt-config.md
@@ -0,0 +1,31 @@
+---
+title: useDuxtConfig()
+description: La configuration fusionnée, chaque champ de texte déjà résolu.
+icon: lucide:settings
+---
+
+Celui vers lequel se tourner dans un composant remplacé.
+
+## Signature
+
+```ts
+const duxt = useDuxtConfig();
+```
+
+## Retourne
+
+Toute la configuration, réactive : les valeurs par défaut de la couche avec votre
+`app.config.ts` fusionné par-dessus, et chaque champ de texte déjà une chaîne.
+
+## Remarques
+
+Deux choses se produisent ici qu’un composant ne devrait jamais avoir à
+connaître. La fusion d’`app.config` propre à Nuxt **ajoute** les tableaux au lieu
+de les remplacer, si bien qu’un consommateur surchargeant `navigation`
+obtiendrait les entrées de la couche après les siennes ; cette fusion-ci les
+remplace. Et un libellé peut être un littéral, une clé i18n ou un enregistrement
+par locale — les trois se réduisent à une chaîne, si bien qu’un template ne
+rencontre jamais une clé.
+
+Le résultat reste vivant, si bien que changer de langue met à jour la barre de
+navigation sans rechargement complet de la page.
diff --git a/docs/fr/4.reference/6.composables/1.config-and-content/2.use-duxt-collection.md b/docs/fr/4.reference/6.composables/1.config-and-content/2.use-duxt-collection.md
new file mode 100644
index 00000000..56fd3bf8
--- /dev/null
+++ b/docs/fr/4.reference/6.composables/1.config-and-content/2.use-duxt-collection.md
@@ -0,0 +1,26 @@
+---
+title: useDuxtCollection()
+description: Quelle collection sert cette route.
+icon: lucide:library
+---
+
+## Signature
+
+```ts
+const { collection, source, sources } = useDuxtCollection();
+```
+
+## Retour
+
+| Valeur | Type | Ce que c’est |
+| :----------- | :------- | :------------------------------------------------ |
+| `collection` | `string` | Le nom de la collection servant la route courante |
+| `source` | `object` | La source résolue derrière elle |
+| `sources` | `array` | Toutes les sources résolues |
+
+## Remarques
+
+`collection` est une **chaîne**, pas un type littéral — les noms de collections
+d’un site sont générés à partir de ses propres sources, si bien que rien dans la
+couche ne peut les nommer. Passez-la à `queryCollection` plutôt que d’écrire
+`'docs'`.
diff --git a/docs/fr/4.reference/6.composables/1.config-and-content/3.use-duxt-navigation.md b/docs/fr/4.reference/6.composables/1.config-and-content/3.use-duxt-navigation.md
new file mode 100644
index 00000000..d0c278ef
--- /dev/null
+++ b/docs/fr/4.reference/6.composables/1.config-and-content/3.use-duxt-navigation.md
@@ -0,0 +1,22 @@
+---
+title: useDuxtNavigation()
+description: L’arbre de navigation de la collection qui sert cette route.
+icon: lucide:list-tree
+---
+
+## Signature
+
+```ts
+const { data: navigation } = await useDuxtNavigation();
+```
+
+## Retour
+
+Un handle `useAsyncData` sur l’arbre de navigation de la collection qui sert la
+route courante.
+
+## Remarques
+
+Demander par route plutôt qu’une fois pour le site est ce qui rend correct un
+arbre versionné : lire `v0.7.0` donne les pages de cette version-là, pas celles
+de la version par défaut.
diff --git a/docs/fr/4.reference/6.composables/1.config-and-content/4.use-duxt-section.md b/docs/fr/4.reference/6.composables/1.config-and-content/4.use-duxt-section.md
new file mode 100644
index 00000000..b40dac6a
--- /dev/null
+++ b/docs/fr/4.reference/6.composables/1.config-and-content/4.use-duxt-section.md
@@ -0,0 +1,23 @@
+---
+title: useDuxtSection()
+description: La partie de la documentation dans laquelle se trouve cette page.
+icon: lucide:folder-open
+---
+
+## Signature
+
+```ts
+const { section, items } = useDuxtSection(navigation);
+```
+
+## Retour
+
+| Valeur | Type | Ce que c’est |
+| :-------- | :------- | :---------------------------------------------------- |
+| `section` | `object` | La section configurée sous laquelle se trouve la page |
+| `items` | `array` | La branche de l’arbre correspondant à cette section |
+
+## Remarques
+
+Sans `sections` configurées, c’est l’arbre entier qui revient, ce qui est la
+bonne forme pour une documentation qui n’est pas découpée en parties.
diff --git a/docs/fr/4.reference/6.composables/1.config-and-content/5.use-duxt-breadcrumb.md b/docs/fr/4.reference/6.composables/1.config-and-content/5.use-duxt-breadcrumb.md
new file mode 100644
index 00000000..01b0f29c
--- /dev/null
+++ b/docs/fr/4.reference/6.composables/1.config-and-content/5.use-duxt-breadcrumb.md
@@ -0,0 +1,20 @@
+---
+title: useDuxtBreadcrumb()
+description: Le fil qui descend de la section jusqu’à une page.
+icon: lucide:chevrons-right
+---
+
+## Signature
+
+```ts
+const trail = useDuxtBreadcrumb(path);
+```
+
+## Retour
+
+Les entrées entre la section et la page, chacune avec son titre et son chemin.
+
+## Remarques
+
+Un groupe qui ne porte pas de page à lui apparaît dans le fil sans lien — le
+dossier est réel, la page derrière lui ne l’est pas.
diff --git a/docs/fr/4.reference/6.composables/1.config-and-content/index.md b/docs/fr/4.reference/6.composables/1.config-and-content/index.md
new file mode 100644
index 00000000..87221870
--- /dev/null
+++ b/docs/fr/4.reference/6.composables/1.config-and-content/index.md
@@ -0,0 +1,11 @@
+---
+title: Configuration et contenu
+description: La configuration fusionnée, et quelle collection sert la page en cours de lecture.
+icon: lucide:database
+---
+
+Commencez ici dans un composant remplacé : ces cinq-là répondent à « que dit ce
+site » et « de quoi cette page fait-elle partie ».
+
+::page-cards
+::
diff --git a/docs/fr/4.reference/6.composables/2.paths-and-versions/1.use-duxt-path.md b/docs/fr/4.reference/6.composables/2.paths-and-versions/1.use-duxt-path.md
new file mode 100644
index 00000000..2fdf9cdd
--- /dev/null
+++ b/docs/fr/4.reference/6.composables/2.paths-and-versions/1.use-duxt-path.md
@@ -0,0 +1,21 @@
+---
+title: useDuxtPath()
+description: Le chemin où se trouve la documentation, locale retirée.
+icon: lucide:milestone
+---
+
+## Signature
+
+```ts
+const path = useDuxtPath();
+```
+
+## Retour
+
+Le chemin de la route courante, segment de locale retiré — le chemin tel que la
+documentation le connaît.
+
+## Remarques
+
+Tout ce qui raisonne sur les pages raisonne là-dessus, pas sur `route.path` :
+`/de/duxt/getting-started` et `/duxt/getting-started` sont la même page.
diff --git a/docs/fr/4.reference/6.composables/2.paths-and-versions/2.use-duxt-link.md b/docs/fr/4.reference/6.composables/2.paths-and-versions/2.use-duxt-link.md
new file mode 100644
index 00000000..acdcf4ac
--- /dev/null
+++ b/docs/fr/4.reference/6.composables/2.paths-and-versions/2.use-duxt-link.md
@@ -0,0 +1,26 @@
+---
+title: useDuxtLink()
+description: Un chemin de documentation, réécrit en lien.
+icon: lucide:link
+---
+
+L’inverse de [`useDuxtPath()`](/reference/composables/paths-and-versions/use-duxt-path).
+
+## Signature
+
+```ts
+const localeLink = useDuxtLink();
+
+localeLink('/getting-started');
+```
+
+## Retour
+
+Une fonction qui remet le préfixe de locale sur un chemin de documentation, et
+laisse une URL absolue telle quelle.
+
+## Remarques
+
+Utilisez-la pour chaque `to` que rend un composant remplacé. Un chemin écrit à
+la main dans un composant est correct dans une langue et faux dans les six
+autres.
diff --git a/docs/fr/4.reference/6.composables/2.paths-and-versions/3.use-duxt-version.md b/docs/fr/4.reference/6.composables/2.paths-and-versions/3.use-duxt-version.md
new file mode 100644
index 00000000..e0bd47be
--- /dev/null
+++ b/docs/fr/4.reference/6.composables/2.paths-and-versions/3.use-duxt-version.md
@@ -0,0 +1,31 @@
+---
+title: useDuxtVersion()
+description: Ce que montre le sélecteur, ce que dit la bannière, s’il faut indexer.
+icon: lucide:layers
+---
+
+## Signature
+
+```ts
+const version = useDuxtVersion();
+```
+
+## Retour
+
+| Valeur | Ce à quoi elle répond |
+| :-------------- | :---------------------------------------------- |
+| `sources` | Toutes les versions du dépôt lu |
+| `current` | La version à laquelle appartient cette page |
+| `preferred` | La version sur laquelle un lecteur devrait être |
+| `isPreferred` | Si ces deux-là sont la même |
+| `status` | Son cycle de vie — upcoming, current, eol … |
+| `kind` | Branche ou tag |
+| `shouldIndex` | Si la page demande à être indexée |
+| `shouldWarn` | Si la bannière s’affiche |
+| `preferredPath` | Cette même page, dans la version préférée |
+
+## Remarques
+
+Trois questions au même endroit — ce que montre le sélecteur, ce que dit la
+bannière, et si la page demande à être indexée — parce que c’est la même
+question à propos de la même version.
diff --git a/docs/fr/4.reference/6.composables/2.paths-and-versions/4.use-duxt-site-url.md b/docs/fr/4.reference/6.composables/2.paths-and-versions/4.use-duxt-site-url.md
new file mode 100644
index 00000000..402eac15
--- /dev/null
+++ b/docs/fr/4.reference/6.composables/2.paths-and-versions/4.use-duxt-site-url.md
@@ -0,0 +1,23 @@
+---
+title: useDuxtSiteUrl()
+description: L’origine configurée, et les chemins rendus absolus.
+icon: lucide:globe
+---
+
+## Signature
+
+```ts
+const { origin, absolute } = useDuxtSiteUrl();
+```
+
+## Retour
+
+| Valeur | Type | Ce que c’est |
+| :--------- | :--------- | :----------------------------------------- |
+| `origin` | `string` | L’origine configurée du site |
+| `absolute` | `function` | Un chemin, rendu absolu par rapport à elle |
+
+## Remarques
+
+Nécessaire partout où une URL quitte la page — un élément de flux, une balise
+canonical, une image OG. À l’intérieur de la page, utilisez un chemin.
diff --git a/docs/fr/4.reference/6.composables/2.paths-and-versions/index.md b/docs/fr/4.reference/6.composables/2.paths-and-versions/index.md
new file mode 100644
index 00000000..c17c6da5
--- /dev/null
+++ b/docs/fr/4.reference/6.composables/2.paths-and-versions/index.md
@@ -0,0 +1,12 @@
+---
+title: Chemins et versions
+description: Lire une URL, en écrire une en retour, et savoir quelle version est lue.
+icon: lucide:route
+---
+
+Un chemin de documentation et une URL ne sont pas la même chaîne : l’URL porte
+la locale, le dépôt et la version. Ces quatre-là sont la jointure entre les
+deux.
+
+::page-cards
+::
diff --git a/docs/fr/4.reference/6.composables/3.reading/1.use-duxt-search.md b/docs/fr/4.reference/6.composables/3.reading/1.use-duxt-search.md
new file mode 100644
index 00000000..945bbb5d
--- /dev/null
+++ b/docs/fr/4.reference/6.composables/3.reading/1.use-duxt-search.md
@@ -0,0 +1,24 @@
+---
+title: useDuxtSearch()
+description: La recherche sur toutes les sources, classées ensemble.
+icon: lucide:search
+---
+
+## Signature
+
+```ts
+const { search, init, labelled } = useDuxtSearch();
+```
+
+## Retour
+
+| Valeur | Ce que c’est |
+| :--------- | :------------------------------------------------------------------------- |
+| `search` | Lancer une requête, sur toutes les sources |
+| `init` | Construire l’index, appelé à la première ouverture de la boîte de dialogue |
+| `labelled` | Les résultats portant la source d’où ils viennent |
+
+## Remarques
+
+L’index est construit à la première utilisation, pas au chargement de la page :
+un lecteur qui ne cherche jamais ne le paie jamais.
diff --git a/docs/fr/4.reference/6.composables/3.reading/2.use-fuzzy-search.md b/docs/fr/4.reference/6.composables/3.reading/2.use-fuzzy-search.md
new file mode 100644
index 00000000..4efef3aa
--- /dev/null
+++ b/docs/fr/4.reference/6.composables/3.reading/2.use-fuzzy-search.md
@@ -0,0 +1,21 @@
+---
+title: useFuzzySearch()
+description: La tolérance aux fautes de frappe, à côté de l’index exact.
+icon: lucide:spell-check
+---
+
+## Signature
+
+```ts
+const { search } = useFuzzySearch(items);
+```
+
+## Retour
+
+Une fonction de recherche qui note les approximations.
+
+## Remarques
+
+Elle ne s’exécute que lorsque la recherche exacte ne renvoie rien, et
+l’interface le dit — un lecteur qui a fait une faute de frappe doit obtenir la
+page, mais doit aussi savoir que c’est bien ce qui s’est passé.
diff --git a/docs/fr/4.reference/6.composables/3.reading/3.use-active-heading.md b/docs/fr/4.reference/6.composables/3.reading/3.use-active-heading.md
new file mode 100644
index 00000000..51b8bf40
--- /dev/null
+++ b/docs/fr/4.reference/6.composables/3.reading/3.use-active-heading.md
@@ -0,0 +1,21 @@
+---
+title: useActiveHeading()
+description: Le titre actuellement en vue.
+icon: lucide:heading
+---
+
+## Signature
+
+```ts
+const active = useActiveHeading(ids);
+```
+
+## Retour
+
+L’id du titre où le lecteur se trouve actuellement.
+
+## Remarques
+
+Il observe les titres plutôt que le décalage de défilement, si bien qu’une
+dernière section courte est tout de même signalée comme atteinte — l’échec
+habituel d’une table des matières fondée sur la position de défilement.
diff --git a/docs/fr/4.reference/6.composables/3.reading/4.use-duxt-page-focus.md b/docs/fr/4.reference/6.composables/3.reading/4.use-duxt-page-focus.md
new file mode 100644
index 00000000..8d2100aa
--- /dev/null
+++ b/docs/fr/4.reference/6.composables/3.reading/4.use-duxt-page-focus.md
@@ -0,0 +1,18 @@
+---
+title: useDuxtPageFocus()
+description: Déplace le focus vers le titre après une navigation côté client.
+icon: lucide:focus
+---
+
+## Signature
+
+```ts
+useDuxtPageFocus();
+```
+
+## Remarques
+
+Un changement de route côté client remplace le document sans déplacer le focus,
+si bien qu’un lecteur d’écran continue de lire l’ancienne page et qu’une
+tabulation clavier reprend là où la page précédente s’était arrêtée. Ceci place
+le focus sur le titre de la nouvelle page.
diff --git a/docs/fr/4.reference/6.composables/3.reading/5.use-recent-pages.md b/docs/fr/4.reference/6.composables/3.reading/5.use-recent-pages.md
new file mode 100644
index 00000000..253ed42b
--- /dev/null
+++ b/docs/fr/4.reference/6.composables/3.reading/5.use-recent-pages.md
@@ -0,0 +1,16 @@
+---
+title: useRecentPages()
+description: Les cinq dernières pages que ce lecteur a ouvertes.
+icon: lucide:history
+---
+
+## Signature
+
+```ts
+const { recent, remember, load } = useRecentPages();
+```
+
+## Remarques
+
+Conservées dans le navigateur, et non dans un cookie : elles ne servent jamais
+au rendu serveur, elles ne voyagent donc avec aucune requête.
diff --git a/docs/fr/4.reference/6.composables/3.reading/6.use-package-manager.md b/docs/fr/4.reference/6.composables/3.reading/6.use-package-manager.md
new file mode 100644
index 00000000..5852cae8
--- /dev/null
+++ b/docs/fr/4.reference/6.composables/3.reading/6.use-package-manager.md
@@ -0,0 +1,19 @@
+---
+title: usePackageManager()
+description: Le gestionnaire de paquets qu’un lecteur a choisi.
+icon: lucide:package
+---
+
+## Signature
+
+```ts
+const manager = usePackageManager();
+```
+
+## Remarques
+
+Un cookie plutôt qu’un stockage navigateur, et c’est délibéré : le stockage est
+lu après l’hydratation, si bien qu’un bloc de commande afficherait un
+gestionnaire puis basculerait vers un autre sous les yeux du lecteur. Il vient
+du site lui-même, n’identifie personne et porte une valeur que le lecteur a
+choisie.
diff --git a/docs/fr/4.reference/6.composables/3.reading/7.use-duxt-toast.md b/docs/fr/4.reference/6.composables/3.reading/7.use-duxt-toast.md
new file mode 100644
index 00000000..76a5c721
--- /dev/null
+++ b/docs/fr/4.reference/6.composables/3.reading/7.use-duxt-toast.md
@@ -0,0 +1,23 @@
+---
+title: useDuxtToast()
+description: Les notifications, enveloppées pour qu’aucun composant n’importe la bibliothèque.
+icon: lucide:bell
+---
+
+## Signature
+
+```ts
+const notify = useDuxtToast();
+
+notify.success(t('duxt.code.copiedToast'));
+```
+
+## Retour
+
+`{ success, info, warning, error, loading, dismiss }`.
+
+## Remarques
+
+L’enveloppe est la jointure : un consommateur qui veut un autre système de
+notifications remplace ce seul composable plutôt que chaque site d’appel, et les
+composants propres à la couche continuent de fonctionner.
diff --git a/docs/fr/4.reference/6.composables/3.reading/8.use-duxt-shortcuts.md b/docs/fr/4.reference/6.composables/3.reading/8.use-duxt-shortcuts.md
new file mode 100644
index 00000000..2ca52d3a
--- /dev/null
+++ b/docs/fr/4.reference/6.composables/3.reading/8.use-duxt-shortcuts.md
@@ -0,0 +1,21 @@
+---
+title: useDuxtShortcuts()
+description: Les touches que le thème écoute.
+icon: lucide:keyboard
+---
+
+## Signature
+
+```ts
+const shortcuts = useDuxtShortcuts();
+
+onDuxtShortcut('k', () => open());
+```
+
+## Remarques
+
+Le seul endroit qui sait quelles touches sont associées, si bien que la
+superposition sur `?` et les gestionnaires ne peuvent pas se contredire.
+`onDuxtShortcut()`, à côté, associe une touche avec les garde-fous dont toute
+association a besoin — dont celui de ne jamais se déclencher pendant que le
+lecteur écrit.
diff --git a/docs/fr/4.reference/6.composables/3.reading/index.md b/docs/fr/4.reference/6.composables/3.reading/index.md
new file mode 100644
index 00000000..4c075c7a
--- /dev/null
+++ b/docs/fr/4.reference/6.composables/3.reading/index.md
@@ -0,0 +1,11 @@
+---
+title: Expérience de lecture
+description: La recherche, les titres, le focus, l’historique, et les choix que fait un lecteur.
+icon: lucide:book-open
+---
+
+Tout ce qui fait que le site se comporte comme un endroit où quelqu’un lit,
+plutôt que comme un ensemble de documents.
+
+::page-cards
+::
diff --git a/docs/fr/4.reference/6.composables/index.md b/docs/fr/4.reference/6.composables/index.md
new file mode 100644
index 00000000..a1afa102
--- /dev/null
+++ b/docs/fr/4.reference/6.composables/index.md
@@ -0,0 +1,14 @@
+---
+title: Composables
+description: Les fonctions que la couche expose à vos propres composants, une page chacune.
+icon: lucide:function-square
+---
+
+Auto-importées, comme tout composable dans une couche Nuxt — aucune ligne
+d’import.
+
+Chaque page nomme la signature, ce qui revient, et la seule chose à son sujet
+qui ne saute pas aux yeux à la lecture du type de retour.
+
+::page-cards
+::
diff --git a/docs/fr/4.reference/7.endpoints/1.llms-txt.md b/docs/fr/4.reference/7.endpoints/1.llms-txt.md
new file mode 100644
index 00000000..24bf12f7
--- /dev/null
+++ b/docs/fr/4.reference/7.endpoints/1.llms-txt.md
@@ -0,0 +1,19 @@
+---
+title: /llms.txt
+description: La documentation en index Markdown, selon llmstxt.org.
+icon: lucide:file-text
+---
+
+Un index du site, en Markdown, au chemin que nomme la convention llmstxt.org.
+
+## Réponse
+
+`text/plain`. Le titre et la description du site, puis chaque page comme entrée
+de liste liée, avec sa propre description.
+
+## Remarques
+
+Généré à partir de la version par défaut seulement. Un modèle à qui l’on remet
+toutes les versions de toutes les pages doit déterminer laquelle est la
+courante, et la réponse à cette question est un fait de compilation que le site
+connaît déjà.
diff --git a/docs/fr/4.reference/7.endpoints/2.llms-full-txt.md b/docs/fr/4.reference/7.endpoints/2.llms-full-txt.md
new file mode 100644
index 00000000..d51cadb6
--- /dev/null
+++ b/docs/fr/4.reference/7.endpoints/2.llms-full-txt.md
@@ -0,0 +1,20 @@
+---
+title: /llms-full.txt
+description: Les mêmes pages, entières, en un seul fichier Markdown.
+icon: lucide:file-stack
+---
+
+Chaque page de la version par défaut, en entier, concaténée.
+
+## Réponse
+
+`text/plain`, un seul document.
+
+## Remarques
+
+L’index et le texte complet répondent à deux questions différentes : un agent
+capable de récupérer suit [`/llms.txt`](/reference/endpoints/llms-txt) page par
+page, et celui qui ne le peut pas prend le tout d’un coup.
+
+Le frontmatter est retiré de chaque page — ce qui est servi se lit comme la
+documentation plutôt que comme les fichiers dont elle provient.
diff --git a/docs/fr/4.reference/7.endpoints/3.page-markdown.md b/docs/fr/4.reference/7.endpoints/3.page-markdown.md
new file mode 100644
index 00000000..00743307
--- /dev/null
+++ b/docs/fr/4.reference/7.endpoints/3.page-markdown.md
@@ -0,0 +1,22 @@
+---
+title: .md
+description: La source de n’importe quelle page, à son URL suivie de .md.
+icon: lucide:file-code
+---
+
+Ajoutez `.md` à n’importe quelle URL de documentation et la réponse est le
+Markdown à partir duquel cette page a été rendue.
+
+```
+/duxt/getting-started → la page
+/duxt/getting-started.md → sa source
+```
+
+## Remarques
+
+C’est un **middleware, pas une route**, car `.md` est un suffixe du dernier
+segment plutôt qu’un segment à part — un fichier de route ne peut pas le
+capturer.
+
+C’est ce que [`DuxtCopyPage`](/reference/components/page/duxt-copy-page) remet à
+un modèle : la source, pas la page rendue.
diff --git a/docs/fr/4.reference/7.endpoints/4.mcp.md b/docs/fr/4.reference/7.endpoints/4.mcp.md
new file mode 100644
index 00000000..1d1b4496
--- /dev/null
+++ b/docs/fr/4.reference/7.endpoints/4.mcp.md
@@ -0,0 +1,22 @@
+---
+title: /mcp
+description: Un serveur MCP sur le même contenu.
+icon: lucide:bot
+---
+
+Un point de terminaison Model Context Protocol, pour qu’un agent puisse
+interroger la documentation au lieu de la scraper.
+
+## Outils
+
+| Outil | Ce qu’il fait |
+| :------------ | :------------------------------------ |
+| `list_pages` | La table des matières |
+| `search_docs` | Trouver une page par terme |
+| `read_page` | Le texte complet d’une page |
+
+## Remarques
+
+Les mêmes collections que les pages, si bien que les réponses ne peuvent pas
+diverger de ce qui est publié. Le nom du serveur, sa description et ses
+instructions viennent des options du module.
diff --git a/docs/fr/4.reference/7.endpoints/5.sitemap-and-robots.md b/docs/fr/4.reference/7.endpoints/5.sitemap-and-robots.md
new file mode 100644
index 00000000..c0a90bf9
--- /dev/null
+++ b/docs/fr/4.reference/7.endpoints/5.sitemap-and-robots.md
@@ -0,0 +1,22 @@
+---
+title: /sitemap.xml et /robots.txt
+description: Ce vers quoi un robot est dirigé, et ce dont on le tient à l’écart.
+icon: lucide:search
+---
+
+## Réponse
+
+`/sitemap.xml` liste les pages de la version par défaut, par locale, avec des
+alternatives `hreflang`. `/robots.txt` est généré à côté et pointe vers le
+sitemap.
+
+## Remarques
+
+Seule la version par défaut est listée. Les versions plus anciennes restent
+accessibles et portent `canonical` plus `noindex` — un résultat de recherche qui
+dépose un lecteur sur la documentation de l’an dernier est l’échec que cette
+paire existe pour empêcher, et la
+[bannière de version](/reference/components/controls/duxt-version-banner) en est
+la moitié visible.
+
+Une version marquée `eol` quitte entièrement le sitemap.
diff --git a/docs/fr/4.reference/7.endpoints/6.rss-xml.md b/docs/fr/4.reference/7.endpoints/6.rss-xml.md
new file mode 100644
index 00000000..bf6c4fc8
--- /dev/null
+++ b/docs/fr/4.reference/7.endpoints/6.rss-xml.md
@@ -0,0 +1,18 @@
+---
+title: /rss.xml
+description: Le flux, une fois qu’une section est nommée.
+icon: lucide:rss
+---
+
+## Réponse
+
+`application/rss+xml`, construit à partir de la section que nomme
+`duxt.feed.path`.
+
+## Remarques
+
+Il répond **vide plutôt que 404** avant d’être configuré : il faut dire à un
+client de flux qu’il n’y a rien ici, pas que le flux a disparu.
+
+Désactivé par défaut, parce qu’un flux est une liste de choses qui se sont
+produites, et une page de référence en cours d’édition n’est pas un événement.
diff --git a/docs/fr/4.reference/7.endpoints/7.devtools.md b/docs/fr/4.reference/7.endpoints/7.devtools.md
new file mode 100644
index 00000000..39a192ed
--- /dev/null
+++ b/docs/fr/4.reference/7.endpoints/7.devtools.md
@@ -0,0 +1,27 @@
+---
+title: /_duxt/devtools
+description: La route depuis laquelle le panneau Nuxt Devtools est servi. En développement seulement.
+icon: lucide:wrench
+---
+
+::callout{type="danger" title="Jamais enregistré dans un build"}
+Le panneau expose la configuration résolue, les chemins de fichiers qui sont
+derrière et un bouton qui supprime un répertoire de cache. Rien de tout cela ne
+regarde qui que ce soit en production, et c’est pourquoi le module retourne avant
+d’enregistrer quoi que ce soit hors d’un serveur de développement.
+::
+
+## Réponse
+
+`text/html`, un document par panneau : `/_duxt/devtools` est le panneau Sources et
+chacun des autres s’y accroche — `/_duxt/devtools/paths`, `/_duxt/devtools/pages`
+et ainsi de suite, dans l’ordre de la rangée d’onglets.
+
+Ce sont les mêmes documents que Nuxt Devtools encadre dans son onglet **duxt** : la
+route existe donc pour être ouverte dans une fenêtre à part — un tableau large se
+lit mieux hors d’un tiroir.
+
+## Notes
+
+Ce à quoi chaque panneau répond, avec chaque panneau montré tel qu’il apparaît,
+forme une section à part : [Devtools](/reference/devtools).
diff --git a/docs/fr/4.reference/7.endpoints/index.md b/docs/fr/4.reference/7.endpoints/index.md
new file mode 100644
index 00000000..e4ba9237
--- /dev/null
+++ b/docs/fr/4.reference/7.endpoints/index.md
@@ -0,0 +1,14 @@
+---
+title: Points de terminaison
+description: Tout ce que le site sert en dehors des pages, une page pour chacun.
+icon: lucide:route
+---
+
+Tout cela est généré à partir des mêmes collections que celles dont les pages
+sont issues — rien de tout cela ne peut donc diverger de ce qui est publié.
+
+::page-cards
+::
+
+Tout ce que lit un modèle, et pourquoi il y a deux fichiers llms, est dans
+[Lecteurs machines](/concepts/machine-readers).
diff --git a/docs/fr/4.reference/8.devtools/1.sources.md b/docs/fr/4.reference/8.devtools/1.sources.md
new file mode 100644
index 00000000..77723d25
--- /dev/null
+++ b/docs/fr/4.reference/8.devtools/1.sources.md
@@ -0,0 +1,42 @@
+---
+title: Sources
+description: Ce que `duxt.sources` est devenu — une collection par source, version et langue.
+icon: lucide:library
+---
+
+Le premier panneau qu’on ouvre, car tout le reste de l’onglet en découle. Une ligne
+par **source résolue** : ce qu’un consommateur a écrit en une poignée d’entrées,
+déplié en collections que Content a réellement générées, et le préfixe d’URL que
+chacune sert.
+
+::devtools-panel{tab="sources"}
+::
+
+## Colonnes
+
+| Colonne | Ce qu’elle dit |
+| :------------------ | :---------------------------------------------------------------- |
+| `Prefix` | Le segment d’URL de cette collection ; `/` pour celle sans préfixe |
+| `Collection` | Le nom de collection généré, tel que `queryCollection` le prend |
+| `Repository` | D’où viennent les pages, en lien dès qu’il est connu |
+| `Folder` | Le `path` dans ce dépôt |
+| `Ref` | La branche ou le tag — et lequel des deux |
+| `Version` | Le slug dans l’URL, `default` pour celle servie sans segment |
+| `Status` | `current`, `deprecated` ou `eol`, directement depuis la source |
+| `Reserved segments` | Noms de dossier de cette source qu’un préfixe a déjà réservés |
+
+## Notes
+
+**Un segment de dépôt n’apparaît qu’à partir du deuxième dépôt**, un segment de
+version qu’à partir de la deuxième ref — un segment qui ne peut prendre qu’une
+valeur ne distingue rien. `sourceOptions.showRepo` et `showVersion` les imposent
+malgré tout.
+
+**Les segments réservés sont le piège pour lequel cette colonne existe.** Si une
+source est servie sous `/sdk` et que son dossier de documentation contient un
+répertoire `v1-9/`, le préfixe gagne et ce dossier est injoignable. Le panneau
+[Checks](/reference/devtools/checks) le signale comme une erreur ; cette colonne
+montre quels noms sont pris avant que cela arrive.
+
+Le pied de page nomme le fichier d’où la liste a été lue — utile sur un site dont
+l’`app.config.ts` n’est pas là où vous l’attendiez.
diff --git a/docs/fr/4.reference/8.devtools/10.redirects.md b/docs/fr/4.reference/8.devtools/10.redirects.md
new file mode 100644
index 00000000..c8b5c523
--- /dev/null
+++ b/docs/fr/4.reference/8.devtools/10.redirects.md
@@ -0,0 +1,39 @@
+---
+title: Redirections
+description: Chaque redirection que sert le serveur, et lesquelles la couche a écrites.
+icon: lucide:signpost
+---
+
+`redirectFrom` dans le frontmatter d’une page devient des route rules, une par
+préfixe sous lequel la page est servie. Ce panneau relit ces règles **depuis le
+serveur en marche** au lieu de les recalculer : une règle générée et une règle
+livrée sont deux affirmations différentes, et seule la seconde redirige quelqu’un.
+
+::devtools-panel{tab="redirects"}
+::
+
+## Written by
+
+Relire les règles, c’est lire celles de tous les modules, pas seulement celles de
+la couche — `@nuxtjs/sitemap` ajoute `/sitemap.xml` sur tout site à plus d’un
+sitemap. Chaque ligne dit donc qui l’a écrite :
+
+| Badge | Signification |
+| :--------------- | :------------------------------------------------------------- |
+| `redirectFrom` | Générée par duxt depuis le frontmatter d’une page |
+| `another module` | La route rule de quelqu’un d’autre, servie par le même serveur |
+
+Rien dans une route rule ne consigne son auteur : la couche note donc à côté les
+chemins qu’elle a générés, et le panneau marque le reste.
+
+## Notes
+
+**Une règle par préfixe.** Une ancienne URL était enregistrée sous exactement l’un
+des segments de dépôt, de version et de langue — et rien ne dit lequel —, donc
+chaque combinaison redirige.
+
+Une page qui redirige vers elle-même est écartée plutôt que livrée : c’est une
+boucle, pas une redirection.
+
+Déplacer une page a son propre guide :
+[Déplacer une page](/guides/move-a-page).
diff --git a/docs/fr/4.reference/8.devtools/2.paths.md b/docs/fr/4.reference/8.devtools/2.paths.md
new file mode 100644
index 00000000..c6a8bbf7
--- /dev/null
+++ b/docs/fr/4.reference/8.devtools/2.paths.md
@@ -0,0 +1,42 @@
+---
+title: Chemins
+description: Collez une URL et voyez quelle source la revendique et ce qu’elle y trouve.
+icon: lucide:route
+---
+
+Le débogueur de chemins, et le panneau où se trouvent la plupart des bugs de
+version. Tous ceux qu’a connus cette couche étaient un désaccord entre l’allure
+d’une URL et la source qui la revendique — `/workflows/v0.7.0` en 404,
+`/workflows/v0.7.0/v0.7.0` généré. Les deux étaient de l’arithmétique de préfixes
+que rien n’affichait.
+
+::devtools-panel{tab="paths"}
+::
+
+## Les étapes
+
+1. **Le segment de langue est retiré.** `/de/sdk/guides/caching` est la page
+ `/sdk/guides/caching` en allemand — la langue traduit l’interface, jamais
+ l’arbre de contenu, et toute requête du thème vise donc le chemin raccourci.
+2. **Le plus long préfixe correspondant gagne.** Une page sous `/sdk/v1-9`
+ correspond aussi à `/sdk` ; prendre le plus court, c’est ainsi qu’un tag finit
+ par rendre `main`.
+3. **La collection qui la revendique est interrogée** sur ce chemin exact.
+4. **La même page est cherchée dans les sources sœurs** — les autres versions et
+ langues du même dépôt, jamais d’un autre.
+
+## Lire un échec
+
+Une page absente ici et présente dans une autre colonne, c’est le cas du
+**fallback** : le sélecteur de versions la propose, et le lecteur est envoyé vers
+l’ancêtre le plus proche plutôt que vers un 404.
+
+Une page absente partout est une faute de frappe, et le panneau liste alors les
+chemins les plus proches de la collection — ceux qui partagent la plus longue
+amorce avec ce que vous avez collé.
+
+::callout{type="tip"}
+Collez l’URL exactement telle que le navigateur l’affiche, segment de langue
+compris. Le raccourcissement, c’est l’affaire du panneau, et le voir faire est la
+moitié de la réponse.
+::
diff --git a/docs/fr/4.reference/8.devtools/3.pages.md b/docs/fr/4.reference/8.devtools/3.pages.md
new file mode 100644
index 00000000..bdb34a0f
--- /dev/null
+++ b/docs/fr/4.reference/8.devtools/3.pages.md
@@ -0,0 +1,41 @@
+---
+title: Pages
+description: Chaque page de chaque collection, avec son frontmatter et ses métadonnées git.
+icon: lucide:files
+---
+
+Ce que Content a fini par produire, un bloc par collection. C’est le panneau qui
+répond à « ma page est-elle seulement dans le build ? » — et, plus souvent, à
+« pourquoi celle-ci ne ressemble-t-elle pas aux autres ? ».
+
+::devtools-panel{tab="pages"}
+::
+
+## Colonnes
+
+| Colonne | Ce qu’elle dit |
+| :------------- | :------------------------------------------------------------------ |
+| `Path` | L’URL, préfixe compris — ce que `queryCollection` stocke |
+| `Title` | Du frontmatter ; `missing` est un avertissement, pas une supposition |
+| `Description` | S’il en existe une ; elle alimente les cartes, l’image OG et llms.txt |
+| `Icon` | Le nom Iconify du frontmatter |
+| `Last updated` | De l’historique git, pas de la date du fichier |
+| `Contributors` | Combien de personnes l’historique nomme |
+| `File` | Ouvre le fichier source dans votre éditeur |
+
+## Notes
+
+**Une collection sans aucun historique** est en général une source distante sans
+`history: true`. Content clone en `--depth 1` : le checkout ne contient qu’un
+commit et chaque page semblerait écrite par la personne qui a coupé la pointe — la
+couche préfère aucune donnée à une donnée fausse.
+
+**À partir de la deuxième collection, tout est replié.** Un site à cinq versions a
+cinq tableaux presque identiques, et les cinq dépliés, personne ne les lit.
+
+**Le filtre apparaît au-dessus des tableaux de plus de douze lignes.** Il masque
+les lignes qui ne correspondent nulle part, compte celles qui restent, et un
+rechargement ramène tout.
+
+**Une collection vide est un constat, pas un tableau vide** — toute URL sous ce
+préfixe est un 404, et le panneau le dit là où les lignes auraient été.
diff --git a/docs/fr/4.reference/8.devtools/4.versions.md b/docs/fr/4.reference/8.devtools/4.versions.md
new file mode 100644
index 00000000..6ac9cd77
--- /dev/null
+++ b/docs/fr/4.reference/8.devtools/4.versions.md
@@ -0,0 +1,38 @@
+---
+title: Versions
+description: Quelle version — et quelle langue — porte quelle page.
+icon: lucide:columns-3
+---
+
+Rien d’autre ne montre cela. Tout le travail du fallback est l’écart entre deux
+colonnes de cette grille, et sans elle la seule façon d’en trouver un est d’ouvrir
+une URL et de voir un 404.
+
+::devtools-panel{tab="versions"}
+::
+
+## Deux axes, pas un
+
+Une traduction n’est pas une version, et les comparer comme si elles l’étaient
+dessine une grille de colonnes identiques. Le panneau fixe donc un axe à la fois :
+
+- **Les versions d’une langue** — `v2` contre `v1.9`, toutes deux en anglais.
+- **Les langues d’une version** — `en` contre `de`, toutes deux en `v2`.
+
+Un site à deux versions en deux langues obtient donc quatre grilles, chacune avec
+une question. Un dépôt publié une fois, en une langue, n’en obtient aucune : une
+seule colonne ne compare rien.
+
+Les dépôts ne sont jamais mélangés. `/sdk/guides` et `/cli/guides` sont deux
+projets qui partagent par hasard un nom de dossier, et les mettre dans une grille
+inventerait un écart qui n’en est pas un.
+
+## La lire
+
+`✓` veut dire que la collection a cette page ; `·` qu’elle ne l’a pas. Le `*` à
+côté d’un en-tête marque la colonne servie sans segment propre — la version par
+défaut, ou la langue qui vit dans le dossier source lui-même.
+
+Un `·` dans une colonne de langue est une page qui attend sa traduction. Un `·`
+dans la colonne d’une version plus ancienne est le plus souvent correct : le guide
+n’existait pas encore.
diff --git a/docs/fr/4.reference/8.devtools/5.checks.md b/docs/fr/4.reference/8.devtools/5.checks.md
new file mode 100644
index 00000000..69c302b0
--- /dev/null
+++ b/docs/fr/4.reference/8.devtools/5.checks.md
@@ -0,0 +1,36 @@
+---
+title: Vérifications
+description: Le validateur du build, appliqué à ce que le site sert en ce moment.
+icon: lucide:check-check
+---
+
+Les mêmes vérifications que `pnpm build`, depuis le même code — l’intérêt n’est pas
+un deuxième avis, mais un endroit où les constats restent. Un avertissement affiché
+pendant un build a défilé bien avant que quiconque le lise.
+
+::devtools-panel{tab="checks"}
+::
+
+## Ce qui est vérifié
+
+Les **erreurs** sont ce qui rend une URL injoignable :
+
+- une **collection sans pages**, dont tout le préfixe est un 404 ;
+- un **dossier nommé comme un segment de dépôt ou de version**, que le préfixe
+ masque entièrement.
+
+Les **avertissements** sont ce qui dégrade une page en silence :
+
+- **pas de `title` ou pas de `description`** — la table des matières, les cartes,
+ l’image OG et llms.txt les lisent tous ;
+- un **lien vers une page que personne ne sert**, résolu exactement comme `ProseA`
+ le résout au rendu : d’abord sous le préfixe de la source, ensuite nu ;
+- un **lien vers une ancre que cette page n’a pas**.
+
+## Notes
+
+Les constats nomment leur fichier entre guillemets, et le panneau transforme le
+premier en un lien qui ouvre votre éditeur à cet endroit.
+
+Les liens sont vérifiés **en interne uniquement**. Une URL externe n’est pas à ce
+build de la vérifier, et le faire mettrait le réseau dans le build.
diff --git a/docs/fr/4.reference/8.devtools/6.search.md b/docs/fr/4.reference/8.devtools/6.search.md
new file mode 100644
index 00000000..ad93c09c
--- /dev/null
+++ b/docs/fr/4.reference/8.devtools/6.search.md
@@ -0,0 +1,29 @@
+---
+title: Recherche
+description: Ce que contient l’index de recherche, via la requête même que lance la boîte de dialogue.
+icon: lucide:search
+---
+
+La recherche a eu trois bugs distincts — un filtre qui en combattait un autre, des
+résultats qui étaient des fragments d’en-tête, des cellules de tableau collées sans
+séparateur — et tous trois étaient invisibles, parce que personne ne pouvait voir
+les sections à partir desquelles l’index est construit.
+
+::devtools-panel{tab="search"}
+::
+
+## La lire
+
+**Une ligne par en-tête**, pas par page. C’est ce que l’index stocke et ce que la
+boîte de dialogue regroupe à nouveau en pages : une page à huit en-têtes, ce sont
+huit lignes.
+
+La colonne du texte indexé est exactement ce contre quoi FTS5 fait la
+correspondance. Si un mot y manque, aucune requête ne le trouvera, aussi correcte
+que la page paraisse.
+
+::callout{type="tip"}
+FTS5 traite des termes et des préfixes, pas des sous-chaînes : `caching` trouve
+`caching`, `cach` aussi, et `aching` ne trouve rien. Une recherche qui revient vide
+est le plus souvent cela, pas une page manquante.
+::
diff --git a/docs/fr/4.reference/8.devtools/7.config.md b/docs/fr/4.reference/8.devtools/7.config.md
new file mode 100644
index 00000000..8431762b
--- /dev/null
+++ b/docs/fr/4.reference/8.devtools/7.config.md
@@ -0,0 +1,42 @@
+---
+title: Config
+description: La configuration `duxt` fusionnée, et de quel côté de la fusion vient chaque valeur.
+icon: lucide:sliders-horizontal
+---
+
+Chaque clé que lit le thème, avec un badge disant d’où elle vient. « Pourquoi
+l’entrée de duxt est-elle encore dans ma barre de navigation ? » coûte ici un
+regard au lieu d’une séance.
+
+::devtools-panel{tab="config"}
+::
+
+## Les badges
+
+| Badge | Signification |
+| :--------- | :----------------------------------------------------------------- |
+| `consumer` | Votre `app.config.ts` l’a définie |
+| `layer` | C’est la valeur par défaut de la couche, intacte |
+| `build` | Elle a été calculée par le build et n’est écrite nulle part |
+
+## La règle de fusion
+
+**Les tableaux sont remplacés en entier, les objets fusionnent clé par clé.** La
+`navigation` d’un consommateur est la navigation — les entrées de la couche ont
+disparu. Son `footer.note` laisse `footer.columns` tranquille.
+
+Ce n’est pas ce que fait Nuxt avec `app.config` tout seul : defu concatène les
+tableaux, un site aurait donc ses entrées plus les nôtres, sans moyen de retirer
+les nôtres. La couche ne publie donc aucune liste dans `app.config` et les fusionne
+elle-même.
+
+## Notes
+
+**Les valeurs structurées se déplient.** Une ligne contenant un objet ou une liste
+affiche un décompte et un aperçu d’une ligne ; un clic imprime la valeur entière,
+indentée. Elle reste dans la page une fois repliée : le filtre trouve donc toujours
+une ligne par n’importe quelle chaîne qu’elle contient — y compris une seule
+traduction d’un libellé.
+
+`resolvedSources` est la seule clé que personne n’écrit : c’est le manifeste que le
+build a calculé à partir de `sources`, et que lit tout autre panneau de cet onglet.
diff --git a/docs/fr/4.reference/8.devtools/8.i18n.md b/docs/fr/4.reference/8.devtools/8.i18n.md
new file mode 100644
index 00000000..18fe3e26
--- /dev/null
+++ b/docs/fr/4.reference/8.devtools/8.i18n.md
@@ -0,0 +1,37 @@
+---
+title: i18n
+description: Clés de message par langue, face à la langue de base.
+icon: lucide:languages
+---
+
+Sept langues et un fichier JSON par espace de noms font de la dérive une question
+de temps — et vue-i18n répond à une clé manquante en imprimant la clé : un trou
+apparaît donc comme un bouton libellé `duxt.nav.open` sur une page que personne n’a
+ouverte dans cette langue.
+
+::devtools-panel{tab="i18n"}
+::
+
+## Colonnes
+
+| Colonne | Ce qu’elle dit |
+| :---------------- | :------------------------------------------------------------------ |
+| `Directory` | Un dossier sous `i18n/locales` ; `base` marque celui de référence |
+| `Keys` | Combien de clés il définit en tout |
+| `Missing vs base` | Les clés que la base a et que celui-ci n’a pas |
+| `Not in base` | Les clés qu’il a et que la base n’a pas — souvent un renommage |
+
+## Notes
+
+**Manquer est un fait, pas un verdict.** Un fichier de langue peut délibérément ne
+contenir que ses écarts : `pt-BR` remplace une poignée de chaînes et lit le reste
+depuis `pt` par le repli sur la langue de base — une longue liste sous « missing »
+y est donc le principe qui fonctionne.
+
+**« Not in base » se lit deux fois.** Une clé que personne d’autre n’a est le plus
+souvent une faute de frappe ou un renommage resté à mi-chemin — et elle s’imprimera
+elle-même sur la page.
+
+La ligne au-dessus du tableau liste les langues que le site sert vraiment, ce qui
+est une autre liste que les dossiers sur le disque : plusieurs langues peuvent en
+partager un.
diff --git a/docs/fr/4.reference/8.devtools/9.cache.md b/docs/fr/4.reference/8.devtools/9.cache.md
new file mode 100644
index 00000000..dbf8ac03
--- /dev/null
+++ b/docs/fr/4.reference/8.devtools/9.cache.md
@@ -0,0 +1,33 @@
+---
+title: Cache
+description: Le cache de téléchargement de Content, et un moyen d’en jeter une entrée.
+icon: lucide:hard-drive
+---
+
+Chaque source distante est clonée dans `.data/content` et y reste. Rien dans un
+build ne mentionne ce répertoire, donc rien ne le suggère quand cela tourne mal.
+
+::devtools-panel{tab="cache"}
+::
+
+## Pourquoi cela compte
+
+Un répertoire périmé a coûté ici du temps bien réel : un tag a été récupéré comme
+une branche, la récupération a échoué, et le dossier à moitié écrit a continué de
+répondre pour la configuration pourtant corrigée jusqu’à ce que quelqu’un le
+supprime à la main. Le build ne disait rien : pour lui, la source était déjà
+téléchargée.
+
+**Drop** supprime une entrée ; le build suivant la retélécharge.
+
+## Notes
+
+Content indexe un répertoire par dépôt **et** par ref : une entrée pour une ref que
+vous avez renommée n’est donc jamais réutilisée — ni jamais nettoyée. C’est dans ce
+panneau qu’on les trouve.
+
+::callout{type="warning"}
+Jeter une entrée supprime un répertoire sur votre disque. Ce n’est jamais que le
+cache — jamais une source de vérité —, mais c’est une vraie suppression, et c’est
+pourquoi cet onglet n’est pas enregistré dans un build.
+::
diff --git a/docs/fr/4.reference/8.devtools/index.md b/docs/fr/4.reference/8.devtools/index.md
new file mode 100644
index 00000000..fc761058
--- /dev/null
+++ b/docs/fr/4.reference/8.devtools/index.md
@@ -0,0 +1,48 @@
+---
+title: Devtools
+description: Dix panneaux qui montrent ce que vos sources sont devenues. En développement seulement.
+icon: lucide:wrench
+---
+
+`sources` est une liste compacte. Ce que le site sert, c’est un ensemble de
+collections, de préfixes d’URL, de redirections, de catalogues de messages et un
+cache de téléchargement que la compilation en a déduits — et avant cet onglet, la
+seule façon d’en voir quoi que ce soit était de lire le code qui le produit.
+
+## L’ouvrir
+
+Lancez le serveur de développement et ouvrez Nuxt Devtools (`Shift` + `Alt` + `D`,
+ou le bouton dans le coin). L’onglet s’appelle **duxt** ; les panneaux se trouvent
+derrière la rangée d’onglets qu’il contient. Les mêmes pages répondent directement
+sous `/_duxt/devtools` si vous les préférez dans une fenêtre à part.
+
+::callout{type="danger" title="Jamais enregistré dans un build"}
+Les panneaux exposent la configuration résolue, les chemins de fichiers qui sont
+derrière et un bouton qui supprime un répertoire de cache. Rien de tout cela ne
+regarde qui que ce soit en production, et c’est pourquoi `modules/devtools.ts`
+retourne avant d’enregistrer quoi que ce soit hors d’un serveur de développement —
+la route n’existe pas, plutôt que d’exister et de refuser.
+::
+
+## Les panneaux
+
+::page-cards
+::
+
+## À propos des aperçus de ces pages
+
+Chaque panneau ci-dessous est intégré tel qu’il se rend — ce n’est pas une capture
+d’écran. Les pages exécutent les fonctions de rendu des panneaux eux-mêmes sur un
+site d’exemple et enregistrent le résultat : un panneau qui gagne une colonne la
+gagne dans cette documentation au même commit.
+
+Ce site d’exemple est un projet imaginaire : `acme/sdk` publié en deux versions
+(`v2` depuis `main`, `v1.9` depuis un tag) en anglais et en allemand, plus
+`acme/cli` en une seule. Il comporte des trous délibérés — un guide que l’ancienne
+version précède, deux pages que la traduction n’a pas rattrapées, une page sans
+frontmatter — car un panneau qui n’a rien à signaler n’apprend à personne de quoi
+il a l’air quand quelque chose cloche.
+
+Les aperçus sont inertes : leurs onglets passent d’un panneau à l’autre, et tout le
+reste — les liens vers l’éditeur, le bouton de suppression, le formulaire de
+recherche — ne fait rien.
diff --git a/docs/fr/4.reference/index.md b/docs/fr/4.reference/index.md
new file mode 100644
index 00000000..271d16b5
--- /dev/null
+++ b/docs/fr/4.reference/index.md
@@ -0,0 +1,12 @@
+---
+title: Référence
+description: La surface publique de la couche — clés de configuration, frontmatter, composants, composables et points de terminaison.
+icon: lucide:list
+---
+
+Ce qu’un consommateur peut écrire, appeler ou surcharger. **Un nom documenté ici
+fait partie de la surface publique** : le renommer est un changement cassant de
+la couche, et c’est ce qui rend ces pages dignes de confiance.
+
+::page-cards
+::
diff --git a/docs/fr/5.credits/index.md b/docs/fr/5.credits/index.md
new file mode 100644
index 00000000..2376c0db
--- /dev/null
+++ b/docs/fr/5.credits/index.md
@@ -0,0 +1,66 @@
+---
+title: Crédits
+description: Ce sur quoi duxt est construit, et ce d’après quoi il l’a été.
+icon: lucide:heart
+---
+
+duxt est une fine couche posée sur le travail d’autres personnes. Presque rien de
+ce qu’il fait n’est de son invention — la lecture des sources, l’analyse, les
+composants et le style viennent tous de projets qui ont résolu ces problèmes les
+premiers, et la description honnête de ce dépôt, c’est la colle entre eux plus
+une poignée de partis pris.
+
+## Construit sur
+
+| Projet | Ce qu’il fait ici |
+| :-------------------------------------------------- | :---------------------------------------------------------------------- |
+| [Vue](https://vuejs.org) | Le modèle de composants dans lequel tout ici est écrit |
+| [Nuxt](https://nuxt.com) | Le framework, et le mécanisme de couches sur lequel repose toute l’idée |
+| [Nuxt Content](https://content.nuxt.com) | Lecture des sources, analyse, requêtes — dépôts git natifs compris |
+| [shadcn-vue](https://www.shadcn-vue.com) | La base de composants, copiée dans la couche plutôt qu’importée |
+| [reka-ui](https://reka-ui.com) | Les primitives en dessous : focus, roving tabindex, ARIA |
+| [Tailwind CSS](https://tailwindcss.com) | Le système de style et la couche de tokens |
+| [Shiki](https://shiki.style) | La coloration syntaxique, à la compilation |
+| [Lucide](https://lucide.dev) | Le jeu d’icônes |
+| [MDC](https://content.nuxt.com/docs/files/markdown) | Des composants appelables depuis le Markdown |
+
+La version de chacun est dans `package.json`, là où un numéro a sa place — une
+seconde copie en prose est une copie qui se périme en silence.
+
+## Construit d’après
+
+Des idées que duxt a prises à des projets sur lesquels il n’est pas construit. Ce
+sur quoi il *est* construit, c’est le tableau ci-dessus — rien n’est nommé deux
+fois.
+
+- [**shadcn/ui**](https://ui.shadcn.com) — l’idée d’origine : des composants que
+ vous possédez comme fichiers plutôt que d’importer comme dépendance. Chaque
+ composant de cette couche est là grâce à lui.
+- [**shadcn-docs-nuxt**](https://shadcn-docs-nuxt.vercel.app) — le voisin le plus
+ proche, et la preuve qu’un modèle de documentation sur Nuxt Content et
+ shadcn-vue vaut la peine d’exister.
+- [**Docus**](https://docus.dev) — l’ergonomie d’origine « étendez une couche,
+ obtenez un site de documentation » dans l’écosystème Nuxt.
+- [**Nuxt UI**](https://ui.nuxt.com) — pour le versant lisible par la machine :
+ `llms.txt` et un point de terminaison de documentation qu’un agent peut
+ appeler, traités comme une sortie de compilation et non comme un ajout.
+- [**VitePress**](https://vitepress.dev) et
+ [**Starlight**](https://starlight.astro.build) — pour ce qu’un thème de
+ documentation doit à un lecteur par défaut : un sélecteur de version qui
+ survit à la navigation, une table des matières qui suit, une recherche
+ présente sans configuration.
+
+::callout{type="tip" title="Là où duxt diffère"}
+Chaque projet ci-dessus documente un dépôt à une version. La raison d’être propre
+de duxt commence là où cela s’arrête : plusieurs dépôts, plusieurs versions de
+chacun, et une seule liste `sources` générant les collections de tous — voir
+[Sources](/concepts/sources).
+::
+
+## Non crédités ici
+
+Deux choses sont délibérément absentes. Les dépendances propres à la couche sont
+listées dans `package.json` et n’ont pas besoin d’une seconde copie tenue à la
+main ; et les personnes qui ont écrit une page donnée sont nommées sur cette
+page, à partir de l’historique git qui la porte, plutôt que dans une liste ici
+qui serait périmée dès le commit suivant.
diff --git a/docs/fr/6.conventions/1.branding.md b/docs/fr/6.conventions/1.branding.md
new file mode 100644
index 00000000..92962b1b
--- /dev/null
+++ b/docs/fr/6.conventions/1.branding.md
@@ -0,0 +1,85 @@
+---
+title: Identité visuelle
+description: La marque [d]uxt — où sert chaque ressource, pourquoi les crochets de l'icône sont redessinés, et quelles couleurs sont fixes.
+icon: lucide:shapes
+---
+
+::callout{type="warning" title="Visuel provisoire"}
+Le logo est un travail provisoire assisté par IA — conçu lors d'une session avec un
+agent de programmation IA, non par une personne graphiste, et destiné à être
+remplacé un jour, sans échéance fixée. Aucun générateur d'images n'est intervenu :
+il est composé typographiquement, pas dessiné.
+::
+
+La marque est le nom du paquet avec sa première lettre entre crochets — `[d]uxt` —
+parce que c'est ainsi que le paquet s'écrit là où il sert :
+
+```ts
+extends: ['@kirchdev/duxt'];
+```
+
+Un tableau à une entrée. Elle est composée en [IBM Plex Mono](https://github.com/IBM/plex)
+SemiBold et vectorisée, de sorte qu'aucune ressource ne dépend de la présence de la
+police.
+
+## Les ressources
+
+| Ressource | Chemin | Utilisée par |
+| :------------------- | :------------------------------------------------------------ | :---------------------------------- |
+| Logo | `.github/assets/wordmark-light.svg`, `wordmark-dark.svg` | Le hero du README, via `` |
+| Icônes du site | `www/public/favicon.svg`, `apple-touch-icon.png` (180×180) | L'onglet du navigateur |
+| Logo dans le site | `www/public/wordmark.svg`, `wordmark-dark.svg` | `duxt.logo` dans `www/` |
+
+Le logo du README resserre les chasses des crochets de 120 unités : Plex Mono donne
+à `[` une chasse de 600 unités pour 311 unités d'encre, et à pleine largeur
+monospace les crochets occupent une espace-mot à eux seuls.
+
+Le favicon porte sa propre règle `prefers-color-scheme`, pour que les deux couleurs
+s'éclaircissent contre une barre d'onglets sombre. Le PNG n'existe que parce qu'iOS
+ignore les icônes SVG.
+
+## Les crochets de l'icône sont redessinés, le `d` non
+
+Le crochet de Plex fait 311 unités face à un `d` de 486, et à 16 px ce partage
+referme la panse en une tache. L'icône garde le fût (104) et le bras (88) de la
+police mais raccourcit le bras à 185, ce qui ramène la marque de 1,45∶1 à 1,13∶1 et
+donne à la contreforme les pixels qu'il lui faut.
+
+Le logo n'a pas cette contrainte et reste du Plex pur. C'est toute la différence
+entre les deux fichiers, et la raison pour laquelle on ne peut pas les produire
+depuis une source unique.
+
+## Couleurs
+
+Les crochets portent la couleur ; les lettres prennent celle du texte environnant.
+Seule la paire de crochets est fixe.
+
+| Rôle | Hex | OKLCH |
+| :----------------- | :-------- | :----------------------- |
+| Crochets (clair) | `#4F46E5` | `oklch(0.511 0.230 277)` |
+| Crochets (sombre) | `#7D7BF5` | `oklch(0.644 0.178 281)` |
+| Lettres (clair) | `#15171E` | `oklch(0.206 0.014 273)` |
+| Lettres (sombre) | `#E8EBF2` | `oklch(0.940 0.010 267)` |
+
+## Licence
+
+IBM Plex est sous [SIL OFL 1.1](https://github.com/IBM/plex/blob/master/LICENSE.txt),
+et la clause 5 tranche explicitement ce cas : _« The requirement for fonts to
+remain under this license does not apply to any document created using the Font
+Software. »_ Un SVG contenant des contours est un tel document, aucun fichier de
+police n'est redistribué, et le nom réservé « Plex » n'apparaît dans aucune
+ressource. Rien n'est à payer, à créditer ni à relicencier — la mention ci-dessus
+est une courtoisie.
+
+## Rien de tout cela n'est publié
+
+::callout{type="warning" title="La couche ne porte aucune identité"}
+`duxt.logo` n'est pas défini par défaut, et `DuxtBrand` retombe sur le `duxt.title`
+du consommateur à côté d'une icône générique. Un site qui étend duxt affiche son
+propre nom dans l'en-tête et le pied de page et sa propre icône dans l'onglet,
+jamais celle-ci. Chaque ressource ci-dessus appartient à ce dépôt et à `www/` —
+aucune n'est dans le paquet publié.
+::
+
+Définir la vôtre, c'est
+[Donner son identité à votre site](/guides/brand-your-site).
diff --git a/docs/fr/6.conventions/index.md b/docs/fr/6.conventions/index.md
new file mode 100644
index 00000000..24210a28
--- /dev/null
+++ b/docs/fr/6.conventions/index.md
@@ -0,0 +1,18 @@
+---
+title: Conventions
+description: Règles propres au projet — celles qu'on ne peut pas déduire du code.
+icon: lucide:ruler
+---
+
+Des règles que ce projet s'impose, écrites parce que le code ne peut pas les
+énoncer. Elles engagent le travail dans ce dépôt ; rien ici ne contraint un site
+qui étend la couche.
+
+Une règle dit ce qui se fait ; une [décision d'architecture](/adr) dit pourquoi
+elle l'a emporté sur les autres options, et reste lisible une fois le
+raisonnement sorti de toutes les têtes. Le journal des décisions ne fait que
+s'ajouter — d'où sa place à côté de ces pages et non parmi elles : celles-ci se
+modifient quand elles changent.
+
+::page-cards
+::
diff --git a/docs/fr/99.adr/0001-build-duxt-as-a-layer-carrying-a-module.md b/docs/fr/99.adr/0001-build-duxt-as-a-layer-carrying-a-module.md
new file mode 100644
index 00000000..1c496cb7
--- /dev/null
+++ b/docs/fr/99.adr/0001-build-duxt-as-a-layer-carrying-a-module.md
@@ -0,0 +1,63 @@
+---
+title: Construire duxt comme une couche portant un module
+description: Distribuer le thème comme une couche Nuxt dont la racine est le paquet, plutôt que comme un modèle de départ.
+status: accepted
+date: 2026-09-06
+---
+
+## Contexte
+
+Un thème de documentation se distribue de deux manières. Un **modèle de départ**
+est généré dans le dépôt du consommateur, où chaque fichier lui appartient et où
+plus aucune amélioration ne lui parvient jamais, sinon comme un diff que
+quelqu’un applique à la main. Une **couche** reste une dépendance : le
+consommateur l’étend, surcharge les fichiers avec lesquels il n’est pas
+d’accord, et emporte le reste des améliorations avec un changement de version.
+
+Une partie de ce que le thème doit faire n’est pas quelque chose qu’une couche
+puisse exprimer en fichiers. Générer des collections, résoudre une liste de
+sources en préfixes d’URL, transformer le frontmatter en règles de route et
+valider le résultat sont du travail de compilation, et le travail de compilation
+dans Nuxt, c’est un module.
+
+## Décision
+
+Nous distribuons duxt comme une **couche Nuxt qui porte ses propres modules**,
+consommée avec une seule entrée `extends`. La racine du dépôt *est* la couche :
+la configuration Nuxt, la configuration content et `app/` sont à la racine, et
+le manifeste du paquet pointe vers eux, si bien que `extends: ['@kirchdev/duxt']`
+se résout sans étape de compilation. Le site consommateur vit à côté, dans le
+même dépôt, et c’est la cible de développement.
+
+## Conséquences
+
+Un consommateur hérite du thème, des pages, des composants, des valeurs de
+configuration par défaut et des collections, et surcharge n’importe lequel en
+créant un fichier du même nom. Mettre à jour est un changement de version.
+
+Rien de relatif à la couche ne se résout comme cela se lit. Un chemin écrit dans
+la couche est lu depuis le répertoire du consommateur, sauf s’il a été résolu par
+rapport à l’emplacement de la couche elle-même, et l’alias `@` appartient à celui
+qui étend la couche, pas à la couche — les imports propres à la couche ont donc
+besoin de leur propre alias. Cela a coûté de vrais bugs, et c’est le prix de
+l’arrangement plutôt qu’un oubli.
+
+Les noms surchargeables deviennent une surface publique. Un composant, une page
+ou une clé de configuration qu’un consommateur peut masquer est un nom dont il
+dépend : en renommer un est donc une publication cassante, et c’est la surface
+documentée qui la fige.
+
+Le site de développement et un modèle de départ sont des artefacts distincts. Le
+site posé à côté de la couche veut des cas limites, du frontmatter laid,
+plusieurs sources et un tag à lire ; un inconnu qui clone un modèle veut
+l’inverse. Les confondre rendrait mauvais l’un des deux.
+
+## Alternatives envisagées
+
+**Un modèle de départ.** Liberté totale pour le consommateur, aucun chemin de
+mise à jour pour personne — la raison pour laquelle la couche l’a emporté.
+
+**Un module sans couche.** Un module peut enregistrer des composants et des
+routes, mais la substance du thème, ce sont des fichiers qu’un consommateur doit
+pouvoir masquer, et les livrer par un module signifie les injecter au lieu de
+laisser faire la résolution de couches propre à Nuxt.
diff --git a/docs/fr/99.adr/0002-generate-the-collections-from-one-source-list.md b/docs/fr/99.adr/0002-generate-the-collections-from-one-source-list.md
new file mode 100644
index 00000000..6b3fb592
--- /dev/null
+++ b/docs/fr/99.adr/0002-generate-the-collections-from-one-source-list.md
@@ -0,0 +1,61 @@
+---
+title: Générer les collections à partir d’une seule liste de sources
+description: Calculer les collections Content à partir d’une liste de sources compacte au chargement de la configuration, au lieu de les faire déclarer par les consommateurs.
+status: accepted
+date: 2026-09-06
+---
+
+## Contexte
+
+Nuxt Content alimente une collection depuis un dépôt git, sur une branche ou un
+tag, s’authentifie auprès d’un dépôt privé et met le téléchargement en cache par
+hachage. Cela a été vérifié en lisant Content lui-même avant que quoi que ce soit
+d’ici ne soit construit, et cela signifie que la moitié difficile d’une
+documentation multi-dépôts et versionnée existait déjà et n’avait pas besoin
+d’être refaite.
+
+Ce qui n’existait pas, c’était l’ergonomie. Un site servant plusieurs versions de
+plusieurs projets déclare une collection par dépôt × ref, à la main : trois
+versions sur quatorze dépôts font quarante-deux déclarations, et couper une
+version les édite toutes les quatorze. Content n’offre pas non plus de hook pour
+injecter des collections — mais il charge la configuration content de chaque
+couche par c12, ce qui veut dire que ce fichier est du code exécuté et non un
+fichier de données, et qu’il peut calculer ses collections au moment où il est
+chargé.
+
+## Décision
+
+Les consommateurs déclarent une **liste de sources** compacte — un dossier,
+éventuellement un dépôt, éventuellement des refs — dans l’app config du site
+lui-même, et la configuration content de la couche en calcule une collection par
+source × ref au chargement. La même liste est résolue une seconde fois par la
+compilation, en un manifeste nommant quelle collection sert quel préfixe d’URL,
+et c’est ce manifeste que le thème lit.
+
+## Conséquences
+
+La liste a la longueur du nombre de projets plutôt que celle du produit des
+projets par les versions, et le dossier unique non versionné ne demande aucune
+configuration du tout.
+
+Le raccourci exprime moins qu’une collection écrite à la main, et ce sera
+toujours le cas. Ce n’est supportable que parce que Content fusionne la
+configuration content de chaque couche, la dernière l’emportant : un consommateur
+qui a besoin de ce que le raccourci ne sait pas dire écrit son propre fichier et
+reprend la main complètement.
+
+Plusieurs fonctionnalités cessent d’avoir besoin de leur propre configuration,
+parce qu’une source nomme déjà un dépôt, une ref et un dossier — les liens de
+retour vers la source, la date de dernière mise à jour et la liste des
+contributeurs en découlent tous.
+
+Les noms de collection deviennent des données. Un site à deux dépôts n’a pas de
+collection appelée `docs` : rien dans la couche ne peut donc en nommer une, et le
+thème lit le nom dans le manifeste à la place. Du code qui code en dur un nom de
+collection fonctionne sur un site à source unique et casse sur tous les autres.
+
+Les chemins des collections se résolvent par rapport à la couche et non au
+consommateur, parce que Content enregistre comme racine d’une collection la
+couche qui l’a déclarée. La couche calcule donc des chemins absolus, ce qui n’est
+possible que parce que la configuration est du code exécuté — la propriété même
+sur laquelle repose tout le raccourci.
diff --git a/docs/fr/99.adr/0003-decide-the-url-prefixes-at-build-time.md b/docs/fr/99.adr/0003-decide-the-url-prefixes-at-build-time.md
new file mode 100644
index 00000000..a53e2db3
--- /dev/null
+++ b/docs/fr/99.adr/0003-decide-the-url-prefixes-at-build-time.md
@@ -0,0 +1,50 @@
+---
+title: Décider les préfixes d’URL à la compilation
+description: L’apparition d’un segment de dépôt ou de version est tranchée par la liste des sources avant la première requête, jamais par requête.
+status: accepted
+date: 2026-09-06
+---
+
+## Contexte
+
+Un site construit à partir de plusieurs sources doit servir un segment de dépôt
+et un segment de version dans ses URL, et un site construit à partir d’un seul
+dossier ne le doit pas — personne ne veut `/my-project/main/guides/deploying`
+pour un projet doté d’un unique dossier de documentation non versionné.
+
+Rendre chaque segment facultatif par requête ne fonctionne pas. Les deux étant
+facultatifs, le premier segment de `/guides/…` pourrait être un dossier, un dépôt
+ou une version, et seule la consultation des trois le dirait. Cette ambiguïté
+n’est pas un désagrément de routage ; elle fait dépendre le sens d’une URL de ce
+qui se trouve exister.
+
+## Décision
+
+Chaque préfixe est activé **pour le site entier, à la compilation, à partir de la
+liste des sources** : un segment de dépôt dès que plus d’un dépôt est publié ou
+qu’une option le force, un segment de version dès qu’une source publie plus d’une
+ref ou qu’une option le force. Une ref par dépôt est servie sans aucun segment de
+version.
+
+## Conséquences
+
+La forme de chaque URL est figée avant la première requête : le routeur ne devine
+donc jamais, et un lien écrit dans une page peut être résolu par une vérification
+à la compilation plutôt qu’en l’essayant.
+
+Un dossier unique non versionné sert des chemins qui ne trahissent en rien
+l’existence de dépôts ou de versions, et c’est ce qui rend le cas le plus simple
+gratuit.
+
+Faire passer un site d’une source à deux change chaque URL qu’il sert. C’est une
+migration, et c’est la machinerie de redirection qui la rend supportable.
+
+Il reste une collision, et aucune conception ne peut l’écarter : un dossier de
+documentation nommé comme un segment de dépôt ou de version, où le préfixe
+l’emporte et le dossier devient inatteignable. La compilation la rejette avec un
+message plutôt que de la trancher silencieusement dans un sens.
+
+Parce que le préfixe propre à une page n’est connu que de la compilation, les
+liens à l’intérieur des pages s’écrivent comme des chemins de documentation nus
+et sont résolus par rapport à la source de la page au moment du rendu. Une page
+qui code en dur son propre préfixe est correcte sur exactement un site.
diff --git a/docs/fr/99.adr/0004-render-markdown-components-with-mdc.md b/docs/fr/99.adr/0004-render-markdown-components-with-mdc.md
new file mode 100644
index 00000000..237fb983
--- /dev/null
+++ b/docs/fr/99.adr/0004-render-markdown-components-with-mdc.md
@@ -0,0 +1,48 @@
+---
+title: Rendre les composants Markdown avec MDC
+description: Utiliser la syntaxe MDC propre à Content pour les composants dans le Markdown plutôt que d’adopter MDX.
+status: accepted
+date: 2026-09-06
+---
+
+## Contexte
+
+Un thème de documentation a besoin de composants au milieu de la prose —
+encadrés, code à onglets, champs de paramètres, arborescences. Deux syntaxes
+étaient disponibles. **MDX** compile le Markdown en un module de composant et
+laisse une page importer et écrire du JSX ; **MDC** est la syntaxe de composants
+en bloc et en ligne que Content livre et analyse déjà.
+
+Les pages sont aussi lues par autre chose qu’un navigateur. Le thème publie la
+source Markdown de chaque page à destination des modèles : la syntaxe dans
+laquelle les pages sont écrites est donc celle qu’un modèle reçoit.
+
+## Décision
+
+Les composants dans le Markdown sont du **MDC**. Les composants appelables
+depuis une page vivent dans un répertoire de contenu dédié, où le fichier du
+même nom d’un consommateur remplace celui de la couche.
+
+## Conséquences
+
+Rien n’a besoin d’être installé ni configuré pour qu’une page appelle un
+composant, et une page reste un fichier Markdown au lieu de devenir un module.
+
+Un bloc MDC survit au fait d’être remis à un modèle sous forme de texte : il se
+lit comme un appel de composant avec des arguments nommés. Du JSX compilé, non.
+
+La syntaxe est celle de Content : ses capacités et ses limites sont donc aussi
+celles de Content. Tout ce qu’une page voudrait et que MDC ne sait pas exprimer
+doit être résolu comme un composant plutôt que comme une expression dans la page
+— ce qui est une contrainte pour les auteurs et, pour un site de documentation,
+une contrainte souhaitable.
+
+Les noms de composants du répertoire de contenu font partie de la surface
+publique, puisqu’une page écrite pour l’un d’eux est un fichier de consommateur.
+
+## Alternatives envisagées
+
+**MDX.** Plus expressif, et chaque parcelle de cette expressivité est du
+JavaScript dans une page de documentation. Il faudrait en plus un module et un
+chemin de compilation que Content n’a pas, pour aboutir à des pages moins bonnes
+à remettre à un modèle.
diff --git a/docs/fr/99.adr/0005-ship-the-layer-without-owner-specific-links.md b/docs/fr/99.adr/0005-ship-the-layer-without-owner-specific-links.md
new file mode 100644
index 00000000..61bfd716
--- /dev/null
+++ b/docs/fr/99.adr/0005-ship-the-layer-without-owner-specific-links.md
@@ -0,0 +1,45 @@
+---
+title: Livrer la couche sans liens propres à un propriétaire
+description: Toute valeur par défaut qui nommerait un projet précis — dépôt, gestionnaire de tickets, communauté, mentions légales — est livrée vide.
+status: accepted
+date: 2026-09-06
+---
+
+## Contexte
+
+Un thème de documentation dessine plusieurs rangées de liens : des liens en
+icône dans la barre de navigation, un bloc communauté à côté du sommaire, des
+liens légaux dans le pied de page, des boutons sur la page d’accueil. Les
+remplir avec le projet du thème lui-même donne à un site de démonstration l’air
+fini — et remet à chaque consommateur un bouton « mettre une étoile à ce dépôt »
+qui met une étoile au travail de quelqu’un d’autre, une communauté qui n’est pas
+la sienne, et des mentions légales juridiquement fausses pour lui.
+
+La rangée légale du pied de page a tranché le principe la première : un site
+allemand doit afficher des mentions légales, et ce n’est indubitablement pas au
+thème de les fournir.
+
+## Décision
+
+Toute valeur par défaut qui nommerait un projet ou une organisation précise est
+livrée **vide**. Les liens du thème lui-même vivent dans la configuration du
+site consommateur, où ils sont un exemple plutôt qu’une valeur par défaut.
+L’habillage générique qui ne nomme personne — un titre de colonne, une action
+« lire la documentation » — reste dans la couche.
+
+## Conséquences
+
+Un inconnu qui étend la couche obtient une rangée vide plutôt qu’une rangée
+fausse, et une rangée vide manque visiblement là où un lien faux a l’air
+correct.
+
+Le site de démonstration porte plus de configuration qu’il n’en faut à un
+consommateur minimal, et c’est le rôle de ce site : il est l’exemple travaillé
+de chaque rangée qu’un consommateur doit remplir.
+
+Les clés de message de la couche restent en place quand ses liens s’en vont, et
+ces clés sont internes. Un consommateur qui irait y puiser dépendrait d’un nom
+qui peut être renommé sans version majeure, et une clé absente s’affiche comme
+la clé — la rupture atteindrait donc un lecteur avant d’atteindre une
+compilation. Les consommateurs écrivent leurs propres chaînes ; la
+[page de configuration](/getting-started/configuration) dit comment.
diff --git a/docs/fr/99.adr/0006-rebuild-on-a-schedule-rather-than-refresh-at-runtime.md b/docs/fr/99.adr/0006-rebuild-on-a-schedule-rather-than-refresh-at-runtime.md
new file mode 100644
index 00000000..3a7bc4ab
--- /dev/null
+++ b/docs/fr/99.adr/0006-rebuild-on-a-schedule-rather-than-refresh-at-runtime.md
@@ -0,0 +1,37 @@
+---
+title: Recompiler de façon planifiée plutôt que rafraîchir à l’exécution
+description: Un site qui lit un autre dépôt en récupère les changements quand il compile, et aucun rafraîchissement à l’exécution n’est proposé.
+status: accepted
+date: 2026-09-06
+---
+
+## Contexte
+
+Un site qui lit sa documentation dans d’autres dépôts a un souhait évident :
+récupérer un push sans déploiement. Content, lui, télécharge un dépôt distant à
+la compilation et le compile dans la base de données que la compilation livre.
+Rafraîchir cela dans un serveur en fonctionnement reviendrait à reconstruire la
+base sur place — ce pour quoi Content n’offre aucune voie prise en charge, et la
+seule disponible est cette même base de données client que lit la recherche.
+
+## Décision
+
+Content est lu à la compilation et n’est jamais rafraîchi à l’exécution. Un site
+dont les sources ont avancé est recompilé : de façon planifiée, ou déclenché
+depuis le dépôt source.
+
+## Conséquences
+
+Un déploiement est immuable et une compilation reproductible — les pages servies
+sont exactement les pages qui ont été compilées, et le même commit produit le
+même site. La sortie statique reste possible, ce qui est la bonne valeur par
+défaut pour de la documentation.
+
+La documentation est en retard sur sa source de l’intervalle de recompilation,
+et un dépôt source qui veut sa documentation en direct doit déclencher la
+compilation du site. C’est le coût, et il se paie en exploitation plutôt que
+dans la couche.
+
+Rien dans le thème ne peut supposer qu’il lui est possible de relire une source.
+Une fonctionnalité qui voudrait du contenu plus frais que celui de la
+compilation demande une autre architecture, pas un réglage.
diff --git a/docs/fr/99.adr/0007-serve-translations-as-collections-of-their-own.md b/docs/fr/99.adr/0007-serve-translations-as-collections-of-their-own.md
new file mode 100644
index 00000000..07a921b2
--- /dev/null
+++ b/docs/fr/99.adr/0007-serve-translations-as-collections-of-their-own.md
@@ -0,0 +1,105 @@
+---
+title: Servir les traductions comme des collections à part
+description: Ajouter une dimension de locale à une source plutôt qu’un segment de locale au chemin de contenu.
+status: accepted
+date: 2026-09-06
+---
+
+## Contexte
+
+La couche traduisait son interface en sept locales et servait un seul jeu de
+pages à toutes : `useDuxtPath()` retirait le segment de locale avant chaque
+recherche de contenu, si bien que `/de-DE/guides/deploying` et
+`/guides/deploying` se résolvaient vers le même fichier. Content v3 n’a aucune
+notion de locale — une collection est un arbre — les pages traduites appelaient
+donc une décision plutôt qu’un réglage.
+
+Ce que font les générateurs comparables a été lu plutôt que supposé. Starlight,
+VitePress, Docusaurus et MkDocs placent toutes leurs traductions dans un dossier
+par langue ; seul Starlight a un repli documenté pour une page qui manque à une
+langue. Passé une certaine taille, la traduction quitte l’outil entièrement :
+React fait tourner `de.react.dev` comme son propre dépôt, Vue toute une
+organisation `vuejs-translations`, parce que les traducteurs travaillent selon
+leur propre calendrier et leur propre relecture. OpenCode a construit un agent
+qui traduisait sa documentation en CI, l’a exécuté, puis l’a coupé ; dix-sept
+langues sont immobiles depuis.
+
+Le coût a été mesuré avant que la forme ne soit choisie : les compilations sur
+1 … 200 collections évoluent linéairement, à environ 2,2 s et 0,63 Mo de base de
+données chacune, sans coude. La matrice n’a pas de plafond qui forcerait la main
+à la conception.
+
+## Décision
+
+Une source gagne une liste `locales`, et une ref aussi, résolue comme l’est déjà
+`status` (`ref.locales ?? source.locales`). Une chaîne est un dossier à
+l’intérieur du `path` de la source ; un objet déplace cette langue dans son
+propre dossier, dépôt ou ref.
+
+**La locale par défaut est l’arbre de `path` lui-même, sans dossier**, si bien
+qu’ajouter la clé ne déplace aucune URL qu’un site sert déjà.
+
+**La locale ne fait pas partie du chemin de contenu.** Elle appartient au
+routage d’i18n, qui la place de toute façon devant le chemin. L’original et la
+traduction vivent donc sous des chemins de contenu identiques, dans des
+collections distinctes.
+
+Une page absente d’une langue retombe le long d’une chaîne — la locale, sa
+langue de base, une région sœur, le `fallbackLocale` de vue-i18n, l’original non
+traduit — et le lecteur est informé, par un bandeau, de la langue qui lui est
+montrée.
+
+## Conséquences
+
+Aucune comparaison de chemin du thème n’est touchée : la navigation, les
+redirections, le fil d’Ariane, le score de page la plus proche de la 404 et le
+sélecteur de langue continuent tous de fonctionner sur un chemin qui n’a jamais
+porté de locale. Le repli est une requête de plus pour le même chemin plutôt
+qu’une redirection ou un second schéma de résolution.
+
+`useDuxtNavigation` et la recherche suivent `useDuxtCollection` : les deux sont
+donc devenus sensibles à la locale sans être modifiés.
+
+Un site qui ne règle rien obtient exactement ce qu’il avait : une collection
+nommée `docs`, une entrée dans le manifeste, une requête par page.
+
+Deux configurations peuvent désormais diverger d’une manière qui produit une
+page vide plutôt qu’une erreur — `content.config.ts` résout la locale par défaut
+sans accès à la configuration Nuxt. Le module duxt vérifie donc
+`sourceOptions.defaultLocale` contre `i18n.defaultLocale` et fait échouer la
+compilation lorsqu’elles diffèrent, au lieu d’injecter l’une dans l’autre et de
+laisser Content calculer l’autre réponse.
+
+Les traductions multiplient les collections, et la compilation paie chacune
+linéairement. Le chiffre a sa place dans la documentation, parce que c’est le
+consommateur qui décide de la matrice.
+
+Les fragments suivent les pages. `_partials/` est une collection PAR LANGUE,
+nommée comme les collections de pages — `duxt_partials` pour l’original,
+`duxt_partials_de` à côté — et `:partial{name}` parcourt la même chaîne de repli
+que la page. Ce doit être la même chaîne : qu’une page et les blocs qu’elle
+inclut se replient sur des langues différentes, c’est exactement ainsi qu’on
+obtient une page à moitié traduite sans que rien ne le signale.
+
+La première version de cette décision partageait UNE collection de fragments non
+traduits entre toutes les langues. C’était défendable tant que rien ne traduisait
+un fragment, et cela a cessé de l’être au moment où une passe de traduction a
+produit `docs/de/_partials/` — des fichiers qu’aucune collection ne lisait, et
+une page allemande affichant une note d’installation en anglais sans que rien
+n’indique que cela s’était produit.
+
+## Alternatives envisagées
+
+**Un segment de locale dans le préfixe de contenu.** Symétrique avec `repo` et
+`version`, et cela aurait obligé chaque comparaison de chemin du thème à
+apprendre les locales — pour une URL qu’i18n préfixe déjà, et qui serait donc
+écrite deux fois.
+
+**Un suffixe de fichier — `installation.de-DE.md` à côté de l’original.** Aucune
+multiplication des collections, et cela échoue sur le cas qu’ont réellement les
+gros projets : cela force la traduction dans le même dépôt et la même ref que
+l’original.
+
+**Une 404 pour une traduction manquante.** Ce que fait VitePress par omission.
+Cela punit le lecteur pour un trou laissé par l’auteur, et cela cache à tous les
+autres que la traduction est incomplète.
diff --git a/docs/fr/99.adr/0008-build-the-theme-on-owned-shadcn-vue-components.md b/docs/fr/99.adr/0008-build-the-theme-on-owned-shadcn-vue-components.md
new file mode 100644
index 00000000..4925dd2d
--- /dev/null
+++ b/docs/fr/99.adr/0008-build-the-theme-on-owned-shadcn-vue-components.md
@@ -0,0 +1,72 @@
+---
+title: Bâtir le thème sur des composants shadcn-vue possédés
+description: La couche détient le code source de ses composants d'interface plutôt que de les importer d'une bibliothèque de composants ou d'un thème de documentation tout fait.
+status: accepted
+date: 2026-09-08
+---
+
+## Contexte
+
+Une couche de documentation, c'est surtout de l'interface : un en-tête, une barre
+latérale, un sommaire, des blocs de code, une boîte de recherche. Quelque chose
+doit les dessiner, et ce choix décide de ce qu'un consommateur peut changer sans
+forker.
+
+Toute la proposition de la couche tient à ce qu'on l'étend au lieu de la générer —
+`extends: ['@kirchdev/duxt']`, et chaque fichier reste surchargeable. Un thème
+dont l'apparence ne serait atteignable que par les options auxquelles son auteur
+a pensé contredirait cela dès la première chose qu'un consommateur voudrait
+différente. La résolution de couches de Nuxt offre déjà la surcharge au niveau du
+fichier — la question était donc de savoir ce que devaient être ces fichiers.
+
+## Décision
+
+La couche possède le code source de ses composants. shadcn-vue sert de source des
+primitives : son CLI écrit le code d'un composant dans le dépôt, et à partir de là
+le fichier appartient à la couche, non à une dépendance. `components.json` pointe
+le CLI sur l'alias propre à la couche : ajouter une primitive tient en une
+commande, et Tailwind fournit la mise en forme en dessous.
+
+La palette est faite de propriétés personnalisées CSS. Les composants lisent des
+jetons et ne détiennent aucune couleur, ce qui permet à une surcharge d'une seule
+ligne d'atteindre toutes les surfaces.
+
+## Conséquences
+
+Un consommateur peut changer n'importe quelle partie de l'interface, à la
+profondeur qu'exige le changement : redéfinir un jeton, occulter un composant par
+son nom, ou ajouter une primitive à lui. Rien de tout cela ne demande un fork, et
+rien n'attend que la couche expose une option pour cela.
+
+Le coût, c'est l'entretien. Un correctif sur une primitive n'arrive pas avec une
+montée de version — il arrive quand quelqu'un relance le CLI pour ce composant. La
+couche l'assume pour les primitives qu'elle livre ; un consommateur qui en
+surcharge une l'assume ensuite. C'est autour de ce compromis qu'est construit le
+guide de surcharge, et c'est la raison de son existence.
+
+Les fichiers repris que le CLI n'atteint pas sont le tranchant du même compromis :
+une feuille de style sans entrée de registre ne peut être mise à jour qu'en la
+retéléchargeant, si bien que les modifications locales se perdent en silence au
+lieu d'entrer bruyamment en conflit.
+
+## Alternatives envisagées
+
+**Un thème de documentation tout fait.** Docus est le choix évident — le thème
+propre de Nuxt Content, et tout ce dont un site de documentation a besoin au
+premier lancement. Écarté pour la raison même qui le rend attirant : le site qu'il
+produit est celui de son auteur, et le remodeler suppose soit une option qui
+existe, soit un fork. Une couche dont les consommateurs devaient se distinguer les
+uns des autres ne pouvait accepter ce plafond.
+
+**Une bibliothèque de composants en dépendance.** Nuxt UI aurait fourni les
+primitives sans l'entretien, et la couche aurait suivi ses versions au lieu de
+copier du code. Écarté parce que les composants d'une dépendance ne se changent
+que dans les limites de leurs props, et la couche aurait alors dû inventer un
+second mécanisme de surcharge pour le reste — avec deux, aucun n'aurait été celui
+vers lequel on se tourne d'évidence.
+
+**Des composants écrits à la main, sans amont du tout.** Cela évacue entièrement
+la question de la dépendance et a été écarté pour son coût : les primitives
+accessibles sont difficiles précisément là où l'on se trompe facilement, et une
+boîte de dialogue ou une combobox écrites de zéro seraient moins bonnes que
+celles adaptées d'une source qui a déjà résolu le problème.
diff --git a/docs/fr/99.adr/0009-take-the-seo-stack-from-the-nuxt-seo-bundle.md b/docs/fr/99.adr/0009-take-the-seo-stack-from-the-nuxt-seo-bundle.md
new file mode 100644
index 00000000..d722555f
--- /dev/null
+++ b/docs/fr/99.adr/0009-take-the-seo-stack-from-the-nuxt-seo-bundle.md
@@ -0,0 +1,97 @@
+---
+title: Prendre le socle SEO dans le bundle Nuxt SEO
+description: La couche installe @nuxtjs/seo et lui confie les balises de head et les données structurées qu'elle écrivait à la main, ne gardant que les règles qui dépendent des versions et des traductions.
+status: accepted
+date: 2026-09-08
+---
+
+## Contexte
+
+La couche embarquait déjà trois des modules de Nuxt SEO — robots, sitemap et
+image OG —, choisis un à un au fil des besoins. Tout ce qu'ils ne couvrent pas
+était écrit à la main : un lien canonique, un bloc `og:`/`twitter:` par page et un
+`@graph` JSON-LD assemblé dans une chaîne de gabarit au sein de `[...slug].vue`.
+
+Cela fonctionnait et restait invisible. C'est précisément le problème : rien de
+tout cela n'était couvert par un test, parce que le SEO vit dans le HTML rendu et
+non dans la logique, et que les tests de ce dépôt ne couvrent délibérément que la
+logique pure. La page d'accueil — celle qu'on partage le plus volontiers —
+n'avait aucune carte sociale, la page d'erreur était indexable, et sept langues
+étaient publiées sans un seul `og:locale`. Chacune de ces lacunes était un oubli
+que personne ne pouvait voir.
+
+La moitié écrite à la main était aussi celle qui grossit. Les données structurées
+forment une spécification à large surface, dotée de ses propres validateurs ;
+chaque nœud ajouté à la main est un nœud dont il faut deviner la forme en lisant
+la spécification.
+
+## Décision
+
+La couche dépend de `@nuxtjs/seo` et le charge comme un seul module, à la place
+qu'occupaient les trois modules nommés — avant `@nuxt/content`, parce que
+l'intégration de la sitemap avec Content l'exige.
+
+Le bundle est un alias, pas une surcouche : sa propre documentation indique qu'il
+« ne contient aucune logique propre ». Ce qu'il apporte, ce sont les quatre
+modules manquants — `nuxt-schema-org` pour le graphe, `nuxt-seo-utils` pour le
+canonique automatique et les balises sociales dérivées, `nuxt-link-checker`, et
+`nuxt-site-config` comme unique endroit d'où `site.url` est lu — ainsi que le
+panneau devtools partagé, qui rend compte de ceux qui sont installés.
+
+Trois valeurs par défaut de `nuxt-seo-utils` sont désactivées, chacune pour une
+raison que la couche ne peut pas contourner par conception :
+`canonicalLowercase`, parce qu'un préfixe de langue est sensible à la casse et que
+`/de-DE/` n'est pas `/de-de/` ; `fallbackTitle`, parce qu'un titre inventé à
+partir d'un slug masquerait le validateur de build qui échoue sur une page sans
+titre ; et `mergeWithSiteConfig`, parce que `app.vue` est propriétaire du gabarit
+de titre.
+
+Reste écrit à la main ce que les modules ne peuvent pas savoir : le canonique
+d'une page versionnée pointe vers la version courante et non vers la page en
+cours de rendu, et `noindex` découle du fait qu'une version est ancienne ou qu'une
+page est servie dans une langue vers laquelle elle n'a pas été traduite.
+
+La vérification des liens signale au lieu d'échouer. `modules/validate.ts` fait
+déjà échouer un build sur un lien qui ne mène nulle part, et c'est la
+vérification qui comprend les versions et les replis de langue.
+
+## Conséquences
+
+Chaque consommateur de la couche installe sept modules là où il en installait
+trois. C'est le prix de la décision, et il est payé aussi par les sites qui
+n'utiliseront aucun des quatre nouveaux.
+
+Les règles qui n'étaient que des affirmations dans un commentaire sont désormais
+des assertions dans `scripts/check-seo.ts`, qui lit les pages construites et
+échoue sur un second canonique, un `hreflang` absent, une page d'erreur
+indexable, ou un graphe qui ne s'analyse pas. Il tourne dans `check` à côté de
+`check:a11y`, pour la même raison : ces balises n'existent que dans le HTML rendu.
+
+Deux de ces règles n'étaient pas vérifiables auparavant, car elles n'existent que
+lorsque le site connaît sa propre origine, et `www` ne déclare volontairement
+aucun domaine. La vérification remet donc au serveur construit sa propre adresse,
+via les variables d'environnement que les modules lisent déjà, plutôt qu'un
+domaine figé dans une configuration qu'un consommateur recopierait.
+
+Publier une `Organization` exige un fait que la couche ne doit pas inventer :
+elle attend donc une nouvelle clé `duxt.organization` et reste absente tant qu'un
+consommateur ne l'a pas renseignée — la même position que l'ADR-0005.
+
+## Alternatives envisagées
+
+**Garder les trois modules et n'ajouter que `nuxt-schema-org`.** Le changement le
+plus étroit, écarté à cause du canonique : la règle de version et le canonique
+automatique doivent de toute façon être conciliés, et le faire sans
+`nuxt-seo-utils` revient à conserver le bloc `og:`/`twitter:` écrit à la main,
+déjà oublié sur deux pages.
+
+**Écrire les données structurées à la main et les garder.** Cela fonctionnait,
+sans dépendance, et a été écarté parce que le graphe était déjà la partie la plus
+susceptible d'être fausse et la moins susceptible d'être remarquée — et parce
+qu'une deuxième page réclamant un deuxième nœud aurait signifié une deuxième copie
+de l'identité du site enfouie dedans.
+
+**Laisser le vérificateur de liens casser le build.** Écarté parce que deux
+gardiens sur une même règle laissent le plus laxiste décider du moment où un
+build casse. La vérification qui comprend les versions et les replis de langue de
+cette couche est la sienne.
diff --git a/docs/fr/99.adr/index.md b/docs/fr/99.adr/index.md
new file mode 100644
index 00000000..e31626a8
--- /dev/null
+++ b/docs/fr/99.adr/index.md
@@ -0,0 +1,24 @@
+---
+title: Décisions d’architecture
+description: Le journal des décisions — chaque décision d’architecture consignée pour duxt.
+icon: lucide:gavel
+---
+
+Une décision mérite un ADR lorsqu’elle contraint le travail qui vient ensuite et
+que son raisonnement serait sinon perdu : un choix entre de vraies alternatives,
+une convention que chaque partie du projet doit suivre, un compromis qui
+ressemble à une erreur tant qu’on n’en connaît pas la raison. Les enregistrements
+sont en ajout seul — une décision renversée s’écrit comme un nouvel ADR qui
+remplace l’ancien, jamais comme une modification de celui-ci.
+
+| ADR | Décision | Statut | Date |
+| :------------------------------------------------------------------------- | :----------------------------------------------------------------- | :------- | :--------- |
+| [ADR-0001](/adr/0001-build-duxt-as-a-layer-carrying-a-module) | Construire duxt comme une couche portant un module | accepted | 2026-09-06 |
+| [ADR-0002](/adr/0002-generate-the-collections-from-one-source-list) | Générer les collections à partir d’une seule liste de sources | accepted | 2026-09-06 |
+| [ADR-0003](/adr/0003-decide-the-url-prefixes-at-build-time) | Décider les préfixes d’URL à la compilation | accepted | 2026-09-06 |
+| [ADR-0004](/adr/0004-render-markdown-components-with-mdc) | Rendre les composants Markdown avec MDC | accepted | 2026-09-06 |
+| [ADR-0005](/adr/0005-ship-the-layer-without-owner-specific-links) | Livrer la couche sans liens propres à un propriétaire | accepted | 2026-09-06 |
+| [ADR-0006](/adr/0006-rebuild-on-a-schedule-rather-than-refresh-at-runtime) | Recompiler de façon planifiée plutôt que rafraîchir à l’exécution | accepted | 2026-09-06 |
+| [ADR-0007](/adr/0007-serve-translations-as-collections-of-their-own) | Servir les traductions comme des collections à part | accepted | 2026-09-06 |
+| [ADR-0008](/adr/0008-build-the-theme-on-owned-shadcn-vue-components) | Bâtir le thème sur des composants shadcn-vue possédés | accepted | 2026-09-08 |
+| [ADR-0009](/adr/0009-take-the-seo-stack-from-the-nuxt-seo-bundle) | Prendre le socle SEO dans le bundle Nuxt SEO | accepted | 2026-09-08 |
diff --git a/docs/fr/_partials/install.md b/docs/fr/_partials/install.md
new file mode 100644
index 00000000..7a5ec489
--- /dev/null
+++ b/docs/fr/_partials/install.md
@@ -0,0 +1,6 @@
+---
+title: Installation
+---
+
+::package-managers{command="add -D @kirchdev/duxt"}
+::
diff --git a/docs/fr/index.md b/docs/fr/index.md
new file mode 100644
index 00000000..d132cf3f
--- /dev/null
+++ b/docs/fr/index.md
@@ -0,0 +1,21 @@
+---
+title: Documentation
+description: Une couche Nuxt qui transforme le dossier docs d’un dépôt en un site de documentation versionné et multi-dépôts.
+icon: lucide:book-open-text
+navigation: false
+---
+
+Tout ce que fait duxt, en quatre parties. Commencez à gauche si vous découvrez
+le projet, ou allez directement à la partie qui vous intéresse.
+
+::page-cards
+::
+
+Deux choses n’y figurent pas, délibérément. Contribuer à duxt lui-même est
+décrit dans
+[`CONTRIBUTING.md`](https://github.com/kirchDev/duxt/blob/main/CONTRIBUTING.md),
+et les décisions derrière la couche sont consignées dans les
+[Décisions d’architecture](/adr).
+
+Ce sur quoi la couche repose, et ce dont elle s’inspire, se trouve dans les
+[Remerciements](/credits).
diff --git a/docs/pt/1.getting-started/1.installation.md b/docs/pt/1.getting-started/1.installation.md
new file mode 100644
index 00000000..7e0d0fd1
--- /dev/null
+++ b/docs/pt/1.getting-started/1.installation.md
@@ -0,0 +1,73 @@
+---
+title: Instalação
+description: Adicione a camada, estenda-a, escreva Markdown.
+icon: lucide:download
+---
+
+## Adicionar a dependência
+
+:partial{name="install"}
+
+## Estender a camada
+
+```ts [nuxt.config.ts]
+export default defineNuxtConfig({
+ extends: ['@kirchdev/duxt']
+});
+```
+
+## Escrever a documentação
+
+O Markdown fica em `docs/`, na raiz do repositório — resolvido em relação ao
+repositório e não ao diretório onde o site corre, para que um site numa
+subpasta o encontre. Um ficheiro torna-se uma página, uma pasta torna-se uma
+secção, e o frontmatter transporta o título, a descrição e o ícone.
+
+::file-tree{title="o-teu-projeto/"}
+---
+tree:
+ - name: docs/
+ children:
+ - name: index.md
+ - name: 1.getting-started/
+ children:
+ - name: index.md
+ - name: 1.installation.md
+ - name: 2.guides/
+ children:
+ - name: 1.deploying.md
+ - name: nuxt.config.ts
+---
+::
+
+```md [docs/1.getting-started/index.md]
+---
+title: Introdução
+description: O que é este projeto.
+icon: lucide:rocket
+---
+
+A tua primeira página.
+```
+
+Um prefixo numérico ordena a árvore e é retirado do URL: `1.getting-started/` é
+servido em `/getting-started`. Reordenar uma secção nunca move um URL — só
+renomear a parte depois do ponto o faz.
+
+::callout{type="tip" title="Sem H1 no corpo"}
+O `title` da página é apresentado como ``. Um cabeçalho teu no início do
+ficheiro seria o segundo da página.
+::
+
+## O que a compilação precisa
+
+- **Node 24.** A camada lê a sua base de dados através de `node:sqlite`, pelo
+ que nenhum controlador SQLite nativo é instalado ou necessário.
+- **Acesso de rede às tuas fontes**, se alguma delas for outro repositório, e um
+ token para as privadas — o Content lê-o do `auth` da coleção.
+
+## Resultado
+
+`nuxt dev` serve o site. Tudo o que o tema desenha são dados com um valor por
+omissão, por isso o passo seguinte é o único ficheiro que o substitui:
+[Configuração](/getting-started/configuration).
diff --git a/docs/pt/1.getting-started/2.configuration.md b/docs/pt/1.getting-started/2.configuration.md
new file mode 100644
index 00000000..510fecac
--- /dev/null
+++ b/docs/pt/1.getting-started/2.configuration.md
@@ -0,0 +1,104 @@
+---
+title: Configuração
+description: Um ficheiro configura o site inteiro — o que lhe pertence e como é combinado.
+icon: lucide:settings
+---
+
+Tudo o que o tema mostra são dados, e `app/app.config.ts` é onde um site os
+declara. Não há um segundo ficheiro de configuração: a mesma chave `duxt`
+transporta o tema, as fontes de documentação e os links do próprio site.
+
+```ts [app/app.config.ts]
+export default defineAppConfig({
+ duxt: {
+ title: 'o meu projeto',
+ version: 'v1.4.0',
+ sources: [{ path: 'docs' }]
+ }
+});
+```
+
+Cada chave e o seu valor por omissão estão na
+[referência de configuração](/reference/configuration); a entrada `sources` tem
+[uma página própria](/reference/sources).
+
+## Como a tua configuração é combinada
+
+O teu objeto `duxt` é combinado chave a chave sobre os valores por omissão da
+camada, e **um array teu substitui o da camada** em vez de lhe ser acrescentado.
+Isso importa porque a combinação de `app.config` do Nuxt faz o contrário:
+concatena arrays, o que te deixaria sem poder remover uma única entrada da barra
+de navegação que a camada traz. Por isso a camada não entrega listas nenhumas em
+`app.config` e combina-as ela própria.
+
+A regra prática: substitui `footer.copyright` e `footer.legal` fica intacto;
+declara `navigation` e a única entrada da camada desaparece.
+
+## Duas chaves são lidas em tempo de compilação
+
+`sources` e `locales` decidem as coleções, as rotas e os links `hreflang`, e as
+três ficam definidas antes do primeiro pedido. Alterar qualquer uma delas exige
+nova compilação; tudo o resto tem efeito assim que a configuração é recarregada.
+
+## Algumas chaves são geradas, não escritas
+
+`resolvedSources`, `layerVersion` e `layerRepository` aparecem na configuração
+em tempo de execução e pertencem à compilação. `resolvedSources` é o manifesto
+que o tema realmente lê — que coleção serve que prefixo de URL — resolvido a
+partir da lista `sources` que escreveste. Escrever qualquer um dos três à mão é
+sobrescrito.
+
+## O que a camada não presume saber
+
+`links`, `aside.links` e `footer.legal` são entregues **vazios**. Um
+repositório, um gestor de issues, uma comunidade e um aviso legal nomeiam cada
+um um projeto concreto, pelo que um valor por omissão com os do duxt entregaria
+aos teus leitores um «Star on GitHub» que marca o trabalho de outra pessoa.
+
+`sections` é entregue vazio pela razão vizinha: nomeia as partes de topo da tua
+árvore de documentação, e a camada não pode saber como lhes chamaste. Define-o
+quando a tua documentação tiver secções; até lá a linha fica escondida e a barra
+lateral mostra a árvore inteira.
+
+```ts
+sections: [
+ { label: 'Guias', to: '/guides', icon: 'lucide:book-open' },
+ { label: 'Referência', to: '/reference', icon: 'lucide:list' }
+]
+```
+
+Escreve os teus — como texto simples, ou como registo por locale se o site
+servir vários idiomas:
+
+```ts
+links: [
+ {
+ icon: 'lucide:github',
+ to: 'https://github.com/tu/o-teu-projeto',
+ label: { 'en-GB': 'Repository', 'pt-PT': 'Repositório' }
+ }
+]
+```
+
+::callout{type="warning" title="duxt.defaults.* não é teu"}
+As chaves i18n da camada traduzem os valores por omissão que ela entrega. São
+internas: renomear uma não é uma alteração incompatível, e o i18n responde a uma
+chave em falta imprimindo a chave — a quebra seria portanto silenciosa no teu
+site. Usa um literal, uma chave tua, ou a forma de registo acima.
+::
+
+## A origem do site
+
+O duxt não consegue adivinhar o domínio a partir do qual será servido, e quatro
+coisas precisam dele: `hreflang`, `canonical`, o sitemap e as imagens Open
+Graph. Indica-o uma vez, onde o Nuxt já o pergunta:
+
+```ts [nuxt.config.ts]
+i18n: { baseUrl: 'https://docs.example.com' }
+```
+
+ou como `NUXT_PUBLIC_I18N_BASE_URL` no deployment. A camada copia-o para
+`site.url`, que é onde os módulos de SEO procuram. Se ficar por definir, cada um
+deles recai em saída relativa em vez de inventar um domínio — um `canonical`
+relativo ainda resolve, um adivinhado está ativamente errado. Ver
+[SEO](/concepts/seo).
diff --git a/docs/pt/1.getting-started/index.md b/docs/pt/1.getting-started/index.md
new file mode 100644
index 00000000..8cb80588
--- /dev/null
+++ b/docs/pt/1.getting-started/index.md
@@ -0,0 +1,36 @@
+---
+title: Introdução
+description: O que é o duxt, e o que chega com uma única linha de configuração.
+icon: lucide:rocket
+---
+
+O duxt é uma camada Nuxt. Estende-a, coloca Markdown em `docs/` e tem um site de
+documentação: navegação, índice, pesquisa, tema, `llms.txt` e um servidor MCP
+incluídos.
+
+```ts [nuxt.config.ts]
+export default defineNuxtConfig({
+ extends: ['@kirchdev/duxt']
+});
+```
+
+É este o caso da pasta única, por inteiro. Sem coleção, sem layout, sem
+`content.config.ts`.
+
+## O que não é
+
+O duxt não obtém conteúdo. O [Nuxt Content v3](https://content.nuxt.com) já
+descarrega um repositório git num ramo ou numa tag, autentica-se perante um
+privado e guarda o resultado em cache por hash — e o duxt usa isso em vez de o
+reimplementar.
+
+O que o duxt acrescenta é a parte que de outro modo se volta a escrever em cada
+repositório de documentação: uma coleção por versão e repositório, o esquema de
+URL que as transporta, o seletor que sabe que página existe onde, e um tema por
+cima. Tudo isto é gerado a partir de uma única lista — ver
+[Fontes](/concepts/sources).
+
+## Para onde ir a seguir
+
+::page-cards
+::
diff --git a/docs/pt/2.concepts/1.layers.md b/docs/pt/2.concepts/1.layers.md
new file mode 100644
index 00000000..e3686696
--- /dev/null
+++ b/docs/pt/2.concepts/1.layers.md
@@ -0,0 +1,50 @@
+---
+title: Camadas e substituição
+description: O que uma camada Nuxt te dá, o que te tira, e que caminhos não resolvem como se leem.
+icon: lucide:layers
+---
+
+O duxt é uma camada, não um modelo. Estende-la e cada ficheiro que ela traz
+passa a ser substituível: coloca um ficheiro com o mesmo nome no teu projeto e o
+Nuxt resolve o teu primeiro. Nada é copiado para o teu repositório, por isso uma
+atualização é um salto de versão e não uma fusão.
+
+## O que isso significa na prática
+
+| Queres | Fazes |
+| :----------------------------------------- | :------------------------------------------------------ |
+| Mudar um texto, um link, um interruptor | Definir a chave no teu `app/app.config.ts` |
+| Substituir uma peça do layout | Criar `app/components/DuxtFooter.vue` no teu projeto |
+| Reestilizar o tema | Redefinir uma variável CSS na tua própria folha de estilo |
+| Acrescentar uma página | Escrever Markdown em `docs/` |
+
+A ordem é deliberada: os três primeiros são compromissos progressivamente
+maiores, e um componente que substituis é um componente que passas a manter.
+Ver [Substituir o tema](/guides/override-the-theme).
+
+## O que te tira
+
+Uma camada nunca conhece o projeto que assenta sobre ela. Duas consequências que
+se sentem:
+
+- **`@` é teu, não da camada.** O alias pertence a quem estende o duxt, por isso
+ as importações da própria camada usam `@duxt`. O teu continua a apontar para o
+ teu `app/`.
+- **Uma coleção resolve contra a camada, não contra ti.** O Content regista como
+ raiz de cada coleção a camada que a declarou, pelo que um `cwd` relativo na
+ configuração do próprio duxt apontaria para dentro do duxt. A camada calcula
+ por isso um caminho absoluto no carregamento, o que funciona porque
+ `content.config.ts` é código executado e não um ficheiro de dados — e é sobre
+ essa junção que assenta todo o atalho `sources`.
+
+## Porque é feito assim
+
+A alternativa é um modelo inicial: `nuxi init`, e o tema passa a ser uma cópia
+dentro do teu repositório. Isso dá liberdade total e nenhum caminho de
+atualização — cada melhoria no tema é um diff que alguém tem de aplicar à mão.
+Uma camada inverte isso: herdas as melhorias e só pagas onde discordaste.
+
+O que fixa tudo isto é a superfície pública. Um componente, uma página ou uma
+chave de `app.config` que podes substituir é um nome do qual dependes, por isso
+renomear um é uma alteração incompatível da camada — que é precisamente o que a
+[referência](/reference) documenta.
diff --git a/docs/pt/2.concepts/2.sources.md b/docs/pt/2.concepts/2.sources.md
new file mode 100644
index 00000000..aabc5883
--- /dev/null
+++ b/docs/pt/2.concepts/2.sources.md
@@ -0,0 +1,58 @@
+---
+title: Fontes
+description: Uma lista de fontes torna-se cada coleção, prefixo e versão que o site serve.
+icon: lucide:git-branch
+---
+
+Uma **fonte** é uma pasta de Markdown: neste repositório ou noutro, no checkout
+atual ou em refs nomeadas. A lista `sources` é o único sítio onde são
+declaradas, e tudo o resto é gerado a partir dela.
+
+```ts [app/app.config.ts]
+sources: [
+ { path: 'docs', slug: 'acme' },
+ { repo: 'acme/api', path: 'docs', refs: ['main', 'v2.0.0', 'v1.4.0'] }
+]
+```
+
+Dois repositórios e três refs: cinco coleções, os prefixos de URL que as servem,
+um seletor de versões, as regras de redirecionamento e as entradas do sitemap.
+Nada disso é escrito à mão. Cada campo está na
+[referência de fontes](/reference/sources).
+
+## Como funciona
+
+O Content v3 já faz a metade difícil. A fonte de uma coleção aceita um URL de
+repositório, um ramo ou tag e credenciais, e o Content descarrega-a e guarda-a
+em cache por hash — vários repositórios, repositórios privados e ler a partir de
+uma tag existem todos lá. O duxt não reimplementa nada disso.
+
+O que o duxt acrescenta acontece ao carregar a configuração. `content.config.ts`
+é código executado e não um ficheiro de dados, por isso pode ler o
+`app.config.ts` do site e calcular as suas coleções a partir dessa lista — uma
+coleção por repositório × ref, cada uma com um `cwd` absoluto, um nome derivado
+do slug e o esquema de página de que o tema precisa. A mesma lista é resolvida
+uma segunda vez pela compilação, no **manifesto**: que coleção serve que prefixo
+de URL, que ref é a predefinida, de onde veio cada página. É esse manifesto que
+o tema lê, e é reescrito na configuração da aplicação como `resolvedSources`.
+
+## Porque é feito assim
+
+Declarar as coleções à mão não escala exatamente na direção em que a
+documentação cresce: três versões em catorze repositórios são quarenta e duas
+declarações, e cada lançamento edita as catorze. O atalho mantém a lista tão
+longa quanto o número de projetos.
+
+O custo é que uma lista de fontes exprime menos do que uma coleção escrita à
+mão. É uma troca deliberada: quem precisar de algo que o atalho não consegue
+dizer escreve um `content.config.ts` próprio e assume por completo, porque o
+Content combina o ficheiro de cada camada, ganhando o posterior.
+
+## O que daí resulta
+
+Como uma fonte nomeia um repositório, uma ref e uma pasta, várias
+funcionalidades não precisam de configuração própria: «Editar esta página», a
+data da última alteração e a lista de quem contribuiu derivam todas dela. Ler o
+histórico de uma fonte **remota** custa um clone completo, por isso espera por
+`history: true` — um clone superficial reportaria o autor da ponta para cada
+ficheiro, o que são dados errados em vez de dados em falta.
diff --git a/docs/pt/2.concepts/3.urls-and-versions.md b/docs/pt/2.concepts/3.urls-and-versions.md
new file mode 100644
index 00000000..0c58d4de
--- /dev/null
+++ b/docs/pt/2.concepts/3.urls-and-versions.md
@@ -0,0 +1,75 @@
+---
+title: URL e versões
+description: Como aparecem os segmentos de repositório e versão, e o que é dito a quem lê uma versão antiga.
+icon: lucide:git-compare
+---
+
+Cada página é servida em
+
+```text
+/[repo]/[version]/[...caminho]
+```
+
+com ambos os prefixos opcionais — e a decisão sobre se cada um aparece é tomada
+**em tempo de compilação a partir da lista de fontes**, nunca por pedido.
+
+- Um segmento de repositório aparece assim que há mais do que um repositório, ou
+ quando `showRepo` o força.
+- Um segmento de versão aparece assim que uma fonte publica mais do que uma ref,
+ ou quando `showVersion` o força.
+
+Assim, uma única pasta sem versões serve `/guides/deploying`, e nada no URL
+denuncia que repositórios e versões sequer existem.
+
+## Porque os prefixos são decididos na compilação
+
+Se ambos os prefixos fossem opcionais por pedido, o primeiro segmento seria
+ambíguo: `/guides/…` podia ser uma pasta, um repositório ou uma versão, e só
+consultando os três se saberia qual. Como a forma de um URL fica fixada antes do
+primeiro pedido, o router nunca tem de adivinhar.
+
+O que resta é uma **colisão de nomes** enquanto um prefixo está ativo: uma pasta
+de documentação com o nome de um repositório ou de uma versão. A compilação
+rejeita-a em vez de a resolver em silêncio para um dos lados; ver
+[O que a compilação verifica](/concepts/build-checks).
+
+## A versão por omissão
+
+Uma ref por repositório é servida sem prefixo de versão: a primeira da lista, ou
+a que `sourceOptions.defaultRef` nomear. É esse o URL a que se deve ligar e para
+o qual os motores de busca são apontados.
+
+## Onde está uma versão na sua vida
+
+`status`, numa fonte ou numa ref, é um ciclo de vida e não um sinónimo de «é a
+predefinida»: um site pode publicar a v2 como atual enquanto a v1 é apenas mais
+antiga e a v0 está mesmo morta, e é preciso dizer ao leitor em qual dos três
+está.
+
+| Estado | O que é dito ao leitor | No sitemap |
+| :----------- | :----------------------------------------------- | :--------------------- |
+| `upcoming` | Isto ainda pode mudar — sem conselho de atualizar | Só se for a predefinida |
+| `current` | Nada | Só se for a predefinida |
+| `maintained` | Existe uma versão mais recente | Só se for a predefinida |
+| `deprecated` | Existe uma versão mais recente; atualiza | Só se for a predefinida |
+| `eol` | Esta versão está morta | Nunca |
+
+`upcoming` é o único que não é sobre idade. Uma versão pode não ser a
+predefinida por ainda não ter acontecido, e dizer a esse leitor para
+«atualizar» é exatamente ao contrário. O semver não consegue situar um ramo
+face a uma tag, por isso este caso é declarado na configuração em vez de
+deduzido.
+
+Ao lado do aviso, uma página numa versão não predefinida leva `noindex` e um
+`canonical` a apontar para a mesma página na versão predefinida — caso
+contrário, um motor de busca serviria a v0.7.0 com a mesma probabilidade que a
+documentação de hoje. As duas metades andam juntas: o aviso é a visível, o
+link-rel a invisível, e qualquer uma sozinha deixa um público enganado.
+
+## Quando uma página falta numa versão
+
+O seletor só oferece uma versão que tenha mesmo a página, e a ligação canónica
+resolve para o caminho existente mais próximo. Um URL que não resolve para nada
+é respondido com um 404 que sugere a página mais próxima na navegação — é fácil
+falhar um URL versionado por um segmento, e um 404 vazio deixa o leitor sem nada
+para clicar.
diff --git a/docs/pt/2.concepts/4.collections.md b/docs/pt/2.concepts/4.collections.md
new file mode 100644
index 00000000..d2f0fe55
--- /dev/null
+++ b/docs/pt/2.concepts/4.collections.md
@@ -0,0 +1,69 @@
+---
+title: Coleções
+description: O que o Content constrói a partir das fontes, e as quatro coisas que o esquema acrescenta.
+icon: lucide:database
+---
+
+Uma coleção é a unidade de obtenção do Content: um glob, um sítio de onde o ler
+e um esquema. O duxt declara uma por repositório × ref, por isso um site com uma
+pasta tem uma coleção e um site com dois repositórios em três refs tem seis.
+
+Os seus **nomes são dados**, não uma constante. Um site com dois repositórios
+tem `docs_duxt` e `docs_workflows` e nenhuma coleção chamada `docs` — é por isso
+que o tema lê o nome da coleção do manifesto em vez de o fixar no código, e por
+isso uma consulta tua deve fazer o mesmo:
+
+```vue
+
+```
+
+## O que o esquema acrescenta
+
+O Content só guarda o que um esquema declara, por isso quatro coisas de que o
+tema precisa são declaradas explicitamente. A lista completa está na
+[referência de frontmatter](/reference/frontmatter); as razões estão aqui:
+
+- **`icon`, `layout`, `navigation`** — campos do tema. Sem o esquema, um `icon:`
+ no frontmatter é descartado em silêncio antes de a barra lateral sequer o ver.
+- **`rawbody`** — o Markdown tal como foi escrito. Duas coisas precisam dele: o
+ botão que entrega uma página a um modelo, e o `llms-full.txt`.
+- **`redirectFrom`** — transformado em regras de rota pela compilação; ver
+ [Mover uma página](/guides/move-a-page).
+- **`sitemap`** — aquilo que põe estas páginas no sitemap. Uma coleção tem de
+ declarar o campo, ou o sitemap lista as rotas do site e nem uma única página
+ de documentação.
+
+## Fragmentos
+
+`_partials/` na pasta de documentação de **qualquer** fonte alimenta uma coleção
+partilhada, e `:partial{name="install"}` em qualquer página de qualquer fonte
+apresenta-o. O Content não traz nenhuma diretiva de inclusão, e ao longo de
+vários repositórios essa lacuna não tem contorno nenhum — uma nota de instalação
+que tem de dizer o mesmo em três projetos é copiada para três projetos e
+diverge.
+
+Os fragmentos carregam a mesma dimensão de idioma que as páginas:
+`docs/_partials/` é `duxt_partials`, `docs/de/_partials/` é `duxt_partials_de`,
+e `:partial` percorre [a mesma cadeia de recurso](/concepts/localisation) que a
+página onde ele está percorreu. Tem de ser a mesma cadeia — uma página e os
+blocos que ela inclui a recorrerem a idiomas diferentes é exatamente como se
+chega a uma página meio traduzida sem que nada o diga.
+
+Os fragmentos são excluídos das próprias coleções de páginas. Caso contrário
+cada fragmento seria também uma página: na barra lateral, na pesquisa, no
+`llms.txt`.
+
+## Rascunhos são ficheiros, não sinalizadores
+
+Um rascunho é `deploying.draft.md`, não `draft: true` no frontmatter. O
+sinalizador é a escrita óbvia e a que não pode funcionar: o conteúdo de uma
+coleção é declarado antes de o Content ter lido um único ficheiro — numa fonte
+remota, antes sequer de a ter descarregado — por isso nada nesse momento sabe o
+que diz qualquer frontmatter. Um nome de ficheiro é conhecido. O Content retira
+`.draft` do URL, pelo que a página é servida em `/deploying` no servidor de
+desenvolvimento e simplesmente não existe na compilação.
diff --git a/docs/pt/2.concepts/5.build-checks.md b/docs/pt/2.concepts/5.build-checks.md
new file mode 100644
index 00000000..85c93f4e
--- /dev/null
+++ b/docs/pt/2.concepts/5.build-checks.md
@@ -0,0 +1,84 @@
+---
+title: O que a compilação verifica
+description: As falhas silenciosas que esta camada teve, transformadas em mensagens.
+icon: lucide:shield-check
+---
+
+Todos os erros que esta camada realmente teve foram silenciosos: uma coleção que
+o Content descartou porque o nome não era um identificador JavaScript, uma
+página que dava 404 porque uma ligação tinha envelhecido, uma navegação que
+voltava vazia. Nenhum dos três disse nada — a compilação estava verde e o site
+errado. Por isso correm verificações depois da compilação, todas a ler a mesma
+coisa: a cache de análise que o Content deixa para trás.
+
+| Verificação | Gravidade |
+| :-------------------------------------------- | :-------- |
+| Uma coleção sem páginas | erro |
+| Uma pasta em conflito com um segmento de URL | erro |
+| Um `title` ou `description` em falta | aviso |
+| Uma ligação interna ou âncora partida | aviso |
+| O que cada idioma carrega e o que ficou parado | nota |
+
+## Porque a gravidade não é uniforme
+
+Um erro custa uma página que ninguém consegue alcançar e que nada no site
+alguma vez explicaria — uma coleção vazia é um 404 em todas as páginas de uma
+versão. Um aviso custa qualidade: a página continua a ser apresentada, e uma
+fonte remota pode desatualizar-se entre um lançamento e o seguinte sem que isso
+seja culpa desta compilação. Fazer a compilação falhar porque o repositório de
+outra pessoa avançou tornaria uma compilação verde dependente de um terceiro.
+
+Uma **nota** não custa nada: é um estado do site, não um defeito nele. Uma
+página por traduzir não é um erro, e reportá-la como aviso ensinaria toda a
+gente a passar por cima dos avisos.
+
+## O relatório de traduções
+
+Uma linha por idioma, em cada compilação:
+
+```
+[duxt] translations
+[duxt] de: 118/121 pages, 3 behind the original
+[duxt] "docs/guides/deploying.md" has no de translation.
+[duxt] "docs/de/concepts/sources.md" has not moved since the original changed.
+```
+
+O segundo número precisa de `history: true` na fonte — as datas vêm do git, e
+uma fonte que não pediu o seu histórico não tem nenhuma. Sem ele o relatório
+conta a cobertura e nada diz sobre o desfasamento, em vez de o adivinhar a
+partir da ausência.
+
+Existe por causa de uma falha concreta. O OpenCode traduziu a sua documentação
+para dezassete idiomas com um agente em CI, desligou o fluxo de trabalho, e em
+lado nenhum ficava dito que as traduções tinham deixado de se mexer. A camada já
+sabe, por página, que idiomas a carregam e quando cada ficheiro mudou pela
+última vez; um relatório é o que transforma isso em algo que alguém vê.
+
+## O que as verificações sabem e tu não
+
+- **Uma colisão de pastas** só é um problema enquanto um prefixo está ativo. Uma
+ pasta de documentação chamada `v2` está bem num site sem versões e é
+ inalcançável num versionado, porque o prefixo ganha. A compilação sabe qual é
+ o caso, e a mensagem nomeia o ficheiro e oferece a solução.
+- **Uma ligação é resolvida como o tema a resolve.** Um caminho absoluto escrito
+ numa página é relativo à fonte dessa mesma página, por isso é tentado primeiro
+ sob o prefixo da fonte e depois nu. Um verificador que fizesse apenas a forma
+ nua reportaria todas as ligações corretas de um site multirrepositório.
+- **Uma ligação é resolvida no seu próprio idioma.** Todos os idiomas de uma
+ fonte servem caminhos de conteúdo idênticos — a locale fica à frente do URL,
+ não na árvore de conteúdo — e as âncoras derivam do texto dos títulos, pelo
+ que um título alemão tem uma âncora alemã. Uma verificação que resolvesse só
+ pelo caminho confrontaria uma ligação alemã com títulos ingleses e reportaria
+ cada âncora correta de um site traduzido. Uma ligação para uma página que um
+ idioma não carrega recorre ao original, que é o que o site faz.
+- **URL externos não são verificados.** Verificar um poria a rede dentro da
+ compilação, e um servidor alheio inacessível não é razão para a tua
+ documentação não ser publicada.
+
+## Escreve as ligações como caminhos de documentação absolutos
+
+`/concepts/sources`, não `../2.concepts/2.sources.md`. O prefixo sob o qual uma
+página é servida é decidido na compilação, por isso escrever caminhos sem ele é
+o que permite que o mesmo Markdown sirva `/guides/deploying` num site e
+`/acme/v2.0.0/guides/deploying` noutro. É também a única forma que o verificador
+de ligações vê.
diff --git a/docs/pt/2.concepts/6.machine-readers.md b/docs/pt/2.concepts/6.machine-readers.md
new file mode 100644
index 00000000..812fb496
--- /dev/null
+++ b/docs/pt/2.concepts/6.machine-readers.md
@@ -0,0 +1,46 @@
+---
+title: Leitores automáticos
+description: O que um modelo recebe — llms.txt, o Markdown em bruto de qualquer página e um verdadeiro servidor MCP.
+icon: lucide:bot
+---
+
+A documentação é lida por modelos tanto quanto por pessoas, e as quatro
+respostas abaixo são geradas a partir das mesmas coleções de que as páginas são
+apresentadas — nenhuma delas pode, portanto, divergir do que está publicado.
+
+| Caminho | O que é |
+| :--------------- | :----------------------------------------------------------------------- |
+| `/llms.txt` | Um índice: nome do site, resumo, e cada página como entrada com ligação |
+| `/llms-full.txt` | As mesmas páginas, inteiras, concatenadas num único ficheiro Markdown |
+| `.md` | O código Markdown de qualquer página |
+| `/mcp` | Um servidor MCP — `list_pages`, `search_docs`, `read_page` |
+
+## Markdown, não HTML
+
+Tudo aqui entrega o Markdown tal como foi escrito, e é por isso que o esquema da
+coleção pede ao Content que guarde `rawbody`. Um modelo a quem se dá HTML
+apresentado tem de desfazer um layout para encontrar um cabeçalho; um a quem se
+dá a fonte lê o que o autor escreveu, com os blocos de código ainda blocos e um
+bloco MDC ainda uma chamada de componente.
+
+`.md` é servido por um **middleware** em vez de uma rota, porque `.md` é
+um sufixo do último segmento do caminho e não um segmento próprio — não existe
+padrão de rota do Nitro que signifique «qualquer caminho terminado em .md».
+
+## Porquê os dois ficheiros llms
+
+A convenção de [llmstxt.org](https://llmstxt.org) nomeia dois, e respondem a
+perguntas diferentes: um modelo que leu o índice ainda tem de ir buscar sessenta
+páginas, e um modelo a quem se entrega o ficheiro completo já as leu. Qual sai
+mais barato depende da pergunta, por isso o site publica ambos.
+
+## Porquê um servidor MCP a sério
+
+`/mcp` fala o protocolo através do SDK oficial em vez de ser um endpoint JSON de
+desenho próprio do duxt. Um cliente liga-se-lhe como se liga a qualquer outra
+coisa, em vez de alguém escrever um adaptador à volta de uma forma que só este
+tema tem.
+
+A sua pesquisa é um `LIKE` sobre títulos e descrições, não o índice que o
+browser usa: esse é construído do lado do cliente a partir do despejo da
+coleção, e uma ferramenta de servidor tem a própria base de dados.
diff --git a/docs/pt/2.concepts/7.seo.md b/docs/pt/2.concepts/7.seo.md
new file mode 100644
index 00000000..42625465
--- /dev/null
+++ b/docs/pt/2.concepts/7.seo.md
@@ -0,0 +1,77 @@
+---
+title: SEO
+description: O que cada página emite para rastreadores e cartões sociais, que módulo o escreve, e o único valor que a camada não consegue adivinhar.
+icon: lucide:search
+---
+
+Cada página emite um head completo, uma descrição estruturada de si mesma, um
+cartão social e uma entrada no sitemap. A maior parte é escrita pelo [Nuxt
+SEO](https://nuxtseo.com), que a camada instala como um único pacote; o resto é a
+parte que depende de versões e traduções, e essa é da própria camada. Um valor
+tem de vir de ti.
+
+## Por página
+
+- **Meta** — o título com o nome do site acrescentado, a descrição e o par
+ `og:`/`twitter:` derivado de ambos. `og:type` e `og:url` são definidos por
+ página; `og:locale` e as suas alternativas vêm dos idiomas que o site serve.
+- **JSON-LD** — um grafo por página: um `WebSite` (e a tua `Organization`, uma
+ vez definida) declarados para todo o site, mais um `TechArticle` e uma
+ `BreadcrumbList` construída a partir do mesmo trilho que a migalha desenha.
+- **Uma imagem de Open Graph**, renderizada a partir do modelo da própria camada
+ — também na página inicial. Publica um componente com o mesmo nome para a
+ substituir.
+- **`canonical` e `robots`**, que são a metade das versões — ver
+ [URL e versões](/concepts/urls-and-versions). A canónica de uma versão antiga
+ aponta para a atual, e é por isso que a camada a escreve em vez de deixar a
+ automática do módulo.
+
+A página de erro leva `noindex, follow`: uma 404 indexada concorre com a página
+que quem lê procurava, e as sugestões nela são páginas reais que um rastreador
+deve continuar a alcançar.
+
+## Em todo o site
+
+`/sitemap.xml` lista as páginas da versão predefinida, por idioma. Uma versão que
+não é a predefinida fica excluída porque as suas páginas levam `noindex` de
+qualquer forma, e uma versão `eol` fica excluída seja ou não a predefinida:
+listar qualquer uma delas pede a um rastreador que vá buscar exatamente aquilo
+que a página lhe dirá depois para descartar. O `/robots.txt` é gerado ao lado.
+
+`duxt.organization` é o que nomeia quem publica — ver
+[Configuração](/reference/configuration). Por definir, o site continua a
+descrever-se como um `WebSite`; o que perde é a entidade por detrás, que é o que
+um painel de conhecimento ou um resumo de IA lê para creditar uma fonte.
+
+## O que é verificado e o que é apenas relatado
+
+`pnpm check:seo` lê as páginas construídas e falha perante uma segunda
+`canonical`, um `hreflang` em falta, uma página de erro indexável, um `og:locale`
+no formato errado ou um grafo que não se analisa. Corre no `pnpm check` depois da
+compilação, porque estas etiquetas só existem no HTML renderizado.
+
+O verificador de ligações relata em vez de falhar. Uma ligação que não leva a
+lado nenhum já faz a compilação falhar através das
+[verificações de compilação](/concepts/build-checks) da própria camada, que são
+as que percebem versões e recuos de idioma; o valor do módulo aqui é o painel de
+devtools e a verificação ao vivo enquanto se escreve.
+
+## O único valor que tens de indicar
+
+A origem. `hreflang`, `canonical`, o sitemap e as imagens Open Graph são todos
+URL absolutos, e uma camada não pode conhecer o domínio onde será implementada.
+Define `i18n.baseUrl` ou `NUXT_PUBLIC_I18N_BASE_URL`; a camada copia-o para
+`site.url`, que é onde os módulos de SEO procuram.
+
+Se ficar por definir, cada um deles recai em saída relativa em vez de inventar
+um domínio. É essa a escolha deliberada: um `canonical` relativo continua a
+resolver corretamente, e um adivinhado está ativamente errado — apontaria um
+rastreador para o site de outra pessoa.
+
+## Mais uma variável para uma implementação progressiva
+
+`NUXT_OG_IMAGE_SECRET` assina os URL das imagens Open Graph. Sem ela é gerado um
+segredo por compilação, o que serve para uma instância e está errado para duas:
+um cartão cujo URL foi assinado por uma instância é rejeitado pela seguinte, por
+isso uma ligação partilhada durante uma implementação progressiva perde a
+imagem. Gera-a uma vez; não roda.
diff --git a/docs/pt/2.concepts/8.localisation.md b/docs/pt/2.concepts/8.localisation.md
new file mode 100644
index 00000000..9b8a4601
--- /dev/null
+++ b/docs/pt/2.concepts/8.localisation.md
@@ -0,0 +1,97 @@
+---
+title: Localização
+description: A camada traz sete locales, e cada texto configurado assume três formas.
+icon: lucide:languages
+---
+
+Os textos do próprio tema — rótulos de navegação, pesquisa, o aviso de versão, o
+404 — são entregues traduzidos em `en-GB`, `en-US`, `de-DE`, `es-ES`, `fr-FR`,
+`pt-PT` e `pt-BR`. Um site escolhe quais serve com `locales`, e
+`i18n.defaultLocale` decide qual é servido sem prefixo de URL.
+
+`locales` é lido em tempo de compilação: os locales decidem rotas e `hreflang`,
+não apenas o que um componente desenha, por isso alterar a lista exige nova
+compilação.
+
+::callout{type="warning" title="Restringir a lista precisa desta chave, não da do i18n"}
+O array `locales` do i18n combina entre camadas em vez de substituir, por isso
+declarar `['de-DE']` aí não acrescenta nem remove nada: os outros seis
+continuam roteados, indexados e anunciados em `hreflang`. `duxt.locales` é a
+chave que a reduz de facto.
+::
+
+## Cada texto configurado assume três formas
+
+Tudo o que escreves na configuração — um rótulo, um título, uma descrição — pode
+ser um literal, uma chave i18n, ou um registo com um texto por locale:
+
+```ts
+label: 'Repository' // literal
+label: 'app.nav.repository' // chave própria
+label: { 'en-GB': 'Repository', 'pt-PT': 'Repositório' } // por locale
+```
+
+Qual delas é decide-se conforme o texto seja uma chave registada. Um site
+monolingue nunca encontra as outras duas formas, e um site com um punhado de
+textos em cinco idiomas não precisa de ficheiros de locale nenhuns — a forma de
+registo resolve pela língua base, por isso `pt-PT` cobre `pt-BR` e `en-GB` cobre
+`en-US`.
+
+## Não mexas nas chaves da camada
+
+`duxt.defaults.*` traduz os valores por omissão que a própria camada entrega.
+Essas chaves são internas: renomear uma não é uma alteração incompatível da
+camada, e o i18n responde a uma chave em falta imprimindo a chave — quem
+dependesse de uma saberia por um leitor, não por uma compilação. Usa um literal,
+uma chave tua, ou a forma de registo.
+
+## Caminhos e ligações
+
+Só os caminhos são localizados. `useDuxtPath()` dá o caminho onde está a
+**documentação**, sem o segmento de locale; `useDuxtLink()` volta a escrevê-lo
+como ligação, acrescentando o segmento de novo. Um URL absoluto, uma ligação de
+correio ou uma âncora isolada passam intactos. Tudo o que calcule um caminho de
+documentação deve passar por este par em vez de ler a rota diretamente.
+
+## Páginas traduzidas
+
+A interface e as páginas são duas decisões separadas. Um site que nada diga
+serve uma mesma árvore em todos os locales — o enquadramento muda de idioma, o
+Markdown não — e essa é a forma certa para a maior parte da documentação.
+
+`locales` numa fonte acrescenta a segunda metade:
+
+```ts [app/app.config.ts]
+sources: [{ path: 'docs', locales: ['en-GB', 'pt'] }]
+```
+
+O locale por omissão é a própria árvore em `path`, sem pasta própria, por isso
+acrescentar esta chave nunca move uma página já publicada. Qualquer outro idioma
+é uma pasta lá dentro — `docs/pt/` — ou fica noutro sítio por completo; ver
+[Vários idiomas](/guides/several-languages).
+
+::callout{type="tip" title="O locale não faz parte do caminho de conteúdo"}
+`/pt-PT/guides/deploying` e `/guides/deploying` são o mesmo caminho de conteúdo
+em duas coleções. O segmento pertence ao roteamento do i18n, nunca a uma coleção
+— é por isso que mudar de idioma mantém o leitor na mesma página, e por isso uma
+tradução em falta é resolvida com mais uma consulta em vez de um
+redirecionamento.
+::
+
+## Quando uma página não tem tradução
+
+O leitor recebe o idioma mais próximo que a tenha, com um aviso a dizê-lo, e a
+página fica fora do índice sob `noindex`, com um `canonical` a apontar para o
+idioma que realmente a transporta. A cadeia é a mesma que a camada já aplica aos
+textos:
+
+1. o próprio locale — `pt-PT` lê `pt-PT`
+2. a sua língua base — `pt-PT` lê uma árvore `pt`, o que permite a uma pasta
+ `pt/` servir tanto `pt-PT` como `pt-BR`
+3. uma irmã da mesma língua — `pt-BR` lê `pt-PT` antes de sair do português
+4. `fallbackLocale` do vue-i18n, o mesmo valor por onde um texto em falta
+ resolve
+5. o original por traduzir
+
+Um 404 fica deliberadamente de fora: uma lacuna numa tradução pertence a quem
+escreve, e respondê-la escondendo a página castiga o leitor por isso.
diff --git a/docs/pt/2.concepts/9.theming.md b/docs/pt/2.concepts/9.theming.md
new file mode 100644
index 00000000..59acaa08
--- /dev/null
+++ b/docs/pt/2.concepts/9.theming.md
@@ -0,0 +1,104 @@
+---
+title: Tematização
+description: Como o tema está montado — componentes próprios, uma camada de tokens e um modo escuro que comuta por classe.
+icon: lucide:palette
+---
+
+O tema não é uma dependência que se configura. É um conjunto de ficheiros que a
+camada possui, construído sobre [shadcn-vue](https://www.shadcn-vue.com) e
+Tailwind 4 — e a razão dessa forma decide quanto te custa substituir algo.
+
+Alterar algo é [Substituir o tema](/guides/override-the-theme). Esta página
+explica por que as costuras estão onde estão.
+
+## Os componentes são copiados, não importados
+
+O shadcn-vue não é uma biblioteca de componentes que se instala. A sua CLI escreve
+o código-fonte de um componente no teu projeto e, a partir daí, o ficheiro é teu. A
+camada guarda as suas primitivas em `app/components/ui/`, e o `components.json`
+aponta a CLI para esse alias, portanto acrescentar uma é um único comando.
+
+Seguem-se duas consequências, e vão em direções opostas:
+
+- **Podes alterar tudo, sem bifurcar.** Um componente que queres diferente é um
+ ficheiro que editas — não há um upstream contra o qual lutar, nem uma API de tema
+ onde procurar uma brecha.
+- **Nada se atualiza sozinho.** Uma correção a montante numa primitiva chega-te
+ quando voltas a correr a CLI para ela, não quando uma dependência sobe de versão.
+ A camada assume esse custo pelas suas próprias primitivas; um componente que
+ substituis no teu projeto, assumes tu.
+
+Essa troca é toda a razão do terceiro nível do guia de substituição: a alteração
+mais pequena que resolve a tarefa é a certa, porque um componente que substituis é
+um componente que passas a manter.
+
+::callout{type="info" title="De onde veio a decisão"}
+Por que shadcn-vue em vez de um tema de documentação já feito está registado no
+[ADR 0008](/adr/0008-build-the-theme-on-owned-shadcn-vue-components).
+::
+
+## Uma camada de tokens, duas folhas de estilo
+
+A paleta são propriedades personalizadas de CSS em `oklch`, definidas uma vez em
+`:root` e redefinidas em `.dark`. Cada componente lê um token; nenhum guarda uma
+cor. É isso que torna possível uma substituição de uma linha — redefines
+`--primary` na tua própria folha de estilo e todas as superfícies que o usam
+acompanham.
+
+Dois ficheiros a sustentam, e não são intermutáveis:
+
+| Ficheiro | Contém |
+| :---------------------------- | :---------------------------------------------------------------------------------- |
+| `app/assets/css/duxt.css` | A paleta, a entrada do Tailwind, a predefinição da camada — o ficheiro que lês |
+| `app/assets/css/typeset.css` | O typeset do shadcn, copiado tal e qual — o ficheiro que **não** editas |
+
+Atrás de `typeset.css` não há entrada de registo, por isso a CLI não o consegue
+obter nem atualizar. Atualizar significa descarregar o ficheiro outra vez, e é por
+isso que as alterações locais pertencem à predefinição de `duxt.css`: uma edição
+feita em `typeset.css` é uma que a próxima atualização descarta em silêncio.
+
+## O modo escuro é uma classe, não uma media query
+
+`@custom-variant dark (&:is(.dark *))` — o tema comuta com uma classe `dark`, e o
+`@nuxtjs/color-mode` põe exatamente essa classe porque a camada o configura com
+`classSuffix: ''`.
+
+Este é o pormenor em que se tropeça ao substituir uma cor. Um token definido apenas
+sob `@media (prefers-color-scheme: dark)` nunca é lido, e um token definido uma vez
+em `:root` serve nos dois modos — ou seja, está errado num deles. Redefine nos dois
+blocos, ou aceita que um dos modos fique com o valor da camada.
+
+## A paleta afasta-se do padrão do shadcn, de propósito
+
+`--muted-foreground` é aqui mais escuro do que o conjunto neutro do shadcn o
+entrega. Esse token leva as descrições de página, o índice e a navegação estrutural,
+e com o valor do shadcn mede abaixo dos 4,5:1 que a WCAG AA pede nas superfícies em
+que realmente assenta.
+
+Nenhuma verificação feita num navegador o apanha: uma passagem de acessibilidade
+sobre as páginas construídas não tem cor calculada para medir. A camada mede, por
+isso, os próprios tokens, em `tests/contrast.test.ts`, emparelhando cada cor de
+primeiro plano com os fundos em que é de facto usada. Se substituíres um token de
+primeiro plano, assumes essa garantia — o teste lê o ficheiro da camada, não o teu.
+
+## É preciso dizer ao Tailwind onde está a camada
+
+`@source '../../../app'` está no início de `duxt.css` e não é enfeite. O Tailwind
+procura nomes de classe a partir da **raiz de compilação**, que é a diretoria do
+projeto consumidor. A camada vive fora dela, em `node_modules`, portanto sem essa
+linha faltaria no CSS gerado cada utilitário usado por um componente da camada: um
+site que apresenta sem estilo nenhum.
+
+O caminho é relativo à folha de estilo, pelo que vale onde quer que o pacote esteja
+instalado.
+
+## O que isto significa para ti
+
+- Uma cor, um raio, um tipo de letra: redefine o token na tua folha de estilo.
+- Uma disposição errada para o teu site: sombreia o componente pelo nome.
+- Uma primitiva que a camada não traz: acrescenta-a com a CLI.
+
+Cada degrau para baixo custa mais manutenção do que o anterior. O
+[guia de substituição](/guides/override-the-theme) percorre os três, e
+[Dá identidade ao teu site](/guides/brand-your-site) cobre a parte que é
+identidade e não tema — o nome, a marca e os ícones.
diff --git a/docs/pt/2.concepts/index.md b/docs/pt/2.concepts/index.md
new file mode 100644
index 00000000..2ded7509
--- /dev/null
+++ b/docs/pt/2.concepts/index.md
@@ -0,0 +1,12 @@
+---
+title: Conceitos
+description: Como a camada funciona — o modelo de fontes, o esquema de URL, as coleções e as verificações.
+icon: lucide:compass
+---
+
+As páginas de introdução levam-te do nada até um site a funcionar. Estas
+explicam o que são as peças, onde ficam as junções e porque cada uma é feita
+como é.
+
+::page-cards
+::
diff --git a/docs/pt/3.guides/1.several-repositories.md b/docs/pt/3.guides/1.several-repositories.md
new file mode 100644
index 00000000..fa3a60bf
--- /dev/null
+++ b/docs/pt/3.guides/1.several-repositories.md
@@ -0,0 +1,60 @@
+---
+title: Vários repositórios
+description: Servir a documentação de vários projetos a partir de um só site.
+icon: lucide:folder-git
+---
+
+Um site, muitos projetos. Cada repositório de origem contém apenas Markdown —
+sem Nuxt, sem `package.json`, sem dependências — o que é a única disposição
+viável para um projeto em Go, PHP ou Rust.
+
+## Passos
+
+::steps
+1. **Nomeia cada repositório como uma fonte.** Um segmento de repositório
+ aparece em todos os URL assim que há mais do que um, por isso dá a cada um um
+ `slug` que estejas disposto a manter: faz parte do URL.
+
+ ```ts [app/app.config.ts]
+ sources: [
+ { path: 'docs', slug: 'acme', origin: { repo: 'acme/site', ref: 'main' } },
+ { repo: 'acme/api', path: 'docs' },
+ { repo: 'acme/cli', path: 'docs' }
+ ]
+ ```
+
+2. **Usa `origin`, não `repo`, para este repositório.** `repo` é o que faz o
+ Content descarregar uma fonte, por isso nomear o teu aí faria a compilação
+ clonar o checkout onde já está. `origin` diz apenas onde vivem as páginas,
+ para a ligação «Editar esta página».
+
+3. **Autentica os privados.** O Content lê um token do `auth` da coleção; nada
+ disso é específico do duxt.
+
+4. **Escreve as tuas próprias `sections`.** A navegação que a camada traz
+ assume uma única fonte sem prefixo. Com um segmento de repositório em cada
+ caminho, as suas entradas deixam de resolver: nomeia as secções tu mesmo,
+ apontando para `/acme/getting-started` em vez de `/getting-started`.
+
+5. **Compila uma vez e lê as mensagens.** Uma coleção vazia faz a compilação
+ falhar, e é a falha que esta montagem produz mais vezes: um `path` que não
+ existe no outro repositório parece exatamente um repositório sem
+ documentação.
+
+::
+
+## O que recebes de graça
+
+A pesquisa abrange todas as fontes e ordena os resultados em conjunto, com a
+fonte em que o leitor está a vir primeiro — a versão que está a ler, ou a sua
+predefinida, uma por repositório, para que uma página nunca apareça uma vez por
+versão. `_partials/` de qualquer fonte alimenta uma coleção partilhada, por isso
+uma nota de instalação escreve-se uma vez e inclui-se nos três projetos.
+
+## Lista de verificação
+
+- [ ] Cada fonte tem um `slug` que te agrada ter no URL
+- [ ] Este repositório usa `origin`, não `repo`
+- [ ] As `sections` apontam para caminhos que incluem o segmento de repositório
+- [ ] A compilação não reporta nenhuma coleção vazia
+- [ ] Pesquisar a partir de um repositório encontra uma página noutro
diff --git a/docs/pt/3.guides/2.several-versions.md b/docs/pt/3.guides/2.several-versions.md
new file mode 100644
index 00000000..587cd4a2
--- /dev/null
+++ b/docs/pt/3.guides/2.several-versions.md
@@ -0,0 +1,73 @@
+---
+title: Várias versões
+description: Servir a mesma documentação em várias refs, com seletor e aviso.
+icon: lucide:git-compare
+---
+
+Uma ref torna-se uma versão. Aponta uma fonte para tags e cada uma é publicada,
+com um segmento de versão no URL e uma entrada no seletor.
+
+## Passos
+
+::steps
+1. **Lista as refs.** Um texto simples é um **ramo**. Uma tag tem de o dizer — o
+ git mantém os dois em espaços de nomes separados, e pedir uma tag sob
+ `refs/heads` faz a compilação falhar.
+
+ ```ts [app/app.config.ts]
+ sources: [
+ {
+ repo: 'acme/api',
+ path: 'docs',
+ refs: [
+ { branch: 'main', status: 'upcoming' },
+ { tag: 'v2.0.0' },
+ { tag: 'v1.4.0' },
+ { tag: 'v0.9.0', status: 'eol' }
+ ]
+ }
+ ],
+ sourceOptions: { defaultRef: 'v2.0.0' }
+ ```
+
+2. **Nomeia a predefinida.** `defaultRef` é a ref servida sem prefixo de versão
+ e aquela para onde os motores de busca são apontados. Se não for definida, é
+ a primeira da lista — o que raramente é o que queres quando a lista começa
+ por um ramo.
+
+3. **Declara o ciclo de vida onde não pode ser deduzido.** O semver ordena tags,
+ por isso «mais antiga do que a predefinida» não precisa de ajuda. O que não
+ consegue é situar um ramo face a uma tag: `main` é `upcoming`, e sem o dizer
+ pede-se ao seu leitor que atualize para uma versão mais antiga do que aquela
+ que está a ler.
+
+4. **Usa `latest` se não quiseres editar esta lista a cada lançamento.**
+ Resolve, na compilação, para a tag semver mais recente desse repositório.
+
+ ```ts
+ refs: [{ branch: 'main', status: 'upcoming' }, 'latest']
+ ```
+
+5. **Assinala o requisito de versão de uma página no texto**, onde quem olha
+ para uma versão não consegue ver as outras:
+
+ ```md
+ ### Novas tentativas :since{version="v2.0.0"}
+ ```
+
+::
+
+## O que acontece às versões antigas
+
+Uma versão não predefinida leva `noindex` e um `canonical` a apontar para a
+mesma página na predefinida, e uma versão `eol` sai completamente do sitemap. É
+isso que impede um motor de busca de oferecer `v0.9.0` onde o leitor queria a
+documentação de hoje — ver [URL e versões](/concepts/urls-and-versions).
+
+## Lista de verificação
+
+- [ ] Cada tag está escrita como `{ tag: … }`, não como texto simples
+- [ ] `defaultRef` nomeia a versão onde os leitores devem aterrar
+- [ ] Um ramo na lista traz um `status` explícito
+- [ ] O seletor mostra todas as versões, com distintivo nas mortas
+- [ ] A página de uma versão morta não está em `/sitemap.xml`
diff --git a/docs/pt/3.guides/3.several-languages.md b/docs/pt/3.guides/3.several-languages.md
new file mode 100644
index 00000000..ff94e4c9
--- /dev/null
+++ b/docs/pt/3.guides/3.several-languages.md
@@ -0,0 +1,126 @@
+---
+title: Vários idiomas
+description: Servir a interface em vários idiomas, e traduzir também as páginas.
+icon: lucide:languages
+---
+
+Os textos do tema estão traduzidos em sete locales. Um site serve o subconjunto
+que quiser, traduz os textos que ele próprio configurou e — se quiser — também
+as páginas.
+
+## Passos
+
+::steps
+1. **Reduz a lista com `duxt.locales`.**
+
+ ```ts [app/app.config.ts]
+ locales: ['en-GB', 'pt-PT']
+ ```
+
+ Não o array `locales` do i18n: esse combina entre camadas em vez de
+ substituir, por isso declarar dois aí deixa os outros cinco roteados e
+ anunciados em `hreflang`.
+
+2. **Escolhe o que fica sem prefixo** no `nuxt.config.ts` com
+ `i18n.defaultLocale`. É relevante para o URL: mudá-lo mais tarde move todas
+ as páginas nos dois sentidos ao mesmo tempo.
+
+3. **Traduz os teus próprios textos.** Para um punhado, a forma de registo custa
+ menos do que um ficheiro de locale por idioma:
+
+ ```ts
+ sections: [
+ {
+ label: { 'en-GB': 'Guides', 'pt-PT': 'Guias' },
+ to: '/guides',
+ icon: 'lucide:book-open'
+ }
+ ]
+ ```
+
+ Para mais do que um punhado, regista as tuas próprias chaves i18n e escreve a
+ chave. Não recorras a `duxt.defaults.*` — são as chaves internas da camada, e
+ uma mudança de nome aí partiria o teu site em silêncio.
+
+4. **Define a origem.** `hreflang` não é válido em relativo, por isso precisa de
+ `i18n.baseUrl` ou `NUXT_PUBLIC_I18N_BASE_URL`.
+
+5. **Traduz as páginas, se quiseres.** Uma pasta por idioma dentro da fonte; o
+ locale por omissão fica onde está e não move nenhum URL:
+
+ ```ts [app/app.config.ts]
+ sources: [{ path: 'docs', locales: ['en-GB', 'pt'] }]
+ ```
+
+ ::file-tree{title="docs/"}
+ ---
+ tree:
+ - name: index.md
+ - name: 1.getting-started/
+ children:
+ - name: index.md
+ - name: pt/
+ children:
+ - name: index.md
+ ---
+ ::
+
+ Traduz tão poucas páginas quanto quiseres — tudo o resto recai no original,
+ com um aviso que diz ao leitor porquê.
+
+6. **Abre uma página em cada locale** e verifica que a interface mudou de
+ idioma, que o caminho continua a resolver e que nenhum rótulo configurado
+ aparece como chave em bruto.
+::
+
+## Só algumas versões, ou outro repositório
+
+`locales` numa ref sobrepõe-se ao da fonte, que é a forma habitual: a versão
+atual está traduzida e as duas anteriores não.
+
+```ts
+{
+ path: 'docs',
+ locales: ['en-GB', 'pt'],
+ refs: [
+ { tag: 'v2.0.0' }, // ambos os idiomas
+ { tag: 'v1.0.0', locales: ['en-GB'] } // só o original
+ ]
+}
+```
+
+A forma de objeto move um idioma completamente para outro sítio — o seu próprio
+repositório, com os seus próprios responsáveis e o seu próprio ritmo:
+
+```ts
+locales: ['en-GB', { locale: 'pt', repo: 'acme/docs-pt', path: 'docs' }]
+```
+
+É o que o React e o Vue fazem fora das suas ferramentas, com `de.react.dev` e a
+organização `vuejs-translations`, porque quem traduz trabalha com o seu próprio
+calendário e a sua própria revisão. Aqui é uma entrada de fonte em vez de um
+segundo site — um índice de pesquisa, um seletor de idioma.
+
+::callout{type="warning" title="O que custa compilar uma tradução"}
+Cerca de **dois segundos de compilação e 0,6 MB de base de dados por idioma ×
+versão × repositório**, medido linear até pelo menos 200 coleções. Traduzir
+todas as versões de todos os repositórios multiplica os três; traduzir apenas a
+versão atual é o que a maioria dos projetos faz.
+::
+
+## Quando uma página não tem tradução
+
+O leitor recebe o idioma mais próximo que a tenha, com um aviso, e a página leva
+`noindex` mais um canonical a apontar para o idioma em que realmente está. A
+cadeia — locale, língua base, região irmã, `fallbackLocale`, original — está em
+[Localização](/concepts/localisation).
+
+## Lista de verificação
+
+- [ ] `duxt.locales` lista exatamente os locales que serves
+- [ ] `/sitemap.xml` contém uma entrada por página e locale, e mais nenhuma
+- [ ] As ligações `hreflang` são absolutas
+- [ ] Nenhum rótulo configurado aparece como chave em bruto
+- [ ] Mudar de locale mantém o leitor na mesma página
+- [ ] Uma página que não traduziste mostra o aviso de recurso, não um 404
+- [ ] `duxt.sourceOptions.defaultLocale` corresponde a `i18n.defaultLocale`
diff --git a/docs/pt/3.guides/4.override-the-theme.md b/docs/pt/3.guides/4.override-the-theme.md
new file mode 100644
index 00000000..39cf53b8
--- /dev/null
+++ b/docs/pt/3.guides/4.override-the-theme.md
@@ -0,0 +1,71 @@
+---
+title: Substituir o tema
+description: Mudar uma cor, substituir um componente, acrescentar uma primitiva shadcn-vue.
+icon: lucide:palette
+---
+
+A camada entrega o tema como ficheiros que podes sombrear. Três níveis, do menor
+para o maior — e o menor que resolva o problema é o certo, porque um componente
+que substituis é um componente que passas a manter.
+
+## Passos
+
+:::steps
+1. **Uma cor: redefine o token.** A paleta é o conjunto neutro do shadcn como
+ variáveis CSS. Redefine uma na tua própria folha de estilos; não é preciso
+ bifurcar o ficheiro da camada.
+
+ ```css [app/assets/css/main.css]
+ :root {
+ --primary: oklch(0.55 0.2 265);
+ }
+ .dark {
+ --primary: oklch(0.7 0.18 265);
+ }
+ ```
+
+ Ambos os blocos importam: o tema comuta com uma **classe** `dark`, não com
+ uma media query, por isso um token definido uma só vez está errado num dos
+ dois modos.
+
+ ::callout{type="warning" title="Verifica o contraste que acabaste de mudar"}
+ `--muted-foreground` transporta as descrições das páginas, o índice e a
+ navegação estrutural. O valor da camada é deliberadamente mais escuro do que
+ o predefinido do shadcn, que mede abaixo de 4,5:1 nas superfícies onde este
+ token realmente assenta.
+ ::
+
+2. **Uma peça do layout: cria o ficheiro.** O Nuxt resolve primeiro o teu
+ componente com o mesmo nome, por isso substituir `DuxtFooter` é criar
+ `app/components/DuxtFooter.vue` no teu projeto. Os nomes, props e slots estão
+ na [referência de componentes](/reference/components) — um nome documentado
+ faz parte da superfície pública, por isso não será renomeado sem um
+ lançamento maior.
+
+3. **Um slot, onde exista.** `DuxtPageFeedback` emite `feedback` e expõe o seu
+ estado através de um slot, para poderes pendurar o teu próprio formulário sem
+ substituir o componente — desenvolvido em
+ [Recolher opiniões da página](/guides/collect-page-feedback).
+
+4. **Uma primitiva nova: usa a CLI.** A camada possui os seus componentes
+ shadcn-vue em vez de os importar, e o `components.json` já aponta a CLI para
+ o alias certo:
+
+ ```bash
+ pnpm dlx shadcn-vue@latest add tooltip
+ ```
+
+5. **Um componente para Markdown.** Tudo o que estiver em
+ `app/components/content/` é invocável a partir de uma página com a sintaxe de
+ bloco MDC. O teu ficheiro com o mesmo nome substitui o da camada — ver a
+ [referência MDC](/reference/mdc-components).
+
+:::
+
+## Lista de verificação
+
+- [ ] Nada foi copiado da camada que um token pudesse ter mudado
+- [ ] Cada token redefinido tem valor para claro e para escuro
+- [ ] Os componentes substituídos têm exatamente o nome que a referência lhes dá
+- [ ] `pnpm build` continua a apresentar todas as páginas (uma substituição é
+ onde o SSR parte)
diff --git a/docs/pt/3.guides/5.move-a-page.md b/docs/pt/3.guides/5.move-a-page.md
new file mode 100644
index 00000000..1b9a6a17
--- /dev/null
+++ b/docs/pt/3.guides/5.move-a-page.md
@@ -0,0 +1,54 @@
+---
+title: Mover uma página
+description: redirectFrom no frontmatter, transformado em regras de rota sob todos os prefixos.
+icon: lucide:signpost
+---
+
+Uma página movida é a única ligação partida que um site de documentação cria
+para si próprio. Põe o URL antigo no frontmatter da página nova, e o
+redirecionamento sai no mesmo commit que a mudança.
+
+## Passos
+
+::steps
+1. **Move o ficheiro** e muda-lhe o nome como pretendias.
+
+2. **Lista os caminhos antigos na própria página**, sem prefixo nenhum:
+
+ ```md [docs/3.guides/6.deploy-a-site.md]
+ ---
+ title: Implementar um site
+ description: Onde um site duxt pode correr.
+ redirectFrom:
+ - /getting-started/deploy
+ - /deployment
+ ---
+ ```
+
+3. **Compila de novo.** A compilação transforma cada entrada em regras de rota —
+ sob todos os prefixos de repositório, versão e locale em que a página é
+ servida.
+
+4. **Verifica um deles**, prefixo incluído, no site compilado.
+
+::
+
+## Porque o frontmatter é o sítio certo
+
+A correção sempre viveu fora do site: uma regra do nginx, um ficheiro
+`_redirects` do Netlify, uma lista da Cloudflare — escritos por quem implementa
+o site e nunca por quem moveu a página. E só a camada o pode resolver de uma
+vez, porque só o manifesto resolvido sabe que prefixos existem; quem escrevesse
+as regras à mão teria de redescobrir todo o esquema página a página.
+
+::callout{type="tip" title="Reordenar nunca precisa de um redirecionamento"}
+O prefixo numérico é retirado do URL, por isso mudar `3.guides/` para
+`4.guides/` não move nada. Só a parte depois do ponto é o URL.
+::
+
+## Lista de verificação
+
+- [ ] Todos os caminhos antigos estão listados, escritos sem prefixo
+- [ ] A compilação não reporta nenhuma ligação interna partida
+- [ ] Um URL antigo também redireciona sob o prefixo de versão, não só nu
+- [ ] Nada foi acrescentado à configuração do servidor web
diff --git a/docs/pt/3.guides/6.deploy-a-site.md b/docs/pt/3.guides/6.deploy-a-site.md
new file mode 100644
index 00000000..892b6b22
--- /dev/null
+++ b/docs/pt/3.guides/6.deploy-a-site.md
@@ -0,0 +1,85 @@
+---
+title: Implementar um site
+description: Onde um site duxt pode correr, do que precisa lá, e porque o conteúdo não é atualizado em execução.
+icon: lucide:cloud-upload
+---
+
+Um site duxt é uma aplicação Nuxt. Tudo o que corre Nuxt corre-o.
+
+## Passos
+
+:::steps
+1. **Prefere estático.** `nuxt generate` produz uma pasta de HTML — sem
+ servidor, sem custo de execução, cada página em cache na periferia. É a
+ escolha certa por omissão para documentação.
+
+ ::package-managers{command="run generate"}
+ ::
+
+ `nuxt build` produz um servidor Node, de que só precisas se uma página tiver
+ de reagir a um pedido.
+
+2. **Dá à compilação o que ela precisa.** Node 24 (a base de dados é lida
+ através de `node:sqlite`, por isso não se instala nenhum controlador nativo),
+ acesso de rede a qualquer fonte remota, e um token para as privadas.
+
+3. **Define a origem.** `NUXT_PUBLIC_I18N_BASE_URL`, ou `i18n.baseUrl` na
+ configuração. Sem ela, `hreflang`, `canonical`, o sitemap e os cartões
+ sociais recaem em saída relativa.
+
+4. **Define um segredo de imagem OG se implementares mais do que uma
+ instância.** `NUXT_OG_IMAGE_SECRET`, gerado uma vez. Sem ele cada compilação
+ assina os URL de imagem com um segredo próprio, e um cartão assinado por uma
+ instância é rejeitado pela seguinte.
+
+5. **Agenda uma nova compilação.** Um site que lê outro repositório recolhe as
+ alterações quando compila, não quando são enviadas.
+
+:::
+
+## Porque não há atualização em execução
+
+O Content descarrega um repositório remoto na compilação e compila-o na base de
+dados que a compilação entrega. Atualizar isso em execução significaria
+reconstruir a base de dados dentro de um servidor vivo — o Content não oferece
+nenhum caminho suportado para isso, e o único disponível é a mesma base de dados
+de cliente que a pesquisa lê. Uma nova compilação agendada, ou desencadeada a
+partir do repositório de origem, mantém a implementação imutável e o conteúdo
+reproduzível.
+
+## Quando uma compilação diz `database is locked`
+
+O Content guarda a sua cache de análise em `.data/content/contents.sqlite`, e
+uma compilação que o kernel ou um `Ctrl-C` deixou para trás pode continuar a
+mantê-la aberta. Cada compilação seguinte falha então com `database is locked` —
+uma mensagem que não nomeia nem o ficheiro nem o processo.
+
+O duxt coloca essa base de dados em [modo WAL](https://www.sqlite.org/wal.html)
+antes de o Content a abrir, o que permite que um leitor e um escritor coexistam
+e elimina por completo a versão quotidiana disto. O que não consegue eliminar é
+um segundo processo ainda em execução:
+
+```bash
+pgrep -af 'nuxt (build|dev|generate)' # o processo órfão, se existir
+kill
+```
+
+Se nada aparecer e o erro persistir, apaga `.data/content/` — é uma cache, e a
+compilação seguinte reconstrói-a a partir das fontes.
+
+## O que o site serve além das páginas
+
+`/llms.txt`, `/llms-full.txt`, o código `.md` de qualquer página, `/mcp`,
+`/sitemap.xml`, `/robots.txt` e — depois de configurado — `/rss.xml`. A lista
+completa com as suas ressalvas está na
+[referência de endpoints](/reference/endpoints).
+
+## Lista de verificação
+
+- [ ] `nuxt generate` termina sem erro do validador
+- [ ] A origem está definida na implementação, não apenas localmente
+- [ ] `/sitemap.xml` lista URL absolutos no domínio real
+- [ ] Está agendada uma nova compilação, ou é desencadeada a partir de cada
+ repositório de origem
+- [ ] `NUXT_OG_IMAGE_SECRET` está definido se mais do que uma instância servir o
+ site
diff --git a/docs/pt/3.guides/7.collect-page-feedback.md b/docs/pt/3.guides/7.collect-page-feedback.md
new file mode 100644
index 00000000..dc8cc1bb
--- /dev/null
+++ b/docs/pt/3.guides/7.collect-page-feedback.md
@@ -0,0 +1,120 @@
+---
+title: Recolher opiniões da página
+description: Envolve o DuxtPageFeedback e guarda a resposta onde a tua analítica já vive.
+icon: lucide:thumbs-up
+---
+
+Cada página de documentação termina com «Esta página foi útil?», e a camada não
+faz nada com a resposta: mantém-na no estado do próprio componente durante a
+visita, emite um evento e não guarda nada. Envolver o componente é a forma de a
+resposta chegar até ti.
+
+## Passos
+
+::steps
+
+1. **Envolve o componente da camada com o nome dele.** O Nuxt resolve o teu
+ ficheiro primeiro, por isso `app/components/DuxtPageFeedback.vue` no teu
+ projeto substitui o da camada — e o alias `@duxt` continua a alcançar o
+ original pelo caminho, de modo que o invólucro mantém a marcação, as traduções
+ e a reposição na navegação da camada:
+
+ ```vue [app/components/DuxtPageFeedback.vue]
+
+
+
+
+
+ ```
+
+2. **Dá à resposta um sítio onde aterrar.** Uma rota tua, para que o browser do
+ leitor nunca fale com um terceiro que não escolheste:
+
+ ```ts [server/api/feedback.post.ts]
+ export default defineEventHandler(async (event) => {
+ const { path, helpful } = await readBody(event);
+
+ // O teu armazenamento, a tua analítica, a tua fila.
+ await recordFeedback({ path, helpful });
+
+ return { ok: true };
+ });
+ ```
+
+ Uma chamada de analítica do lado do cliente funciona da mesma maneira —
+ substitui o `$fetch` do invólucro pelo que o SDK do teu fornecedor expuser.
+
+3. **Compila de novo e responde à pergunta uma vez**, depois verifica se a
+ resposta chegou com o caminho que esperavas — sob um prefixo de versão, e não
+ apenas no URL nu.
+
+::
+
+## Apresentar o teu próprio controlo
+
+O evento mantém os botões da camada. Para os substituir, usa antes o slot por
+omissão: expõe o mesmo estado a partir do qual o componente é apresentado, para
+que a reposição na navegação e o estado de «obrigado» continuem a ser tarefa da
+camada.
+
+```vue [app/components/DuxtPageFeedback.vue]
+
+
+
+
+
+
+ Diz-nos o que falta
+
+
+ Enviar
+
+ Obrigado.
+
+
+```
+
+`answer(helpful)` define o estado e emite o evento, por isso um slot e um
+ouvinte compõem-se: pendura o teu formulário no slot e mantém o manipulador
+`@feedback` do passo 1 para entregar o que ele recolheu.
+
+::callout{type="warning" title="Duas respostas não são dois leitores"}
+Nada no componente identifica um visitante, e assim fica de propósito. Se contas
+respostas por página, conta-as como respostas — um browser que recarrega e
+responde outra vez é uma segunda resposta, não um segundo leitor, e transformar
+uma coisa na outra é uma questão de consentimento que a tua política de
+privacidade tem de responder primeiro.
+::
+
+## Porque a camada não guarda nada
+
+Um tema de documentação que telefonasse para casa por omissão teria de escolher
+um destino para os leitores de outra pessoa, e não há nenhum defensável: a
+camada não sabe onde vive a tua analítica nem o que promete a tua política de
+privacidade. Entregar a pergunta com um evento e sem backend mantém a decisão
+onde pertence — a mesma razão por que a linha legal do rodapé e as ligações ao
+repositório são entregues vazias
+([ADR 0005](/adr/0005-ship-the-layer-without-owner-specific-links)).
+
+## Lista de verificação
+
+- [ ] O invólucro apresenta o componente da camada em vez de o reimplementar
+- [ ] A resposta é enviada com o caminho em que foi dada, prefixos incluídos
+- [ ] O destino é teu — uma rota, uma fila ou um SDK que escolheste
+- [ ] Nada na carga útil identifica o leitor
diff --git a/docs/pt/3.guides/8.brand-your-site.md b/docs/pt/3.guides/8.brand-your-site.md
new file mode 100644
index 00000000..2d72d6de
--- /dev/null
+++ b/docs/pt/3.guides/8.brand-your-site.md
@@ -0,0 +1,91 @@
+---
+title: Dá identidade ao teu site
+description: Define o nome, o logótipo e os ícones, para que o site se leia como teu e não como o da camada.
+icon: lucide:badge-check
+---
+
+A camada é entregue sem identidade de propósito: sem nome, sem marca, sem ícones.
+Um site que a estendesse e mostrasse a identidade do duxt estaria a anunciar o
+projeto de outra pessoa — cada uma destas coisas é tua para definir. Até lá, o
+cabeçalho recai num ícone genérico de livro.
+
+## Passos
+
+:::steps
+
+1. **O nome.** `title` é a palavra na barra de navegação, no modelo do título de
+ SEO, na 404, no canal RSS e como cabeçalho do `llms.txt`. Um valor, sete
+ leitores.
+
+ ```ts [app/app.config.ts]
+ export default defineAppConfig({
+ duxt: {
+ title: 'Acme SDK'
+ }
+ });
+ ```
+
+ Igual em todas as línguas, portanto uma cadeia simples é o correto. Onde não
+ for, a forma de registo funciona aqui como em qualquer outro sítio — vê
+ [Várias línguas](/guides/several-languages).
+
+2. **O logótipo, se tiveres um.** `logo.src` substitui o par de ícone e nome por
+ uma imagem. `srcDark` é trocado por CSS sob a classe `dark`, não por script —
+ uma escolha por script apresenta a marca clara no HTML do servidor e troca-a
+ depois da hidratação, o que se vê como um piscar.
+
+ ```ts [app/app.config.ts]
+ logo: {
+ src: '/wordmark.svg',
+ srcDark: '/wordmark-dark.svg',
+ alt: 'Acme SDK'
+ }
+ ```
+
+ `alt` recai em `title`, portanto define-o só onde a marca diga algo que o nome
+ não diz.
+
+ ::callout{type="info" title="A altura é fixa, a largura acompanha"}
+ Cada logótipo tem a sua própria proporção, por isso o componente fixa a altura e
+ deixa a largura acompanhar. Uma marca pensada para uma caixa quadrada parecerá
+ esfomeada; uma muito mais larga do que alta não será esmagada.
+ ::
+
+3. **Os ícones do navegador.** Isto não é configuração do duxt, mas entradas
+ normais de head do Nuxt — porque um favicon é de um site, nunca de um tema.
+
+ ```ts [nuxt.config.ts]
+ app: {
+ head: {
+ link: [
+ { rel: 'icon', type: 'image/svg+xml', href: '/favicon.svg' },
+ { rel: 'apple-touch-icon', href: '/apple-touch-icon.png' }
+ ]
+ }
+ }
+ ```
+
+ Um ícone SVG pode levar a sua própria regra `prefers-color-scheme`, portanto um
+ ficheiro cobre uma barra de separadores clara e uma escura. O PNG (180×180)
+ existe só porque o iOS ignora ícones SVG.
+
+4. **A linha de atribuição, se a quiseres fora.** O rodapé leva uma linha
+ "Powered by duxt" com a versão da camada. `poweredBy: false` retira-a — uma
+ camada a que não se pode dizer que pare de se nomear é adware.
+
+:::
+
+## O que continua a ser da camada
+
+Cores, espaçamento e componentes são outra questão, e outra página:
+[Substituir o tema](/guides/override-the-theme). Esta página cobre a identidade —
+o nome e a marca. Vale a pena mantê-las separadas, porque uma cor de marca
+costuma revelar-se um token e não uma bifurcação do tema.
+
+## Lista de verificação
+
+- [ ] `title` está definido e nada no cabeçalho se lê ainda como "Documentation"
+- [ ] `logo.srcDark` está definido, ou a marca é legível em ambos os fundos
+- [ ] O favicon e o apple-touch-icon resolvem — ambos servidos a partir de `public/`
+- [ ] `llms.txt` e `rss.xml` nomeiam o teu site, não um marcador de posição
+- [ ] `poweredBy` é uma escolha deliberada e não um padrão que ninguém olhou
diff --git a/docs/pt/3.guides/index.md b/docs/pt/3.guides/index.md
new file mode 100644
index 00000000..701c192a
--- /dev/null
+++ b/docs/pt/3.guides/index.md
@@ -0,0 +1,12 @@
+---
+title: Guias
+description: Uma tarefa por página, do início ao fim — vários repositórios, várias versões, vários idiomas e uma implementação.
+icon: lucide:book-open
+---
+
+Cada página aqui completa uma tarefa e termina com uma lista de verificação. O
+modelo por trás delas está nos [Conceitos](/concepts); cada chave que definem
+está na [Referência](/reference).
+
+::page-cards
+::
diff --git a/docs/pt/4.reference/1.configuration.md b/docs/pt/4.reference/1.configuration.md
new file mode 100644
index 00000000..7c9f9bd3
--- /dev/null
+++ b/docs/pt/4.reference/1.configuration.md
@@ -0,0 +1,195 @@
+---
+title: Configuração
+description: Cada chave sob duxt em app.config.ts — o que controla e quando é lida.
+icon: lucide:settings
+---
+
+Tudo o que se segue vive sob `duxt` no teu `app/app.config.ts`. Como a
+combinação funciona, e porque os arrays substituem em vez de acrescentar, está
+em [Configuração](/getting-started/configuration).
+
+## Site
+
+| Chave | Por omissão | O que controla |
+| :----------------- | :------------------- | :-------------------------------------------------------- |
+| `title` | **nenhum** | O nome na barra de navegação e no título de SEO |
+| `logo` | **nenhum** | Um logótipo em vez do par de ícone e nome — ver abaixo |
+| `version` | **nenhuma** | O distintivo ao lado |
+| `organization` | **nenhuma** | Quem publica o site, para schema.org — ver abaixo |
+| `locales` | os sete | Que idiomas da camada este site serve |
+| `breadcrumb` | `true` | `false` retira o trilho por cima do título da página |
+| `pageIcon` | **nenhum** | Ícone para páginas sem um próprio — o da secção ganha |
+| `packageManagers` | pnpm, npm, yarn, bun | Que gestores um bloco de comando oferece, por essa ordem |
+| `poweredBy` | ligado | `false` retira a linha «Powered by duxt» do rodapé |
+
+### `logo`
+
+| Campo | Tipo | Notas |
+| :-------- | :------- | :------------------------------------------------------------ |
+| `src` | `string` | A marca. Sem valor fica o ícone genérico ao lado de `title` |
+| `srcDark` | `string` | Trocado por CSS sob a classe `dark`, não por script |
+| `alt` | texto | Recai em `title` |
+
+Definir um é [Dá identidade ao teu site](/guides/brand-your-site).
+
+### `organization`
+
+O nó `Organization` que os motores de busca e os resumos de IA leem para nomear
+uma fonte. Ausente até o definires: o duxt renderiza a documentação de outros e
+não adivinha de quem.
+
+| Campo | Tipo | Notas |
+| :----- | :------- | :---------------------------------------------------------------- |
+| `name` | texto | O nome de quem publica. Sem ele não se publica nada |
+| `url` | `string` | O site dessa entidade. Recorre a `site.url` |
+| `logo` | `string` | Absoluto, ou um caminho desde a raiz; quadrado e com 112px ou mais |
+
+
+## Navegação e ligações
+
+Uma entrada de secção é uma ligação mais um campo próprio:
+
+| Campo | Tipo | Notas |
+| :--------- | :------- | :------------------------------------------------------------------ |
+| `pageIcon` | `string` | Ícone para as páginas desta secção que não definam um. Prevalece sobre `duxt.pageIcon`; o frontmatter de uma página continua a ganhar |
+
+Útil onde as páginas não podem levar ícone — o frontmatter de um ADR está fixo em
+`title`, `description`, `status` e `date`, pelo que o registo de decisões seria
+apresentado sem nenhum.
+
+| Chave | Por omissão | O que controla |
+| :----------- | :--------------- | :-------------------------------------------------------------- |
+| `navigation` | uma entrada | Ligações da barra; uma entrada com `children` vira um menu |
+| `sections` | **vazio** | A segunda linha da barra — as partes de topo da documentação |
+| `links` | **vazio** | Ligações com ícone à direita da barra |
+| `aside` | só o título | `title` e `links` sob o índice |
+| `footer` | **vazio** | `copyright` e `legal` |
+| `landing` | uma ação | A página em `/` |
+
+A maioria é entregue vazia, por duas razões diferentes. `links`, `aside.links`,
+`footer.legal`, `title`, `version` e o texto da página inicial nomeiam um projeto
+concreto — um repositório, uma comunidade, um aviso legal, um nome, uma versão
+publicada, uma frase sobre o que faz — e pertencem a quem gere o site. `sections` nomeia as partes de
+uma **árvore** de documentação, e a árvore é tua: quatro separadores a apontar
+para nomes de pasta que a camada adivinhasse não levariam a lado nenhum.
+Define-o quando a tua documentação tiver secções; até lá a linha não é
+apresentada e a barra lateral mostra a árvore inteira, que é a forma certa para
+documentação sem secções.
+
+### A página inicial
+
+A página em `/` desenha três faixas, e cada uma está ausente até a sua chave
+estar definida.
+
+| Chave | Por omissão | O que desenha |
+| :------------ | :----------------------- | :---------------------------------------------------- |
+| `badge` | **nenhum** | A pastilha por cima do título — texto, ou um objeto de distintivo |
+| `headline` | **nenhum** | O `h1`; recai sobre `title` |
+| `description` | **nenhuma** | O parágrafo por baixo, e a meta description da página |
+| `actions` | uma, «Ler a documentação» | Os botões do bloco principal; uma ação sem `to` resolve para a primeira secção |
+| `command` | **nenhum** | Um comando de instalação copiável sob os botões |
+| `preview` | **nenhum** | Uma página deste site, incorporada numa janela de browser |
+| `features` | **vazio** | A grelha de cartões |
+
+`command` é uma `string` simples, não texto: um comando de shell é igual em
+todos os idiomas, e um traduzido por engano é um que não corre. A camada não
+entrega nenhum — não sabe como se chama o teu projeto, pela mesma razão por que
+`links` está vazio.
+
+Não há chamada à ação final, deliberadamente. Os botões do bloco principal são a
+chamada; repeti-los sob uma grelha de cartões pede a um leitor acabado de chegar que
+decida duas vezes.
+
+`badge` é entregue vazio pela mesma razão que `links`: uma pastilha por cima do
+título diz algo sobre o estado de um projeto — «beta», «a v2 saiu» — e a camada
+nada sabe sobre o estado do teu. Um texto é a forma curta; o objeto acrescenta
+ícone, cor e destino.
+
+| Campo de `badge` | Tipo | Notas |
+| :--------------- | :-------- | :--------------------------------------------------------- |
+| `label` | texto | Obrigatório. `{version}` é substituído pela `version` do site |
+| `icon` | `string` | Qualquer nome do Iconify |
+| `variant` | `string` | `default`, `secondary`, `outline`, `success`, `destructive` |
+| `to` | `string` | Faz de toda a pastilha uma ligação |
+| `external` | `boolean` | O separador novo |
+
+```ts [app/app.config.ts]
+badge: {
+ label: '{version} lançada',
+ icon: 'lucide:rocket',
+ variant: 'success',
+ to: 'https://github.com/acme/sdk/releases/latest',
+ external: true
+}
+```
+
+`preview` é uma janela viva, não uma imagem. Incorpora uma página **deste** site
+num enquadramento de browser que o leitor pode percorrer, navegar e cujo tema
+pode mudar sem sair da página inicial. O enquadramento só é montado quando a
+faixa entra no ecrã, e nunca durante a renderização no servidor — um iframe no
+HTML inicial é um segundo carregamento completo a competir com o primeiro.
+
+| Campo de `preview` | Tipo | Notas |
+| :----------------- | :------- | :---------------------------------------------------------- |
+| `to` | `string` | A página a incorporar; por omissão, a primeira secção |
+| `height` | `string` | Qualquer comprimento CSS. Por omissão `32rem`, `24rem` sob `sm` |
+| `src` | `string` | Uma captura EM VEZ da página viva |
+| `srcDark` | `string` | Serve o modo escuro; sem ele, `src` serve ambos |
+| `alt` | texto | O texto alternativo da imagem e o nome acessível do enquadramento |
+
+::callout{type="tip" title="Uma captura é a opção mais barata"}
+O enquadramento vivo carrega a aplicação uma segunda vez. Um site que prefira
+não pagar isso define `src` e obtém a mesma janela à volta de uma imagem fixa.
+::
+
+### Forma de uma ligação
+
+| Campo | Tipo | Notas |
+| :------------ | :------------------ | :------------------------------------------------ |
+| `label` | texto | Obrigatório |
+| `to` | `string` | Um caminho de documentação é localizado; um URL não |
+| `icon` | `string` | Qualquer nome do Iconify, p. ex. `lucide:github` |
+| `description` | texto | Mostrada num menu suspenso da barra |
+| `external` | `boolean` | O separador novo e a seta — nunca o roteamento |
+| `children` | `DuxtLink[]` | Transforma uma entrada da barra num menu suspenso |
+| `variant` | variante de botão | Só `landing.actions` |
+
+Todos os campos marcados como _texto_ aceitam um literal, uma chave i18n ou um
+registo por locale — ver [Localização](/concepts/localisation).
+
+## Fontes
+
+| Chave | Lida em | O que controla |
+| :-------------- | :------------------- | :------------------------------------------ |
+| `sources` | tempo de compilação | As fontes de documentação |
+| `sourceOptions` | tempo de compilação | Como essas fontes se tornam prefixos de URL |
+| `versions` | execução | Substitui as versões derivadas quando precisam de rótulos ou descrições |
+
+Ambas as chaves de fonte têm [uma página própria](/reference/sources).
+`locales` é a terceira chave de compilação: alterar qualquer uma das três exige
+nova compilação.
+
+## O feed
+
+`/rss.xml` está vazio até `feed.path` nomear uma secção:
+
+```ts
+feed: { path: '/changelog', title: 'o meu projeto — lançamentos' }
+```
+
+Desligado por omissão, de propósito. Um feed é uma lista de coisas que
+**aconteceram**, e uma página de referência editada não é um acontecimento — um
+site que publica cada edição como item ensina os seus leitores a cancelar a
+subscrição. Os itens são ordenados pela `date` da própria página, recorrendo ao
+último commit que lhe tocou, e só a versão predefinida de cada fonte contribui,
+por isso um changelog versionado não repete cada entrada uma vez por versão.
+
+## Gerado, não escrito
+
+| Chave | Escrito por | Contém |
+| :----------------- | :----------------- | :----------------------------------------------- |
+| `resolvedSources` | a compilação | O manifesto: que coleção serve que prefixo |
+| `layerVersion` | a compilação | A versão do duxt, para o rodapé |
+| `layerRepository` | a compilação | O repositório do duxt, para o rodapé |
+
+Definir qualquer um dos três à mão é sobrescrito na compilação seguinte.
diff --git a/docs/pt/4.reference/2.sources.md b/docs/pt/4.reference/2.sources.md
new file mode 100644
index 00000000..14185609
--- /dev/null
+++ b/docs/pt/4.reference/2.sources.md
@@ -0,0 +1,124 @@
+---
+title: Fontes
+description: Os campos de uma entrada de fonte, as suas refs, e as opções que moldam os URL.
+icon: lucide:git-branch
+---
+
+`duxt.sources` é uma lista de entradas de fonte; `duxt.sourceOptions` diz como
+se tornam URL. Ambas são lidas em tempo de compilação. O que geram, e porquê,
+está em [Fontes](/concepts/sources).
+
+## Uma entrada de fonte
+
+| Campo | Tipo | Notas |
+| :-------- | :----------------------- | :---------------------------------------------------------------- |
+| `path` | `string` | Pasta com o Markdown, relativa à raiz do repositório |
+| `repo` | `string` | `owner/name` ou um URL git. **Omitido significa este repositório** |
+| `refs` | lista de refs | Refs a publicar como versões. Omitido significa o checkout atual |
+| `label` | texto | Mostrado no seletor e usado no URL; por omissão, a ref |
+| `slug` | `string` | O segmento de URL do repositório; por omissão, o seu nome |
+| `status` | estado | Ciclo de vida de cada versão que esta entrada publica |
+| `origin` | `{ repo, ref? }` | Onde vivem as páginas lidas do disco, para ligar de volta |
+| `history` | `boolean` | Ler o histórico git desta fonte |
+| `locales` | lista de locales | Idiomas em que esta fonte existe — ver abaixo |
+
+Dois campos confundem-se facilmente e saem caros quando errados:
+
+- **`repo` descarrega, `origin` apenas liga.** Nomear o teu próprio repositório
+ em `repo` faz a compilação clonar o checkout onde já está.
+- **`history` custa um clone completo.** O Content clona um repositório remoto
+ com `--depth 1`, por isso o seu checkout tem um commit e cada ficheiro parece
+ escrito por quem cortou a ponta — dados errados, não dados em falta. Ativá-lo
+ completa o clone uma vez. Uma fonte lida do disco já é um checkout completo e
+ é lida de qualquer maneira.
+
+## Refs
+
+Um texto simples é um **ramo**. Uma tag tem de o dizer: o git guarda ramos e
+tags em espaços de nomes separados, e pedir uma tag sob `refs/heads` faz a
+compilação falhar com `Could not find refs/heads/…`.
+
+```ts
+refs: [
+ 'main', // um ramo
+ { branch: 'next', status: 'upcoming' }, // o mesmo, com um ciclo de vida
+ { tag: 'v2.0.0', label: 'v2' }, // uma tag
+ 'latest' // a tag semver mais recente, resolvida na compilação
+]
+```
+
+`'latest'` está reservado. Um ramo genuinamente chamado `latest` precisa da
+forma `{ branch }`.
+
+## Locales
+
+Omitido, a fonte tem um idioma e uma coleção. Listado, cada entrada torna-se uma
+coleção própria.
+
+```ts
+locales: [
+ 'en-GB', // o predefinido: o próprio docs/
+ 'de', // docs/de/
+ { locale: 'fr', path: 'translations/fr' }, // noutro sítio deste repositório
+ { locale: 'es', repo: 'acme/docs-es', path: 'docs' } // outro repositório
+]
+```
+
+Um texto é uma pasta dentro do `path` da fonte. A forma de objeto substitui
+`path`, `repo` e `ref` só para esse idioma — e é isso que permite a uma tradução
+viver num repositório próprio, com os seus responsáveis e o seu calendário de
+lançamentos.
+
+**O locale por omissão é a própria árvore em `path` e não ocupa pasta.** Qual é
+vem de `sourceOptions.defaultLocale`, e tem de concordar com
+`i18n.defaultLocale` — a compilação falha se divergirem, porque
+`content.config.ts` resolve as coleções sem acesso à configuração do Nuxt.
+
+Uma ref pode ter os seus próprios `locales`, sobrepondo-se aos da fonte tal como
+`status` faz. É essa a forma habitual: a versão atual está traduzida, as
+anteriores não.
+
+```ts
+refs: [
+ { tag: 'v2.0.0' }, // herda os locales da fonte
+ { tag: 'v1.0.0', locales: ['en-GB'] } // só o original
+]
+```
+
+Nomeia uma pasta por **idioma** onde a região nada acrescente: `docs/pt/` serve
+tanto `pt-PT` como `pt-BR`, porque uma página recai na sua língua base. A mesma
+regra que os ficheiros de locale da própria camada seguem.
+
+## Estado
+
+| Valor | Significa |
+| :----------- | :---------------------------------------------------- |
+| `upcoming` | Ainda não lançada — pode ainda mudar |
+| `current` | A documentação a ler |
+| `maintained` | Mais antiga, ainda suportada |
+| `deprecated` | Mais antiga, e o leitor deve atualizar |
+| `eol` | Morta — avisada, e fora do sitemap |
+
+O que cada um custa a uma página está em
+[URL e versões](/concepts/urls-and-versions).
+
+## Opções de fonte
+
+| Campo | Por omissão | O que faz |
+| :-------------- | :------------------ | :---------------------------------------------------------- |
+| `showRepo` | desligado | Força um segmento de repositório com um só repositório |
+| `showVersion` | desligado | Força um segmento de versão com uma só versão |
+| `defaultRef` | a primeira ref | A ref servida sem prefixo de versão |
+| `defaultLocale` | o primeiro locale | O locale servido a partir do próprio `path`, sem pasta |
+
+## O manifesto resolvido
+
+A compilação transforma a lista acima em `duxt.resolvedSources`, e é isso que o
+tema lê. `useDuxtCollection()` expõe-no; cada entrada transporta o nome da
+coleção, o `prefix` de URL, `repo`, `version`, `isDefault`, `locale`,
+`isDefaultLocale`, `status`, o repositório e a ref de onde vieram as páginas, e
+se o seu `history` foi lido.
+
+O locale está deliberadamente ausente do `prefix`: o i18n já o coloca à frente
+do caminho, por isso todos os idiomas de uma fonte partilham um prefixo e só
+diferem pela coleção.
diff --git a/docs/pt/4.reference/3.frontmatter.md b/docs/pt/4.reference/3.frontmatter.md
new file mode 100644
index 00000000..5fb591df
--- /dev/null
+++ b/docs/pt/4.reference/3.frontmatter.md
@@ -0,0 +1,54 @@
+---
+title: Frontmatter
+description: Os campos que uma página pode ter além do título e da descrição, e os dois que a compilação preenche.
+icon: lucide:file-code
+---
+
+O Content só guarda o que o esquema de uma coleção declara, por isso esta é a
+lista completa: um campo não declarado é descartado em silêncio, antes de a
+barra lateral ou a pesquisa o verem.
+
+## O que escreves
+
+| Campo | Tipo | O que faz |
+| :------------- | :--------- | :------------------------------------------------------------------- |
+| `title` | `string` | Apresentado como o `` da página, e o seu título de SEO e social |
+| `description` | `string` | O resumo — barra lateral, cartões, imagem OG, `llms.txt` |
+| `icon` | `string` | Mostrado ao lado da entrada na barra lateral, na linha e nos cartões |
+| `navigation` | `boolean` | `false` esconde a página da navegação |
+| `layout` | `string` | `landing` apresenta a página sem o layout de documentação |
+| `redirectFrom` | `string[]` | URL antigos desta página — ver [Mover uma página](/guides/move-a-page) |
+| `date` | `string` | A data desta página, para o feed |
+
+`title` e `description` são os dois cuja falta a compilação avisa. Nenhum parte
+uma página, e ambos degradam em silêncio o índice, o cartão social e o
+`llms.txt`.
+
+::callout{type="tip" title="Sem H1 no corpo"}
+`title` é apresentado como o cabeçalho. Um cabeçalho `#` no início do ficheiro é
+o segundo `` da página.
+::
+
+## O que a compilação preenche
+
+| Campo | Preenchido por | Contém |
+| :------------- | :------------------------- | :---------------------------------------------- |
+| `lastUpdated` | o módulo git-meta | O último commit que tocou no ficheiro |
+| `contributors` | o módulo git-meta | Nome, número de commits e utilizador, por autor |
+| `rawbody` | o esquema da coleção | O Markdown tal como foi escrito |
+| `sitemap` | a integração do sitemap | Aquilo que põe a página no `/sitemap.xml` |
+
+Escrever qualquer um deles à mão não serve de nada: a compilação sobrescreve-os.
+Ambos os campos de git precisam de um histórico real, por isso uma fonte remota
+precisa de `history: true` — ver a
+[referência de fontes](/reference/sources).
+
+## O nome do ficheiro transporta mais duas coisas
+
+- **Um prefixo numérico ordena a árvore** e é retirado do URL:
+ `3.guides/2.deploy.md` é servido em `/guides/deploy`. Reordenar nunca move um
+ URL.
+- **`.draft.md` é um rascunho.** É servido no servidor de desenvolvimento e não
+ existe na compilação. Não há `draft: true`, porque o conteúdo de uma coleção é
+ declarado antes de qualquer ficheiro ter sido lido — ver
+ [Coleções](/concepts/collections).
diff --git a/docs/pt/4.reference/4.mdc-components/1.callout.md b/docs/pt/4.reference/4.mdc-components/1.callout.md
new file mode 100644
index 00000000..7da65737
--- /dev/null
+++ b/docs/pt/4.reference/4.mdc-components/1.callout.md
@@ -0,0 +1,72 @@
+---
+title: Callout
+description: Um aparte em que o leitor deve parar — uma dica, um aviso, um perigo.
+icon: lucide:message-square-warning
+---
+
+Um aparte numa de quatro variantes. O corpo é Markdown, por isso ligações,
+`code` e listas funcionam todos lá dentro.
+
+## Utilização
+
+::preview
+:::callout{type="tip" title="Não é preciso mais nada"}
+O corpo também é Markdown — ligações, `code` e listas funcionam todos lá dentro.
+:::
+
+#code
+```mdc
+::callout{type="tip" title="Não é preciso mais nada"}
+O corpo também é Markdown — ligações, `code` e listas funcionam todos lá dentro.
+::
+```
+::
+
+## Tipos
+
+::preview
+:::callout{type="info"}
+`info` — o predefinido, para contexto que a página pressupõe.
+:::
+
+:::callout{type="tip"}
+`tip` — algo que torna a tarefa mais fácil.
+:::
+
+:::callout{type="warning"}
+`warning` — uma forma de fazer isto mal.
+:::
+
+:::callout{type="danger"}
+`danger` — perda de dados, ou uma alteração que não pode ser desfeita.
+:::
+
+#code
+```mdc
+::callout{type="info"}
+`info` — o predefinido, para contexto que a página pressupõe.
+::
+```
+::
+
+## API
+
+### Props
+
+| Prop | Tipo | Por omissão | Notas |
+| :------ | :------------------------------------ | :---------- | :------------------------------------------- |
+| `type` | `info` · `tip` · `warning` · `danger` | `info` | Escolhe a cor, o ícone e o papel |
+| `title` | `string` | — | Primeira linha a negrito; o corpo segue-se |
+| `icon` | `string` | por tipo | Qualquer nome Iconify, substituindo o predefinido |
+
+### Slots
+
+| Slot | O que leva dentro |
+| :-------- | :------------------- |
+| `default` | O corpo, em Markdown |
+
+## Notas
+
+Um callout `danger` transporta `role="alert"`; os outros não. Um alerta
+interrompe um leitor de ecrã a meio de uma frase, o que é certo para «isto apaga
+os teus dados» e errado para uma dica.
diff --git a/docs/pt/4.reference/4.mdc-components/10.package-managers.md b/docs/pt/4.reference/4.mdc-components/10.package-managers.md
new file mode 100644
index 00000000..6abb4eb5
--- /dev/null
+++ b/docs/pt/4.reference/4.mdc-components/10.package-managers.md
@@ -0,0 +1,44 @@
+---
+title: Gestores de pacotes
+description: Um comando, um separador por gestor de pacotes, memorizado de página em página.
+icon: lucide:package
+---
+
+Um comando apresentado para cada gestor de pacotes que o site oferece, com a
+escolha do leitor memorizada para todo o site.
+
+## Utilização
+
+Escreve o comando **sem** o gestor: o `install` do npm e a grafia de `dlx` de
+cada gestor são substituídos.
+
+::preview
+:::package-managers{command="add -D @kirchdev/duxt"}
+:::
+
+#code
+```mdc
+::package-managers{command="add -D @kirchdev/duxt"}
+::
+```
+::
+
+## API
+
+### Props
+
+| Prop | Tipo | Por omissão | Notas |
+| :--------- | :--------- | :--------------------- | :--------------------------------------- |
+| `command` | `string` | obrigatório | Sem o gestor — `add -D pkg` |
+| `managers` | `string[]` | `duxt.packageManagers` | Substitui a lista do site para um bloco |
+
+## Notas
+
+A escolha é um cookie, não armazenamento do navegador, e deliberadamente: o
+armazenamento é lido depois da hidratação, por isso o bloco desenharia um gestor
+e depois trocaria para outro à frente do leitor. É próprio do site, não
+identifica ninguém e transporta um valor que o leitor escolheu — ver
+[`usePackageManager()`](/reference/composables/reading/use-package-manager).
+
+Um bloco pode listar menos gestores do que a escolha do leitor abrange; recai no
+seu próprio primeiro separador em vez de não mostrar nada.
diff --git a/docs/pt/4.reference/4.mdc-components/11.file-tree.md b/docs/pt/4.reference/4.mdc-components/11.file-tree.md
new file mode 100644
index 00000000..eb5a8a3d
--- /dev/null
+++ b/docs/pt/4.reference/4.mdc-components/11.file-tree.md
@@ -0,0 +1,60 @@
+---
+title: Árvore de ficheiros
+description: Uma estrutura de pastas, com ícones por tipo de ficheiro.
+icon: lucide:folder-tree
+---
+
+Uma listagem de diretórios em árvore, com o ícone de cada tipo de ficheiro lido
+a partir da sua extensão.
+
+## Utilização
+
+::preview
+:::file-tree{title="o-teu-projeto/"}
+---
+tree:
+ - name: docs/
+ children:
+ - name: index.md
+ - name: 1.getting-started/
+ children:
+ - name: index.md
+ - name: 1.installation.md
+ - name: app/
+ children:
+ - name: app.config.ts
+ - name: nuxt.config.ts
+---
+:::
+
+#code
+```mdc
+::file-tree{title="o-teu-projeto/"}
+---
+tree:
+ - name: docs/
+ children:
+ - name: index.md
+ - name: nuxt.config.ts
+---
+::
+```
+::
+
+## API
+
+### Props
+
+| Prop | Tipo | Por omissão | Notas |
+| :------ | :-------- | :---------- | :--------------------------------- |
+| `tree` | `Entry[]` | — | Dado como YAML no corpo do bloco |
+| `title` | `string` | — | O rótulo de raiz por cima da árvore |
+
+Uma entrada é `{ name, children? }`. Um nome terminado em `/`, ou que transporte
+filhos, é um diretório.
+
+## Notas
+
+Assenta na árvore da reka-ui, por isso tem foco itinerante, navegação com as
+teclas de seta e os papéis ARIA. Os nós são indexados por caminho e não por
+nome: os nomes repetem-se entre ramos, os caminhos não.
diff --git a/docs/pt/4.reference/4.mdc-components/12.preview.md b/docs/pt/4.reference/4.mdc-components/12.preview.md
new file mode 100644
index 00000000..a58a3160
--- /dev/null
+++ b/docs/pt/4.reference/4.mdc-components/12.preview.md
@@ -0,0 +1,63 @@
+---
+title: Pré-visualização
+description: Um componente desenhado tal como a página o desenha, com o seu código-fonte ao lado.
+icon: lucide:eye
+---
+
+O exemplo é o componente real no tema real — os mesmos tokens, o mesmo modo
+escuro — por isso um leitor vê o que um bloco faz em vez de uma descrição dele.
+Esta página, e todas as outras páginas desta pasta, são feitas com ele.
+
+## Utilização
+
+::preview
+:::callout{type="tip"}
+Desenhado pela página que estás a ler.
+:::
+
+#code
+```mdc
+::callout{type="tip"}
+Desenhado pela página que estás a ler.
+::
+```
+::
+
+````mdc
+::preview
+:::callout{type="tip"}
+Desenhado pela página que estás a ler.
+:::
+
+#code
+```mdc
+::callout{type="tip"}
+Desenhado pela página que estás a ler.
+::
+```
+::
+````
+
+## API
+
+Sem props.
+
+### Slots
+
+| Slot | O que leva dentro |
+| :-------- | :------------------------------------------------------------------- |
+| `default` | O exemplo, desenhado |
+| `code` | O código-fonte, como bloco de código. Omite-o e não aparece nenhuma faixa de separadores |
+
+## Notas
+
+O código-fonte em `#code` é escrito à mão em vez de derivado do exemplo acima
+dele, e esse é o único custo real do bloco. Quando o componente corre, os seus
+filhos já são nós desenhados e o Markdown que os produziu desapareceu; voltar a
+serializá-los seria adivinhar uma sintaxe de onde nunca vieram.
+
+::callout{type="warning" title="Duas cópias divergem"}
+Nada verifica que o separador do código-fonte corresponde ao que é desenhado ao
+lado. Mantém o exemplo curto o suficiente para que mudar um sem o outro seja
+óbvio.
+::
diff --git a/docs/pt/4.reference/4.mdc-components/13.page-cards.md b/docs/pt/4.reference/4.mdc-components/13.page-cards.md
new file mode 100644
index 00000000..e97a74f2
--- /dev/null
+++ b/docs/pt/4.reference/4.mdc-components/13.page-cards.md
@@ -0,0 +1,31 @@
+---
+title: Cartões de página
+description: Os filhos de uma secção, como cartões.
+icon: lucide:layout-grid
+---
+
+Os filhos de uma secção, desenhados como cartões com o seu ícone, título e
+descrição — aquilo com que termina cada `index.md` desta documentação.
+
+## Utilização
+
+```mdc
+::page-cards
+::
+```
+
+Sem prop, lista os filhos da página atual, e é por isso que aqui aparece pelo
+nome em vez de apresentado: esta página não tem nenhum.
+
+## API
+
+### Props
+
+| Prop | Tipo | Por omissão | Notas |
+| :----- | :------- | :------------- | :------------------------------- |
+| `path` | `string` | a página atual | Listar os filhos de outra secção |
+
+## Notas
+
+O título, a descrição e o ícone vêm do frontmatter de cada filho, por isso um
+cartão não pode divergir da página para que aponta.
diff --git a/docs/pt/4.reference/4.mdc-components/14.partial.md b/docs/pt/4.reference/4.mdc-components/14.partial.md
new file mode 100644
index 00000000..e6435d3e
--- /dev/null
+++ b/docs/pt/4.reference/4.mdc-components/14.partial.md
@@ -0,0 +1,35 @@
+---
+title: Partial
+description: Um bloco escrito uma vez e apresentado em todas as fontes que dele precisam.
+icon: lucide:puzzle
+---
+
+Em linha, e o único componente que sai da fonte da própria página: `_partials/`
+em **qualquer** fonte alimenta uma coleção partilhada.
+
+## Utilização
+
+```mdc
+:partial{name="install"}
+```
+
+## API
+
+### Props
+
+| Prop | Tipo | Por omissão | Notas |
+| :----- | :------- | :---------- | :------------------------------------ |
+| `name` | `string` | — | O ficheiro em `_partials/`, sem `.md` |
+
+## Notas
+
+O Content não traz nenhuma diretiva de inclusão e, entre repositórios, isso é
+uma lacuna sem alternativa: uma nota de instalação que tem de dizer o mesmo em
+três projetos é copiada para três projetos e diverge. Um bloco definido num
+repositório pode ser chamado a partir das páginas de outro.
+
+Traduzido como uma página. Uma fonte que declara `locales` recebe uma coleção de
+fragmentos por idioma — `docs/de/_partials/install.md` é o `install` alemão — e
+o componente recorre exatamente como a página à sua volta, terminando no
+original por traduzir. Escreve apenas os idiomas que tens; os restantes
+resolvem-se para cima.
diff --git a/docs/pt/4.reference/4.mdc-components/15.since.md b/docs/pt/4.reference/4.mdc-components/15.since.md
new file mode 100644
index 00000000..fc1b83b7
--- /dev/null
+++ b/docs/pt/4.reference/4.mdc-components/15.since.md
@@ -0,0 +1,34 @@
+---
+title: Since
+description: A versão em que algo apareceu, ao lado de um cabeçalho.
+icon: lucide:milestone
+---
+
+Em linha. Marca a versão em que algo apareceu — utilizável dentro de um
+cabeçalho.
+
+## Utilização
+
+### Retries :since{version="v2.0.0"}
+
+```mdc
+### Retries :since{version="v2.0.0"}
+```
+
+## API
+
+### Props
+
+| Prop | Tipo | Por omissão | Notas |
+| :-------- | :------- | :---------- | :------------------------ |
+| `version` | `string` | — | Impresso tal como escrito |
+
+## Notas
+
+Assenta no modelo de versões em vez de o repetir: o que o distintivo diz é a
+versão em que algo APARECEU, o que é um facto sobre a API e não sobre a página
+onde está documentado — por isso mantém-se correto em todas as versões da
+documentação que transportem a frase.
+
+Para tudo o resto, `:badge[…]` chama diretamente o Badge do próprio tema; o MDC
+resolve pelo nome qualquer componente registado globalmente.
diff --git a/docs/pt/4.reference/4.mdc-components/16.mermaid.md b/docs/pt/4.reference/4.mdc-components/16.mermaid.md
new file mode 100644
index 00000000..6b2cac7c
--- /dev/null
+++ b/docs/pt/4.reference/4.mdc-components/16.mermaid.md
@@ -0,0 +1,46 @@
+---
+title: Mermaid
+description: Um diagrama a partir de um bloco de código ou de uma prop.
+icon: lucide:workflow
+---
+
+Um bloco ```` ```mermaid ```` é um diagrama; `::mermaid` com uma prop `code` é o
+mesmo componente.
+
+## Utilização
+
+```mermaid
+flowchart LR
+ A[app.config.ts] --> B[resolveSources]
+ B --> C[collections]
+ B --> D[resolvedSources]
+ C --> E[Content]
+ D --> F[theme]
+```
+
+`````md
+```mermaid
+flowchart LR
+ A[app.config.ts] --> B[resolveSources]
+ B --> C[collections]
+```
+`````
+
+## API
+
+### Props
+
+| Prop | Tipo | Por omissão | Notas |
+| :----- | :------- | :---------- | :------------------------------------------ |
+| `code` | `string` | — | O código do diagrama, quando não há um bloco |
+
+## Notas
+
+Carregado a pedido e só no navegador: o mermaid tem perto de meio megabyte e
+traz consigo o seu próprio parser, e um site onde três páginas em sessenta
+transportam um diagrama não pode obrigar as outras cinquenta e sete a pagar por
+isso.
+
+Isso também significa que o diagrama é apresentado no cliente — o resultado do
+servidor é o texto de origem, que é o recurso honesto para um leitor sem
+JavaScript.
diff --git a/docs/pt/4.reference/4.mdc-components/17.prose/1.prose-a.md b/docs/pt/4.reference/4.mdc-components/17.prose/1.prose-a.md
new file mode 100644
index 00000000..3a7c6bda
--- /dev/null
+++ b/docs/pt/4.reference/4.mdc-components/17.prose/1.prose-a.md
@@ -0,0 +1,36 @@
+---
+title: ProseA
+description: Uma ligação, resolvida em função da fonte em que foi escrita.
+icon: lucide:link
+---
+
+Todas as ligações de uma página Markdown passam por este componente.
+
+## O que acrescenta
+
+Um caminho da documentação é reescrito para levar o prefixo da fonte de onde a
+página veio, mais o locale do leitor. Um URL externo fica intocado e recebe o
+separador novo e a seta.
+
+## Porquê
+
+O Markdown não faz ideia de que está a ser servido sob um prefixo.
+`/getting-started` escrito no `docs/` deste repositório está correto num site com
+uma fonte e é um 404 num site com duas, onde a mesma página vive em
+`/duxt/getting-started` — e quem escreveu o Markdown, que pode estar num
+repositório completamente diferente, não tem como saber qual.
+
+Pior, o mesmo ficheiro é servido em vários prefixos ao mesmo tempo quando é
+publicado em mais do que uma versão: uma ligação não pode ser reescrita em tempo
+de compilação, porque não há uma única resposta certa. É antes resolvida a cada
+apresentação, em função da fonte a partir da qual a página está a ser lida — para
+que uma ligação dentro de `v0.7.0` mantenha o leitor em `v0.7.0`.
+
+## API
+
+### Props
+
+| Prop | Tipo | Notas |
+| :------- | :-------- | :--------------------------------------- |
+| `href` | `string` | Tal como escrito no Markdown |
+| `target` | `string` | Definido para uma ligação externa |
diff --git a/docs/pt/4.reference/4.mdc-components/17.prose/2.prose-headings.md b/docs/pt/4.reference/4.mdc-components/17.prose/2.prose-headings.md
new file mode 100644
index 00000000..8d498fe4
--- /dev/null
+++ b/docs/pt/4.reference/4.mdc-components/17.prose/2.prose-headings.md
@@ -0,0 +1,34 @@
+---
+title: ProseH2, ProseH3, ProseH4
+description: Cabeçalhos, cada um com a sua âncora permanente.
+icon: lucide:heading
+---
+
+Três componentes, um comportamento: `ProseH2`, `ProseH3` e `ProseH4` apresentam
+os cabeçalhos a partir dos quais se constrói o índice de uma página.
+
+## O que acrescentam
+
+Uma âncora permanente ao lado do cabeçalho, visível ao passar o rato e alcançável
+pelo teclado. O `id` é o próprio slug do Content, por isso a âncora, o índice e a
+navegação dentro da página concordam todos.
+
+## API
+
+### Props
+
+| Prop | Tipo | Notas |
+| :--- | :------- | :-------------------------------------- |
+| `id` | `string` | O slug que o Content gerou para o texto |
+
+### Slots
+
+| Slot | O que leva dentro |
+| :-------- | :------------------- |
+| `default` | O texto do cabeçalho |
+
+## Notas
+
+`ProseH1` não é substituído de propósito: o título da página é apresentado pela
+página, não pelo Markdown, por isso um cabeçalho `#` de Markdown dentro do corpo
+seria um segundo `h1` na página.
diff --git a/docs/pt/4.reference/4.mdc-components/17.prose/3.prose-img.md b/docs/pt/4.reference/4.mdc-components/17.prose/3.prose-img.md
new file mode 100644
index 00000000..6ec70fc8
--- /dev/null
+++ b/docs/pt/4.reference/4.mdc-components/17.prose/3.prose-img.md
@@ -0,0 +1,41 @@
+---
+title: ProseImg
+description: "Uma imagem: consciente do tema, e ampliável."
+icon: lucide:image
+---
+
+Todas as imagens de uma página Markdown.
+
+## O que acrescenta
+
+**Duas variantes.** A captura de um editor escuro numa página branca — ou o
+contrário — é a coisa feia mais comum na documentação, e o CSS não a resolve
+porque o que está errado são os píxeis, não a moldura. Uma página pode nomear um
+segundo ficheiro:
+
+```md
+{dark="/shots/sidebar-dark.png"}
+```
+
+**Clicar para ampliar**, a não ser que a imagem peça o contrário:
+
+```md
+{zoom="false"}
+```
+
+## API
+
+### Props
+
+| Prop | Tipo | Por omissão | Notas |
+| :----- | :-------- | :---------- | :--------------------------------------------- |
+| `src` | `string` | — | Tal como escrito |
+| `alt` | `string` | — | Tal como escrito |
+| `dark` | `string` | — | Um segundo ficheiro, servido sob o tema escuro |
+| `zoom` | `boolean` | `true` | `false` deixa a imagem inerte |
+
+## Notas
+
+A variante escura é nomeada explicitamente em vez de adivinhada por convenção.
+Derivar `-dark` de um nome de ficheiro faz com que um ficheiro em falta se torne
+uma imagem partida, e quem a escreveu nunca chega a saber.
diff --git a/docs/pt/4.reference/4.mdc-components/17.prose/4.prose-pre.md b/docs/pt/4.reference/4.mdc-components/17.prose/4.prose-pre.md
new file mode 100644
index 00000000..5aa1edd8
--- /dev/null
+++ b/docs/pt/4.reference/4.mdc-components/17.prose/4.prose-pre.md
@@ -0,0 +1,46 @@
+---
+title: ProsePre
+description: "O bloco de código: nome do ficheiro, ícone da linguagem, botão de copiar."
+icon: lucide:terminal
+---
+
+Todos os blocos de código delimitados do site, e o componente que transforma um
+bloco mermaid num diagrama.
+
+## O que acrescenta
+
+O cartão à volta do código — o nome do ficheiro, um ícone para a sua linguagem e
+um botão de copiar — mais números de linha opcionais.
+
+## Números de linha
+
+Ativados bloco a bloco, escritos no seu meta:
+
+`````md
+```ts line-numbers
+```
+`````
+
+Não estão ligados por omissão: a maioria dos excertos da documentação tem quatro
+linhas, e uma goteira de números ao lado deles é ruído. Os números são desenhados
+por um contador CSS, por isso um leitor que copie o bloco leva o código e não a
+numeração.
+
+## Anotações do Shiki
+
+Duas funcionam no próprio bloco, para além das quatro que o Content traz:
+
+| Escrito | Faz |
+| :--------------------- | :-------------------------- |
+| ```` ```ts {2,4-6} ```` | Realça essas linhas |
+| ```` ```ts /useDuxtConfig/ ```` | Realça essa palavra |
+
+## API
+
+### Props
+
+| Prop | Tipo | Notas |
+| :--------- | :------- | :--------------------------------------- |
+| `code` | `string` | O código em bruto, para o botão de copiar |
+| `language` | `string` | Do bloco |
+| `filename` | `string` | Do `[…]` do bloco |
diff --git a/docs/pt/4.reference/4.mdc-components/17.prose/5.prose-table.md b/docs/pt/4.reference/4.mdc-components/17.prose/5.prose-table.md
new file mode 100644
index 00000000..96434a6b
--- /dev/null
+++ b/docs/pt/4.reference/4.mdc-components/17.prose/5.prose-table.md
@@ -0,0 +1,21 @@
+---
+title: ProseTable
+description: Uma tabela que se desloca em vez da página.
+icon: lucide:table
+---
+
+Todas as tabelas de Markdown.
+
+## O que acrescenta
+
+Um contentor com deslocamento à volta da tabela, para que uma tabela larga se
+desloque dentro da sua própria caixa em vez de fazer a página inteira
+deslocar-se para o lado.
+
+## API
+
+### Slots
+
+| Slot | O que leva dentro |
+| :-------- | :---------------- |
+| `default` | A tabela |
diff --git a/docs/pt/4.reference/4.mdc-components/17.prose/index.md b/docs/pt/4.reference/4.mdc-components/17.prose/index.md
new file mode 100644
index 00000000..2311c159
--- /dev/null
+++ b/docs/pt/4.reference/4.mdc-components/17.prose/index.md
@@ -0,0 +1,16 @@
+---
+title: Substituições de prosa
+description: O que a camada muda no Markdown simples — ligações, cabeçalhos, imagens, código e tabelas.
+icon: lucide:type
+---
+
+Estes componentes nunca são invocados pelo nome. O Content apresenta Markdown
+simples através deles, por isso substituir um muda o aspeto de um parágrafo, de
+uma ligação ou de um bloco de código vulgares em todas as páginas.
+
+Estão listados aqui pela mesma razão que os blocos ao seu lado: um nome
+documentado faz parte da superfície pública da camada, e o teu próprio ficheiro
+com o mesmo nome substitui o nosso.
+
+::page-cards
+::
diff --git a/docs/pt/4.reference/4.mdc-components/2.steps.md b/docs/pt/4.reference/4.mdc-components/2.steps.md
new file mode 100644
index 00000000..c8453549
--- /dev/null
+++ b/docs/pt/4.reference/4.mdc-components/2.steps.md
@@ -0,0 +1,47 @@
+---
+title: Passos
+description: Uma lista ordenada desenhada como passos numerados sobre uma calha.
+icon: lucide:list-ordered
+---
+
+Envolve uma **lista ordenada** num layout de passos. Numera a lista que
+envolver, seja ela qual for, por isso o Markdown continua a ser uma lista
+vulgar.
+
+## Utilização
+
+::preview
+:::steps
+1. Adiciona a dependência.
+2. Estende a camada.
+3. Escreve Markdown.
+:::
+
+#code
+```mdc
+::steps
+1. Adiciona a dependência.
+2. Estende a camada.
+3. Escreve Markdown.
+::
+```
+::
+
+## API
+
+Sem props. O bloco estiliza o seu `` filho.
+
+### Slots
+
+| Slot | O que leva dentro |
+| :-------- | :------------------------------------ |
+| `default` | Uma lista ordenada, itens em Markdown |
+
+## Notas
+
+Vale a pena recorrer a ele assim que um passo transporta um bloco de código:
+numa lista ordenada simples o bloco afasta o texto do seu próprio número, e este
+layout mantém o número preso ao lado dele.
+
+Os marcadores são um contador CSS nos itens da lista, não conteúdo — um leitor
+que copie os passos fica com o texto sem a numeração desenhada duas vezes.
diff --git a/docs/pt/4.reference/4.mdc-components/3.code-group.md b/docs/pt/4.reference/4.mdc-components/3.code-group.md
new file mode 100644
index 00000000..d305c57c
--- /dev/null
+++ b/docs/pt/4.reference/4.mdc-components/3.code-group.md
@@ -0,0 +1,66 @@
+---
+title: Grupo de código
+description: Vários blocos de código como um só bloco com separadores, rotulados pelo nome do ficheiro.
+icon: lucide:file-code
+---
+
+Vários blocos de código apresentados como um só bloco, com um separador cada. O
+rótulo de cada separador é o nome de ficheiro do próprio bloco, porque é entre
+isso que o leitor está a escolher e já está escrito.
+
+## Utilização
+
+::preview
+:::code-group
+```ts [nuxt.config.ts]
+export default defineNuxtConfig({
+ extends: ['@kirchdev/duxt']
+});
+```
+
+```json [package.json]
+{
+ "devDependencies": {
+ "@kirchdev/duxt": "latest"
+ }
+}
+```
+:::
+
+#code
+`````mdc
+::code-group
+```ts [nuxt.config.ts]
+export default defineNuxtConfig({
+ extends: ['@kirchdev/duxt']
+});
+```
+
+```json [package.json]
+{
+ "devDependencies": { "@kirchdev/duxt": "latest" }
+}
+```
+::
+`````
+::
+
+## API
+
+Sem props. Um bloco sem nome de ficheiro fica com a sua linguagem como rótulo.
+
+### Slots
+
+| Slot | O que leva dentro |
+| :-------- | :-------------------------------- |
+| `default` | Uma sequência de blocos de código |
+
+## Notas
+
+Os filhos são lidos a partir dos VNodes do slot `default` em vez de serem
+registados por um componente filho: um bloco de código é um `ProsePre`, não algo
+a quem este bloco possa pedir que se anuncie, e o seu nome de ficheiro é uma
+prop nesse nó.
+
+Para tudo o que não seja código, usa
+[Grupo de separadores](/reference/mdc-components/tab-group).
diff --git a/docs/pt/4.reference/4.mdc-components/4.tab-group.md b/docs/pt/4.reference/4.mdc-components/4.tab-group.md
new file mode 100644
index 00000000..82e13963
--- /dev/null
+++ b/docs/pt/4.reference/4.mdc-components/4.tab-group.md
@@ -0,0 +1,58 @@
+---
+title: Grupo de separadores
+description: Separadores para tudo o que não seja código.
+icon: lucide:panels-top-left
+---
+
+`::tab-group` à volta de blocos [`::tab`](/reference/mdc-components/tab) — um
+painel por separador, navegável pelo teclado.
+
+## Utilização
+
+::preview
+::::tab-group
+:::tab{label="macOS"}
+Tudo o que a camada precisa vem com o Node.
+:::
+
+:::tab{label="Windows"}
+O mesmo, e nada na compilação é específico da plataforma.
+:::
+::::
+
+#code
+```mdc
+:::tab-group
+ ::tab{label="macOS"}
+ Tudo o que a camada precisa vem com o Node.
+ ::
+
+ ::tab{label="Windows"}
+ O mesmo, e nada na compilação é específico da plataforma.
+ ::
+:::
+```
+::
+
+## API
+
+Sem props. Os rótulos vêm dos filhos.
+
+### Slots
+
+| Slot | O que leva dentro |
+| :-------- | :---------------- |
+| `default` | Blocos `::tab` |
+
+## Notas
+
+**Não `::tabs`.** O kit de UI do tema já regista um `Tabs` — o do shadcn — e dois
+componentes com o mesmo nome não são uma substituição que a compilação resolva a
+teu favor: o Nuxt fica com um deles, e ficou com o do shadcn, por isso `::tabs`
+renderizava uma primitiva sem gatilhos e sem estado. Um componente de conteúdo
+não pode tomar um nome que o kit de UI possui.
+
+Assente nas primitivas do reka-ui e não no invólucro do shadcn, porque os rótulos
+só são conhecidos depois de os filhos se registarem: o MDC entrega-os como
+conteúdo de slot, não como prop. A navegação por teclado, o foco itinerante e os
+papéis ARIA vêm com elas.
diff --git a/docs/pt/4.reference/4.mdc-components/5.tab.md b/docs/pt/4.reference/4.mdc-components/5.tab.md
new file mode 100644
index 00000000..33897a27
--- /dev/null
+++ b/docs/pt/4.reference/4.mdc-components/5.tab.md
@@ -0,0 +1,38 @@
+---
+title: Tab
+description: Um painel de um grupo de separadores.
+icon: lucide:square
+---
+
+Um painel dentro de [`::tab-group`](/reference/mdc-components/tab-group). Não tem
+significado sozinho.
+
+## Utilização
+
+```mdc
+:::tab-group
+ ::tab{label="pnpm"}
+ A camada é uma dependência de desenvolvimento.
+ ::
+:::
+```
+
+## API
+
+### Props
+
+| Prop | Tipo | Por omissão | Notas |
+| :------ | :------- | :---------- | :------------------------------ |
+| `label` | `string` | — | O texto no gatilho do separador |
+
+### Slots
+
+| Slot | O que leva dentro |
+| :-------- | :-------------------- |
+| `default` | O painel, em Markdown |
+
+## Notas
+
+O componente regista o seu próprio rótulo no pai durante o setup, e é isso que
+permite construir a lista de gatilhos a partir de filhos que o MDC ainda não
+renderizou.
diff --git a/docs/pt/4.reference/4.mdc-components/6.accordion.md b/docs/pt/4.reference/4.mdc-components/6.accordion.md
new file mode 100644
index 00000000..e53d8e84
--- /dev/null
+++ b/docs/pt/4.reference/4.mdc-components/6.accordion.md
@@ -0,0 +1,52 @@
+---
+title: Accordion
+description: Secções recolhíveis — um FAQ, ou respostas que o leitor escolhe.
+icon: lucide:chevrons-up-down
+---
+
+`::accordion` à volta de blocos
+[`::accordion-item`](/reference/mdc-components/accordion-item).
+
+## Utilização
+
+::preview
+::::accordion
+:::accordion-item{label="Quem a consome escreve content.config.ts?" icon="lucide:file-question"}
+Não. `duxt.sources` em `app.config.ts` é a única declaração; o
+`content.config.ts` da própria camada lê-a.
+:::
+
+:::accordion-item{label="É preciso um driver SQLite nativo?" icon="lucide:database"}
+Não. A base de dados é lida através de `node:sqlite`, e é por isso que a
+compilação precisa do Node 24.
+:::
+::::
+
+#code
+```mdc
+:::accordion
+ ::accordion-item{label="Quem a consome escreve content.config.ts?" icon="lucide:file-question"}
+ Não. `duxt.sources` em `app.config.ts` é a única declaração.
+ ::
+:::
+```
+::
+
+## API
+
+Sem props.
+
+### Slots
+
+| Slot | O que leva dentro |
+| :-------- | :------------------------ |
+| `default` | Blocos `::accordion-item` |
+
+## Notas
+
+`type="multiple"` de propósito: um FAQ onde abrir uma resposta fecha aquela que
+estavas a meio de ler é um FAQ pior.
+
+Assente nas primitivas do reka-ui, como a árvore e os separadores, pela mesma
+razão — um componente de revelação feito à mão não tem tratamento de teclado,
+não tem `aria-expanded` e não tem relação entre o gatilho e a região que abre.
diff --git a/docs/pt/4.reference/4.mdc-components/7.accordion-item.md b/docs/pt/4.reference/4.mdc-components/7.accordion-item.md
new file mode 100644
index 00000000..616219a2
--- /dev/null
+++ b/docs/pt/4.reference/4.mdc-components/7.accordion-item.md
@@ -0,0 +1,32 @@
+---
+title: Item de accordion
+description: Uma secção recolhível.
+icon: lucide:chevron-down
+---
+
+Uma secção dentro de [`::accordion`](/reference/mdc-components/accordion).
+
+## Utilização
+
+```mdc
+:::accordion
+ ::accordion-item{label="Porquê uma camada e não um módulo?" icon="lucide:layers"}
+ Um módulo não pode trazer páginas, um tema ou um valor por omissão em app.config.
+ ::
+:::
+```
+
+## API
+
+### Props
+
+| Prop | Tipo | Por omissão | Notas |
+| :------ | :------- | :---------- | :------------------------------------- |
+| `label` | `string` | — | O texto do gatilho |
+| `icon` | `string` | — | Qualquer nome Iconify, antes do rótulo |
+
+### Slots
+
+| Slot | O que leva dentro |
+| :-------- | :---------------------- |
+| `default` | A resposta, em Markdown |
diff --git a/docs/pt/4.reference/4.mdc-components/8.field-group.md b/docs/pt/4.reference/4.mdc-components/8.field-group.md
new file mode 100644
index 00000000..73816879
--- /dev/null
+++ b/docs/pt/4.reference/4.mdc-components/8.field-group.md
@@ -0,0 +1,41 @@
+---
+title: Grupo de campos
+description: Uma lista de campos como um só bloco.
+icon: lucide:list-tree
+---
+
+Agrupa blocos [`::field`](/reference/mdc-components/field) para que se leiam como
+uma só lista com separadores em vez de cartões soltos.
+
+## Utilização
+
+::preview
+::::field-group
+:::field{name="path" type="string" required}
+Pasta com o Markdown, relativa à raiz do repositório.
+:::
+
+:::field{name="history" type="boolean" default="false"}
+Lê o histórico git desta fonte para a «Última atualização» e os contribuidores.
+:::
+::::
+
+#code
+```mdc
+:::field-group
+ ::field{name="path" type="string" required}
+ Pasta com o Markdown, relativa à raiz do repositório.
+ ::
+:::
+```
+::
+
+## API
+
+Sem props.
+
+### Slots
+
+| Slot | O que leva dentro |
+| :-------- | :---------------- |
+| `default` | Blocos `::field` |
diff --git a/docs/pt/4.reference/4.mdc-components/9.field.md b/docs/pt/4.reference/4.mdc-components/9.field.md
new file mode 100644
index 00000000..521f2052
--- /dev/null
+++ b/docs/pt/4.reference/4.mdc-components/9.field.md
@@ -0,0 +1,50 @@
+---
+title: Campo
+description: Um parâmetro, propriedade ou opção, com o seu tipo e valor por omissão.
+icon: lucide:tag
+---
+
+O componente mais usado que falta a um tema de documentação: um parâmetro de
+API, com mais explicação do que aquela que cabe numa linha de tabela.
+
+## Utilização
+
+::preview
+:::field{name="showRepo" type="boolean" default="false"}
+Força um segmento de repositório mesmo com um só repositório.
+:::
+
+#code
+```mdc
+::field{name="showRepo" type="boolean" default="false"}
+Força um segmento de repositório mesmo com um só repositório.
+::
+```
+::
+
+## API
+
+### Props
+
+| Prop | Tipo | Por omissão | Notas |
+| :--------- | :-------- | :---------- | :--------------------------------------------------- |
+| `name` | `string` | — | O nome do parâmetro, em monoespaçado |
+| `type` | `string` | — | Desenhado ao lado dele, em monoespaçado |
+| `default` | `string` | — | Alinhado à direita, como `= value` |
+| `required` | `boolean` | `false` | Desenha um distintivo em vez de um valor por omissão |
+
+### Slots
+
+| Slot | O que leva dentro |
+| :-------- | :----------------------- |
+| `default` | A descrição, em Markdown |
+
+## Notas
+
+Uma lista de definições seria a escolha semântica e é a errada aqui: o nome, o
+tipo, o valor por omissão e se é obrigatório são quatro coisas diferentes, e
+`` dá-lhes um só slot para todas.
+
+Uma tabela continua a ser a melhor escolha para uma lista longa de campos de uma
+linha — ver [Fontes](/reference/sources), onde oito campos são para comparar de
+relance em vez de lidos um a um.
diff --git a/docs/pt/4.reference/4.mdc-components/index.md b/docs/pt/4.reference/4.mdc-components/index.md
new file mode 100644
index 00000000..9aa904fe
--- /dev/null
+++ b/docs/pt/4.reference/4.mdc-components/index.md
@@ -0,0 +1,22 @@
+---
+title: Componentes MDC
+description: Os blocos que uma página Markdown pode invocar, uma página cada, com as suas props.
+icon: lucide:blocks
+---
+
+Tudo o que está em `app/components/content/` pode ser invocado a partir do
+Markdown com sintaxe MDC — sem módulo, sem MDX. Estes vêm com a camada; um
+ficheiro teu com o mesmo nome substitui um deles.
+
+Todas as páginas aqui são feitas da mesma maneira: o que o bloco é, um exemplo
+apresentado tal como este site o apresenta com o Markdown que o produziu ao
+lado, e depois as suas props, slots e eventos.
+
+::page-cards
+::
+
+::callout{type="tip" title="Aninhar leva mais um dois-pontos"}
+Um bloco dentro de um bloco abre com mais um dois-pontos do que o seu pai —
+`:::accordion` à volta de `::accordion-item`. O delimitador de fecho corresponde
+ao de abertura.
+::
diff --git a/docs/pt/4.reference/5.components/1.layout/1.duxt-header.md b/docs/pt/4.reference/5.components/1.layout/1.duxt-header.md
new file mode 100644
index 00000000..afc095a8
--- /dev/null
+++ b/docs/pt/4.reference/5.components/1.layout/1.duxt-header.md
@@ -0,0 +1,28 @@
+---
+title: DuxtHeader
+description: "A barra de navegação: título, navegação, pesquisa e as ligações de ícone."
+icon: lucide:panel-top
+---
+
+A barra no topo de todas as páginas — o título do site, as entradas de
+navegação, a pesquisa, os seletores de versão e de locale, e o alternador de
+tema.
+
+## Utilização
+
+```vue
+
+```
+
+## API
+
+Sem props. Tudo vem da configuração: `title`, `navigation`, `links`, `version` e
+`locales`.
+
+## Notas
+
+Uma entrada sem `to` nem `children` significa «a documentação». A entrada «Docs»
+da própria camada vem sem `to` de propósito: onde vive a documentação depende
+dos prefixos de quem a consome, e isso a camada não pode saber. Resolve para a
+primeira secção, e é isso que faz a barra de navegação por omissão funcionar
+tanto num site com versões como num site plano.
diff --git a/docs/pt/4.reference/5.components/1.layout/2.duxt-footer.md b/docs/pt/4.reference/5.components/1.layout/2.duxt-footer.md
new file mode 100644
index 00000000..19746556
--- /dev/null
+++ b/docs/pt/4.reference/5.components/1.layout/2.duxt-footer.md
@@ -0,0 +1,29 @@
+---
+title: DuxtFooter
+description: "O rodapé: o nome do site, as suas ligações legais e a linha da própria camada."
+icon: lucide:panel-bottom
+---
+
+Uma linha, e apenas o que um rodapé de página deve: de quem é este site e as
+ligações que a lei pede. A navegação vive no cabeçalho e na barra lateral.
+
+## Utilização
+
+```vue
+
+```
+
+## API
+
+Sem props. Lê `footer.copyright`, `footer.legal` e `poweredBy`.
+
+## Notas
+
+A linha «Powered by duxt» lê a sua versão e o seu repositório do `package.json`
+do próprio duxt em tempo de compilação — o release-please atualiza esse ficheiro,
+e uma segunda cópia do número é uma cópia errada a partir do primeiro lançamento.
+
+É desligável, porque uma camada que não pode ser mandada parar de se nomear é
+adware: `poweredBy: false` retira-a.
+
+A linha legal é entregue **vazia**. Um aviso legal nunca cabe ao modelo declarar.
diff --git a/docs/pt/4.reference/5.components/1.layout/index.md b/docs/pt/4.reference/5.components/1.layout/index.md
new file mode 100644
index 00000000..d38e1afc
--- /dev/null
+++ b/docs/pt/4.reference/5.components/1.layout/index.md
@@ -0,0 +1,17 @@
+---
+title: Layout
+description: Os dois componentes que emolduram uma página — a barra de navegação e o rodapé.
+icon: lucide:layout-panel-top
+---
+
+O que rodeia a documentação em todas as páginas. Nenhum recebe uma prop: leem
+[`useDuxtConfig()`](/reference/composables/config-and-content/use-duxt-config),
+por isso o que desenham é o que a configuração diz.
+
+A linha de secções por baixo da barra de navegação é
+[`DuxtSections`](/reference/components/navigation/duxt-sections), arquivada com a
+navegação — nomeia as partes da árvore de documentação, o que é navegar, não
+emoldurar.
+
+::page-cards
+::
diff --git a/docs/pt/4.reference/5.components/2.navigation/1.duxt-sections.md b/docs/pt/4.reference/5.components/2.navigation/1.duxt-sections.md
new file mode 100644
index 00000000..6b811be3
--- /dev/null
+++ b/docs/pt/4.reference/5.components/2.navigation/1.duxt-sections.md
@@ -0,0 +1,26 @@
+---
+title: DuxtSections
+description: A segunda linha da barra de navegação — as partes de topo da documentação.
+icon: lucide:rows-3
+---
+
+A linha de secções por baixo do cabeçalho, apenas nas páginas de documentação.
+
+## Utilização
+
+```vue
+
+```
+
+## API
+
+Sem props. Lê `sections`; não apresenta nada quando essa lista está vazia.
+
+## Notas
+
+Fica com a documentação em vez de ficar no cabeçalho global, porque a página de
+entrada não tem secções para mostrar.
+
+Uma secção raramente é a própria página — é o ramo em que a página está. Por
+isso a correspondência exata anuncia `aria-current="page"` e um ascendente
+anuncia `true`, que é a distinção para a qual `aria-current` existe.
diff --git a/docs/pt/4.reference/5.components/2.navigation/2.duxt-navigation.md b/docs/pt/4.reference/5.components/2.navigation/2.duxt-navigation.md
new file mode 100644
index 00000000..b01347b2
--- /dev/null
+++ b/docs/pt/4.reference/5.components/2.navigation/2.duxt-navigation.md
@@ -0,0 +1,34 @@
+---
+title: DuxtNavigation
+description: A árvore da barra lateral.
+icon: lucide:list-tree
+---
+
+A lista aninhada de páginas da secção atual.
+
+## Utilização
+
+```vue
+
+
+
+
+
+```
+
+## API
+
+### Props
+
+| Prop | Tipo | Notas |
+| :------ | :------------------------- | :------------------------------------------ |
+| `items` | `ContentNavigationItem[]` | Obrigatório — um ramo da árvore do Content |
+
+## Notas
+
+A árvore vem de [`useDuxtNavigation()`](/reference/composables/config-and-content/use-duxt-navigation),
+que pergunta à coleção que serve a rota atual — por isso um site versionado
+obtém a árvore dessa versão, e não a da versão por omissão.
diff --git a/docs/pt/4.reference/5.components/2.navigation/3.duxt-navigation-link.md b/docs/pt/4.reference/5.components/2.navigation/3.duxt-navigation-link.md
new file mode 100644
index 00000000..a74d7e6f
--- /dev/null
+++ b/docs/pt/4.reference/5.components/2.navigation/3.duxt-navigation-link.md
@@ -0,0 +1,28 @@
+---
+title: DuxtNavigationLink
+description: Uma entrada da barra lateral.
+icon: lucide:link-2
+---
+
+Uma única entrada, com o seu ícone, o seu estado ativo e — se for um ramo — os
+seus filhos.
+
+## Utilização
+
+```vue
+
+```
+
+## API
+
+### Props
+
+| Prop | Tipo | Notas |
+| :----- | :---------------------- | :---------------------------- |
+| `item` | `ContentNavigationItem` | Obrigatório — um nó da árvore |
+
+## Notas
+
+Vale a pena substituir por si só quando só o aspeto de uma entrada está errado:
+sombreá-lo deixa a árvore, o estado ativo e o recolher a cargo de
+[`DuxtNavigation`](/reference/components/navigation/duxt-navigation).
diff --git a/docs/pt/4.reference/5.components/2.navigation/4.duxt-breadcrumb.md b/docs/pt/4.reference/5.components/2.navigation/4.duxt-breadcrumb.md
new file mode 100644
index 00000000..c3ec81c0
--- /dev/null
+++ b/docs/pt/4.reference/5.components/2.navigation/4.duxt-breadcrumb.md
@@ -0,0 +1,26 @@
+---
+title: DuxtBreadcrumb
+description: O trilho por cima do título da página.
+icon: lucide:chevrons-right
+---
+
+A secção, e depois quaisquer grupos entre ela e a página.
+
+## Utilização
+
+```vue
+
+```
+
+## API
+
+### Props
+
+| Prop | Tipo | Notas |
+| :----- | :------- | :--------------------------------------------------- |
+| `path` | `string` | Obrigatório — o caminho da documentação, sem prefixo |
+
+## Notas
+
+`breadcrumb: false` na configuração retira-o em todo o site: uma árvore de
+documentação plana ganha um trilho que só repete o nome da sua própria secção.
diff --git a/docs/pt/4.reference/5.components/2.navigation/5.duxt-toc.md b/docs/pt/4.reference/5.components/2.navigation/5.duxt-toc.md
new file mode 100644
index 00000000..191c9e38
--- /dev/null
+++ b/docs/pt/4.reference/5.components/2.navigation/5.duxt-toc.md
@@ -0,0 +1,32 @@
+---
+title: DuxtToc
+description: O índice, e o aside por baixo dele.
+icon: lucide:list
+---
+
+Os cabeçalhos da página atual, acompanhando o leitor à medida que este desloca
+a página, mais as ligações de `aside` configuradas por baixo.
+
+## Utilização
+
+```vue
+
+```
+
+## API
+
+### Props
+
+| Prop | Tipo | Notas |
+| :------ | :---------- | :------------------------------------------------ |
+| `links` | `TocLink[]` | Obrigatório — as ligações de toc do próprio Content |
+
+## Notas
+
+Qual é o cabeçalho atual vem de
+[`useActiveHeading()`](/reference/composables/reading/use-active-heading), que
+observa os cabeçalhos em vez do deslocamento — uma última secção curta continua
+a ser dada como alcançada.
+
+O bloco `aside.links` por baixo dele é entregue **vazio**: uma ligação para um
+repositório, um gestor de issues ou uma comunidade pertencem a quem gere o site.
diff --git a/docs/pt/4.reference/5.components/2.navigation/6.duxt-page-nav.md b/docs/pt/4.reference/5.components/2.navigation/6.duxt-page-nav.md
new file mode 100644
index 00000000..aadc33ee
--- /dev/null
+++ b/docs/pt/4.reference/5.components/2.navigation/6.duxt-page-nav.md
@@ -0,0 +1,30 @@
+---
+title: DuxtPageNav
+description: Anterior e seguinte, limitados pela secção.
+icon: lucide:arrow-left-right
+---
+
+As duas ligações no fim de uma página.
+
+## Utilização
+
+```vue
+
+```
+
+## API
+
+### Props
+
+| Prop | Tipo | Notas |
+| :----- | :------- | :--------------------------------------------------- |
+| `path` | `string` | Obrigatório — o caminho da documentação, sem prefixo |
+
+## Notas
+
+Para na fronteira da secção de propósito: passar para outra parte da
+documentação é trabalho da linha das secções, não de uma ligação que diz
+«seguinte».
+
+Os grupos são achatados nos seus filhos, por isso «seguinte» é a página que um
+leitor leria a seguir, e não a pasta seguinte.
diff --git a/docs/pt/4.reference/5.components/2.navigation/index.md b/docs/pt/4.reference/5.components/2.navigation/index.md
new file mode 100644
index 00000000..88266750
--- /dev/null
+++ b/docs/pt/4.reference/5.components/2.navigation/index.md
@@ -0,0 +1,15 @@
+---
+title: Navegação
+description: Circular pela documentação — a linha de secções, a barra lateral, o trilho, o índice e anterior/seguinte.
+icon: lucide:compass
+---
+
+Seis componentes sobre uma árvore. A linha de secções nomeia as suas partes de
+topo, a barra lateral percorre o ramo em que o leitor está, e os últimos três
+respondem a «onde estou» e «o que vem a seguir».
+
+A maioria recebe uma prop, porque cada um desenha uma parte específica da
+árvore em vez da configuração inteira.
+
+::page-cards
+::
diff --git a/docs/pt/4.reference/5.components/3.controls/1.duxt-search.md b/docs/pt/4.reference/5.components/3.controls/1.duxt-search.md
new file mode 100644
index 00000000..4e2df84a
--- /dev/null
+++ b/docs/pt/4.reference/5.components/3.controls/1.duxt-search.md
@@ -0,0 +1,27 @@
+---
+title: DuxtSearch
+description: Pesquisa em todas as fontes, ordenada em conjunto.
+icon: lucide:search
+---
+
+O campo de pesquisa e a sua caixa de diálogo, aberta pelo botão ou por
+⌘K .
+
+## Utilização
+
+```vue
+
+```
+
+## API
+
+Sem props. Assenta em
+[`useDuxtSearch()`](/reference/composables/reading/use-duxt-search).
+
+## Notas
+
+Os resultados abrangem todas as fontes e são ordenados em conjunto, por isso um
+termo que apareça em dois repositórios é respondido a partir de ambos. Quando
+nada corresponde exatamente, a lista recai nas aproximações de
+[`useFuzzySearch()`](/reference/composables/reading/use-fuzzy-search) e di-lo —
+uma gralha deve devolver a página, não uma caixa vazia.
diff --git a/docs/pt/4.reference/5.components/3.controls/2.duxt-version.md b/docs/pt/4.reference/5.components/3.controls/2.duxt-version.md
new file mode 100644
index 00000000..dc5e0af8
--- /dev/null
+++ b/docs/pt/4.reference/5.components/3.controls/2.duxt-version.md
@@ -0,0 +1,32 @@
+---
+title: DuxtVersion
+description: O distintivo de versão, e o seletor quando há mais do que uma.
+icon: lucide:layers
+---
+
+Um elemento ao lado do título: um distintivo quando não há nada a escolher, o
+mesmo distintivo como gatilho de um menu pendente quando há.
+
+## Utilização
+
+```vue
+
+```
+
+## API
+
+Sem props. Lê `versions`, ou o manifesto de fontes resolvido quando essa está
+por definir.
+
+## Notas
+
+Antes, a versão do projeto ficava como distintivo ao lado do título enquanto as
+versões da documentação viviam num menu pendente à parte, lá na direita — dois
+controlos a dizer coisas diferentes, e o da direita só aparecia nas fontes que
+têm versões, por isso os ícones ao seu lado deslocavam-se à medida que se
+passava de um repositório para outro.
+
+Mudar de versão mantém a página: troca-se o prefixo em vez de se enviar o leitor
+para a raiz da versão. Cada entrada transporta o seu ciclo de vida, porque uma
+lista em que três entradas parecem iguais nada diz sobre qual delas ainda é
+segura de ler.
diff --git a/docs/pt/4.reference/5.components/3.controls/3.duxt-version-banner.md b/docs/pt/4.reference/5.components/3.controls/3.duxt-version-banner.md
new file mode 100644
index 00000000..b5f32ce7
--- /dev/null
+++ b/docs/pt/4.reference/5.components/3.controls/3.duxt-version-banner.md
@@ -0,0 +1,32 @@
+---
+title: DuxtVersionBanner
+description: A barra que diz ao leitor que esta versão não é a atual.
+icon: lucide:triangle-alert
+---
+
+Mostrada por cima da página quando a versão que está a ser lida não é a
+predefinida.
+
+## Utilização
+
+```vue
+
+```
+
+## API
+
+Sem props. Lê
+[`useDuxtVersion()`](/reference/composables/paths-and-versions/use-duxt-version).
+
+## Notas
+
+A metade visível do problema das versões. `canonical` e `noindex` dizem a um
+rastreador que versão servir; ao leitor não dizem nada. Quem chegou à v0.7.0 a
+partir de um resultado de pesquisa, ou de uma ligação num issue antigo, vê
+documentação com exatamente o mesmo aspeto da atual. Qualquer uma das metades
+sozinha deixa um público enganado, por isso as etiquetas do `head` e este aviso
+são entregues juntos.
+
+A uma versão `upcoming` diz-se que a sua documentação ainda pode mudar, e
+**não** se lhe diz para atualizar — esse é o único estado que nunca pode ser
+alcançado por adivinhação.
diff --git a/docs/pt/4.reference/5.components/3.controls/4.duxt-locale.md b/docs/pt/4.reference/5.components/3.controls/4.duxt-locale.md
new file mode 100644
index 00000000..bad1ae57
--- /dev/null
+++ b/docs/pt/4.reference/5.components/3.controls/4.duxt-locale.md
@@ -0,0 +1,28 @@
+---
+title: DuxtLocale
+description: O seletor de idioma.
+icon: lucide:languages
+---
+
+Absolutamente nada quando o site serve um idioma, um menu pendente quando serve
+vários.
+
+## Utilização
+
+```vue
+
+```
+
+## API
+
+Sem props. Lê `locales`.
+
+## Notas
+
+Construído como o [`DuxtVersion`](/reference/components/controls/duxt-version)
+ao seu lado, para que quem a consome com um só locale nunca veja que o duxt fala
+sete idiomas.
+
+Usa `switchLocalePath` em vez de uma ligação para a raiz do locale: mudar de
+idioma numa página sobre implementação deve chegar a essa mesma página no novo
+idioma, e não atirar o leitor de volta para a porta de entrada.
diff --git a/docs/pt/4.reference/5.components/3.controls/5.duxt-progress.md b/docs/pt/4.reference/5.components/3.controls/5.duxt-progress.md
new file mode 100644
index 00000000..31af82d3
--- /dev/null
+++ b/docs/pt/4.reference/5.components/3.controls/5.duxt-progress.md
@@ -0,0 +1,27 @@
+---
+title: DuxtProgress
+description: Até onde o leitor já desceu na página.
+icon: lucide:loader
+---
+
+Um filete de dois píxeis por baixo do cabeçalho.
+
+## Utilização
+
+```vue
+
+```
+
+## API
+
+Sem props.
+
+## Notas
+
+Um filete em vez de um número: numa página de referência longa a pergunta útil é
+«estou perto do fim», e uma percentagem responde-lhe pior do que uma linha. Leva
+`aria-hidden`, porque não diz nada que um leitor de ecrã não possa já perguntar
+ao documento.
+
+Medido contra o documento e não contra um contentor de deslocamento, por isso
+mantém-se correto tanto no layout da documentação como na página de entrada.
diff --git a/docs/pt/4.reference/5.components/3.controls/6.duxt-shortcuts.md b/docs/pt/4.reference/5.components/3.controls/6.duxt-shortcuts.md
new file mode 100644
index 00000000..fd69f6cc
--- /dev/null
+++ b/docs/pt/4.reference/5.components/3.controls/6.duxt-shortcuts.md
@@ -0,0 +1,25 @@
+---
+title: DuxtShortcuts
+description: A folha de atalhos de teclado, na tecla ?.
+icon: lucide:keyboard
+---
+
+A sobreposição que lista todas as teclas que o tema associa, aberta com
+? .
+
+## Utilização
+
+```vue
+
+```
+
+## API
+
+Sem props.
+
+## Notas
+
+A sua lista é `duxtShortcuts` — o mesmo array com que os handlers fazem a
+correspondência — por isso a folha não pode acabar a anunciar uma tecla que nada
+associa. Ver
+[`useDuxtShortcuts()`](/reference/composables/reading/use-duxt-shortcuts).
diff --git a/docs/pt/4.reference/5.components/3.controls/7.duxt-skip-link.md b/docs/pt/4.reference/5.components/3.controls/7.duxt-skip-link.md
new file mode 100644
index 00000000..d235dc8f
--- /dev/null
+++ b/docs/pt/4.reference/5.components/3.controls/7.duxt-skip-link.md
@@ -0,0 +1,27 @@
+---
+title: DuxtSkipLink
+description: O primeiro elemento focável da página.
+icon: lucide:skip-forward
+---
+
+«Saltar para o conteúdo», invisível até receber foco.
+
+## Utilização
+
+```vue
+
+```
+
+## API
+
+Sem props.
+
+## Notas
+
+Sem ela, cada mudança de página significa passar com o tabulador pelo cabeçalho,
+pela linha de secções e por toda a barra lateral antes da primeira palavra do
+texto. O `sr-only` reduz a ligação a um único píxel, e o estado de foco
+devolve-a ao ecrã por cima de tudo o resto.
+
+O clique é tratado em vez de ficar entregue ao hash, porque o foco só é movido
+por uma ligação com hash em alguns navegadores — o handler move-o em todos.
diff --git a/docs/pt/4.reference/5.components/3.controls/index.md b/docs/pt/4.reference/5.components/3.controls/index.md
new file mode 100644
index 00000000..9dac567c
--- /dev/null
+++ b/docs/pt/4.reference/5.components/3.controls/index.md
@@ -0,0 +1,18 @@
+---
+title: Controlos
+description: O que o leitor opera — a pesquisa, os seletores, a folha de atalhos e dois indicadores.
+icon: lucide:sliders-horizontal
+---
+
+Cada um destes é um controlo que o leitor opera, ou um estado que ele lê, e não
+uma parte da estrutura da página. Nenhum recebe uma prop: leem
+[`useDuxtConfig()`](/reference/composables/config-and-content/use-duxt-config)
+e não desenham nada quando a configuração nada lhes dá para oferecer.
+
+É essa última parte o padrão que vale a pena conhecer antes de substituir algum.
+Um seletor com uma só escolha não é um seletor, por isso o `DuxtVersion` desenha
+um distintivo e o `DuxtLocale` não desenha absolutamente nada — um site com um
+só idioma nunca fica a saber que o duxt fala sete línguas.
+
+::page-cards
+::
diff --git a/docs/pt/4.reference/5.components/4.page/1.duxt-page-info.md b/docs/pt/4.reference/5.components/4.page/1.duxt-page-info.md
new file mode 100644
index 00000000..da0f5199
--- /dev/null
+++ b/docs/pt/4.reference/5.components/4.page/1.duxt-page-info.md
@@ -0,0 +1,34 @@
+---
+title: DuxtPageInfo
+description: "De onde veio a página: ligação de edição, última atualização, contribuidores."
+icon: lucide:git-commit-horizontal
+---
+
+A proveniência, por baixo do índice.
+
+## Utilização
+
+```vue
+
+```
+
+## API
+
+### Props
+
+| Prop | Tipo | Notas |
+| :----- | :------- | :----------------------------------------------------- |
+| `page` | `object` | Obrigatório — o documento do Content a ser apresentado |
+
+## Notas
+
+Não no fim do artigo, que foi onde esteve primeiro e onde se lia como um
+pensamento tardio por baixo das ligações anterior/seguinte. «Editar esta
+página», quando mudou pela última vez e quem a escreveu são uma só pergunta — a
+proveniência — e a coluna da direita já é a que responde ao que uma página É, e
+não ao que ela diz.
+
+Tudo aqui é derivado. A ligação de edição aponta para o repositório de origem e
+para a ref de onde a página foi lida, por isso uma página vinda de uma tag liga
+à sua fonte em vez de a um formulário de edição que editaria o ramo errado. A
+«última atualização» e os contribuidores precisam de `history: true` na fonte.
diff --git a/docs/pt/4.reference/5.components/4.page/2.duxt-copy-page.md b/docs/pt/4.reference/5.components/4.page/2.duxt-copy-page.md
new file mode 100644
index 00000000..90b658ca
--- /dev/null
+++ b/docs/pt/4.reference/5.components/4.page/2.duxt-copy-page.md
@@ -0,0 +1,39 @@
+---
+title: DuxtCopyPage
+description: Entrega esta página a um modelo, ou lê o seu código-fonte.
+icon: lucide:clipboard-copy
+---
+
+Um controlo dividido ao lado do título da página: copiar o Markdown, vê-lo, ou
+abri-lo num modelo de chat.
+
+## Utilização
+
+```vue
+
+```
+
+## API
+
+### Props
+
+| Prop | Tipo | Notas |
+| :-------- | :------- | :---------------------------------------------- |
+| `path` | `string` | Obrigatório — o caminho da documentação |
+| `title` | `string` | Usado no prompt entregue a um modelo |
+| `rawbody` | `string` | O código-fonte Markdown, para o botão de copiar |
+
+## Notas
+
+Um controlo dividido em vez de quatro botões — o caso comum é «copiar», e os
+outros três são a mesma intenção por outra porta.
+
+O que todos entregam é o **Markdown**, não a página apresentada: um modelo a
+quem se dá HTML tem de desfazer um layout para encontrar um cabeçalho, e um a
+quem se dá a fonte lê o que o autor escreveu. O frontmatter é removido, por isso
+o que se copia lê-se como a página e não como o ficheiro.
+
+As ligações externas precisam de um URL absoluto, e a origem é lida do browser
+no momento do clique em vez de vir da configuração — o único valor que é
+seguramente correto, incluindo num deployment de pré-visualização cujo domínio
+ninguém anotou.
diff --git a/docs/pt/4.reference/5.components/4.page/3.duxt-page-feedback.md b/docs/pt/4.reference/5.components/4.page/3.duxt-page-feedback.md
new file mode 100644
index 00000000..02ce13e0
--- /dev/null
+++ b/docs/pt/4.reference/5.components/4.page/3.duxt-page-feedback.md
@@ -0,0 +1,39 @@
+---
+title: DuxtPageFeedback
+description: "«Esta página foi útil?» — um evento, e sem backend."
+icon: lucide:thumbs-up
+---
+
+O bloco no fim de todas as páginas.
+
+## Utilização
+
+```vue
+ track(helpful)" />
+```
+
+## API
+
+### Emits
+
+| Evento | Carga útil | Quando |
+| :--------- | :------------------ | :----------------- |
+| `feedback` | `helpful: boolean` | O leitor responde |
+
+### Slots
+
+| Slot | Props | O que substitui |
+| :-------- | :--------------------- | :---------------------- |
+| `default` | `{ answered, answer }` | A pergunta e os botões |
+
+## Notas
+
+Um slot com um evento e sem backend, e é esse o desenho todo. A camada não
+guarda nada e não envia nada: não lhe compete saber onde vive a analítica de
+quem a consome, e um tema de documentação que telefona para casa por omissão não
+é um tema para publicar. A resposta vive no estado do próprio componente durante
+a visita e em mais lado nenhum — uma mudança de rota repõe-na, porque uma página
+nova é uma pergunta nova.
+
+Fazer a ligação é um guia por si só:
+[Recolher opiniões da página](/guides/collect-page-feedback).
diff --git a/docs/pt/4.reference/5.components/4.page/4.duxt-code-block.md b/docs/pt/4.reference/5.components/4.page/4.duxt-code-block.md
new file mode 100644
index 00000000..42367707
--- /dev/null
+++ b/docs/pt/4.reference/5.components/4.page/4.duxt-code-block.md
@@ -0,0 +1,40 @@
+---
+title: DuxtCodeBlock
+description: O cartão à volta de um bloco de código.
+icon: lucide:code
+---
+
+A moldura em que cada bloco é desenhado: nome do ficheiro, ícone da linguagem,
+botão de copiar.
+
+## Utilização
+
+Chamado pelo [`ProsePre`](/reference/mdc-components/prose/prose-pre) e não à mão,
+mas utilizável diretamente:
+
+```vue
+
+```
+
+## API
+
+### Props
+
+| Prop | Tipo | Notas |
+| :--------- | :------- | :-------------------------------------------------------- |
+| `code` | `string` | Código-fonte em bruto, para o botão de copiar e como corpo |
+| `language` | `string` | Escolhe o ícone |
+| `filename` | `string` | O rótulo no cabeçalho |
+
+### Slots
+
+| Slot | O que leva dentro |
+| :-------- | :------------------------------- |
+| `default` | Marcação realçada, quando existe |
+
+## Notas
+
+Sem nome de ficheiro, o botão de copiar flutua no canto e aparece ao passar o
+rato; com um, fica no cabeçalho, onde está sempre visível. Um bloco que mostra
+sempre um botão no canto da página é mais ruidoso do que um que o mostra quando
+um leitor o vai procurar.
diff --git a/docs/pt/4.reference/5.components/4.page/index.md b/docs/pt/4.reference/5.components/4.page/index.md
new file mode 100644
index 00000000..c2207c08
--- /dev/null
+++ b/docs/pt/4.reference/5.components/4.page/index.md
@@ -0,0 +1,11 @@
+---
+title: Página
+description: Os componentes que pertencem à própria página.
+icon: lucide:file-text
+---
+
+A proveniência, o controlo de cópia, o bloco de feedback e o cartão à volta de
+um bloco de código.
+
+::page-cards
+::
diff --git a/docs/pt/4.reference/5.components/index.md b/docs/pt/4.reference/5.components/index.md
new file mode 100644
index 00000000..bc83ac7c
--- /dev/null
+++ b/docs/pt/4.reference/5.components/index.md
@@ -0,0 +1,24 @@
+---
+title: Componentes
+description: Os componentes da própria camada, uma página cada — para que serve cada um e o que recebe.
+icon: lucide:component
+---
+
+Todos os componentes aqui são **sombreáveis**: um ficheiro com o mesmo nome no
+teu projeto substitui o da camada, sem configuração nenhuma. Um nome documentado
+aqui faz parte da superfície pública, por isso não será renomeado sem um
+lançamento maior.
+
+As páginas estão agrupadas pelo que um componente faz: os dois que emolduram uma
+página, os seis que navegam a sua árvore, os controlos que um leitor opera e os
+componentes que pertencem à própria página.
+
+::page-cards
+::
+
+::callout{type="tip" title="Substitui o menos possível"}
+Antes de sombrear um componente, verifica se uma chave de configuração, um slot
+ou um token CSS já faz o que precisas — ver
+[Substituir o tema](/guides/override-the-theme). Um ficheiro substituído deixa
+de receber as correções da camada.
+::
diff --git a/docs/pt/4.reference/6.composables/1.config-and-content/1.use-duxt-config.md b/docs/pt/4.reference/6.composables/1.config-and-content/1.use-duxt-config.md
new file mode 100644
index 00000000..5e568203
--- /dev/null
+++ b/docs/pt/4.reference/6.composables/1.config-and-content/1.use-duxt-config.md
@@ -0,0 +1,30 @@
+---
+title: useDuxtConfig()
+description: A configuração fundida, com todos os campos de texto já resolvidos.
+icon: lucide:settings
+---
+
+Aquele a que recorrer num componente substituído.
+
+## Assinatura
+
+```ts
+const duxt = useDuxtConfig();
+```
+
+## Devolve
+
+A configuração inteira, reativa: os valores por omissão da camada com o teu
+`app.config.ts` fundido por cima, e cada campo de texto já uma string.
+
+## Notas
+
+Acontecem aqui duas coisas que um componente nunca deveria ter de saber. A fusão
+de `app.config` do próprio Nuxt **acrescenta** os arrays em vez de os substituir,
+por isso quem a consome e substitua `navigation` ficaria com as entradas da
+camada depois das suas; esta fusão substitui-as. E um rótulo pode ser um literal,
+uma chave i18n ou um registo por locale — os três colapsam numa string, por isso
+um template nunca encontra uma chave.
+
+O resultado mantém-se vivo, por isso mudar de idioma atualiza a barra de
+navegação sem recarregar a página por completo.
diff --git a/docs/pt/4.reference/6.composables/1.config-and-content/2.use-duxt-collection.md b/docs/pt/4.reference/6.composables/1.config-and-content/2.use-duxt-collection.md
new file mode 100644
index 00000000..03929d3d
--- /dev/null
+++ b/docs/pt/4.reference/6.composables/1.config-and-content/2.use-duxt-collection.md
@@ -0,0 +1,25 @@
+---
+title: useDuxtCollection()
+description: Que coleção serve esta rota.
+icon: lucide:library
+---
+
+## Assinatura
+
+```ts
+const { collection, source, sources } = useDuxtCollection();
+```
+
+## Devolve
+
+| Valor | Tipo | O que é |
+| :----------- | :------- | :---------------------------------------------- |
+| `collection` | `string` | O nome da coleção que serve a rota atual |
+| `source` | `object` | A fonte resolvida por trás dela |
+| `sources` | `array` | Todas as fontes resolvidas |
+
+## Notas
+
+`collection` é uma **string**, não um tipo literal — os nomes de coleção de um
+site são gerados a partir das suas próprias fontes, por isso nada na camada os
+pode nomear. Passa-o a `queryCollection` em vez de escreveres `'docs'`.
diff --git a/docs/pt/4.reference/6.composables/1.config-and-content/3.use-duxt-navigation.md b/docs/pt/4.reference/6.composables/1.config-and-content/3.use-duxt-navigation.md
new file mode 100644
index 00000000..a93ddab6
--- /dev/null
+++ b/docs/pt/4.reference/6.composables/1.config-and-content/3.use-duxt-navigation.md
@@ -0,0 +1,22 @@
+---
+title: useDuxtNavigation()
+description: A árvore de navegação da coleção que serve esta rota.
+icon: lucide:list-tree
+---
+
+## Assinatura
+
+```ts
+const { data: navigation } = await useDuxtNavigation();
+```
+
+## Devolve
+
+Um handle de `useAsyncData` sobre a árvore de navegação da coleção que serve a
+rota atual.
+
+## Notas
+
+Perguntar por rota em vez de uma vez para o site é o que torna correta uma
+árvore versionada: ler `v0.7.0` dá as páginas dessa versão, não as da versão por
+omissão.
diff --git a/docs/pt/4.reference/6.composables/1.config-and-content/4.use-duxt-section.md b/docs/pt/4.reference/6.composables/1.config-and-content/4.use-duxt-section.md
new file mode 100644
index 00000000..e042b466
--- /dev/null
+++ b/docs/pt/4.reference/6.composables/1.config-and-content/4.use-duxt-section.md
@@ -0,0 +1,23 @@
+---
+title: useDuxtSection()
+description: A parte da documentação em que esta página está.
+icon: lucide:folder-open
+---
+
+## Assinatura
+
+```ts
+const { section, items } = useDuxtSection(navigation);
+```
+
+## Devolve
+
+| Valor | Tipo | O que é |
+| :-------- | :------- | :--------------------------------------------- |
+| `section` | `object` | A secção configurada em que a página se encontra |
+| `items` | `array` | O ramo da árvore dessa secção |
+
+## Notas
+
+Sem `sections` configuradas volta a árvore inteira, que é a forma certa para
+documentação que não está dividida em partes.
diff --git a/docs/pt/4.reference/6.composables/1.config-and-content/5.use-duxt-breadcrumb.md b/docs/pt/4.reference/6.composables/1.config-and-content/5.use-duxt-breadcrumb.md
new file mode 100644
index 00000000..c1583fc4
--- /dev/null
+++ b/docs/pt/4.reference/6.composables/1.config-and-content/5.use-duxt-breadcrumb.md
@@ -0,0 +1,20 @@
+---
+title: useDuxtBreadcrumb()
+description: O trilho da secção até uma página.
+icon: lucide:chevrons-right
+---
+
+## Assinatura
+
+```ts
+const trail = useDuxtBreadcrumb(path);
+```
+
+## Devolve
+
+As entradas entre a secção e a página, cada uma com o seu título e caminho.
+
+## Notas
+
+Um grupo que não transporta página própria aparece no trilho sem ligação — a
+pasta é real, a página por trás dela não.
diff --git a/docs/pt/4.reference/6.composables/1.config-and-content/index.md b/docs/pt/4.reference/6.composables/1.config-and-content/index.md
new file mode 100644
index 00000000..787ecf94
--- /dev/null
+++ b/docs/pt/4.reference/6.composables/1.config-and-content/index.md
@@ -0,0 +1,11 @@
+---
+title: Configuração e conteúdo
+description: A configuração fundida, e que coleção serve a página que está a ser lida.
+icon: lucide:database
+---
+
+Começa por aqui num componente substituído: estas cinco respondem a «o que diz
+este site» e «de que faz parte esta página».
+
+::page-cards
+::
diff --git a/docs/pt/4.reference/6.composables/2.paths-and-versions/1.use-duxt-path.md b/docs/pt/4.reference/6.composables/2.paths-and-versions/1.use-duxt-path.md
new file mode 100644
index 00000000..7fd827a4
--- /dev/null
+++ b/docs/pt/4.reference/6.composables/2.paths-and-versions/1.use-duxt-path.md
@@ -0,0 +1,22 @@
+---
+title: useDuxtPath()
+description: O caminho em que a documentação está, com o locale retirado.
+icon: lucide:milestone
+---
+
+## Assinatura
+
+```ts
+const path = useDuxtPath();
+```
+
+## Devolve
+
+O caminho da rota atual sem o segmento de locale — o caminho tal como a
+documentação o conhece.
+
+## Notas
+
+Tudo o que raciocina sobre páginas raciocina sobre isto, e não sobre
+`route.path`: `/de/duxt/getting-started` e `/duxt/getting-started` são a mesma
+página.
diff --git a/docs/pt/4.reference/6.composables/2.paths-and-versions/2.use-duxt-link.md b/docs/pt/4.reference/6.composables/2.paths-and-versions/2.use-duxt-link.md
new file mode 100644
index 00000000..e84caeb7
--- /dev/null
+++ b/docs/pt/4.reference/6.composables/2.paths-and-versions/2.use-duxt-link.md
@@ -0,0 +1,25 @@
+---
+title: useDuxtLink()
+description: Um caminho de documentação, reescrito como ligação.
+icon: lucide:link
+---
+
+O inverso de [`useDuxtPath()`](/reference/composables/paths-and-versions/use-duxt-path).
+
+## Assinatura
+
+```ts
+const localeLink = useDuxtLink();
+
+localeLink('/getting-started');
+```
+
+## Devolve
+
+Uma função que volta a pôr o prefixo de locale num caminho de documentação e
+deixa em paz um URL absoluto.
+
+## Notas
+
+Usa-a em todos os `to` que um componente substituído desenhe. Um caminho escrito
+à mão num componente está certo num idioma e errado nos outros seis.
diff --git a/docs/pt/4.reference/6.composables/2.paths-and-versions/3.use-duxt-version.md b/docs/pt/4.reference/6.composables/2.paths-and-versions/3.use-duxt-version.md
new file mode 100644
index 00000000..f000f03b
--- /dev/null
+++ b/docs/pt/4.reference/6.composables/2.paths-and-versions/3.use-duxt-version.md
@@ -0,0 +1,31 @@
+---
+title: useDuxtVersion()
+description: O que o seletor mostra, o que o aviso diz, se se deve indexar.
+icon: lucide:layers
+---
+
+## Assinatura
+
+```ts
+const version = useDuxtVersion();
+```
+
+## Devolve
+
+| Valor | O que responde |
+| :-------------- | :------------------------------------------------- |
+| `sources` | Todas as versões do repositório que está a ser lido |
+| `current` | A versão a que esta página pertence |
+| `preferred` | A versão em que o leitor devia estar |
+| `isPreferred` | Se as duas são a mesma |
+| `status` | O seu ciclo de vida — upcoming, current, eol … |
+| `kind` | Ramo ou tag |
+| `shouldIndex` | Se a página pede para ser indexada |
+| `shouldWarn` | Se o aviso é mostrado |
+| `preferredPath` | Esta mesma página, na versão preferida |
+
+## Notas
+
+Três perguntas num só sítio — o que o seletor mostra, o que o aviso diz, e se a
+página pede para ser indexada — porque são a mesma pergunta sobre a mesma
+versão.
diff --git a/docs/pt/4.reference/6.composables/2.paths-and-versions/4.use-duxt-site-url.md b/docs/pt/4.reference/6.composables/2.paths-and-versions/4.use-duxt-site-url.md
new file mode 100644
index 00000000..5cd27b03
--- /dev/null
+++ b/docs/pt/4.reference/6.composables/2.paths-and-versions/4.use-duxt-site-url.md
@@ -0,0 +1,23 @@
+---
+title: useDuxtSiteUrl()
+description: A origem configurada, e caminhos tornados absolutos.
+icon: lucide:globe
+---
+
+## Assinatura
+
+```ts
+const { origin, absolute } = useDuxtSiteUrl();
+```
+
+## Devolve
+
+| Valor | Tipo | O que é |
+| :--------- | :--------- | :-------------------------------------------- |
+| `origin` | `string` | A origem configurada do site |
+| `absolute` | `function` | Um caminho, tornado absoluto em relação a ela |
+
+## Notas
+
+Necessário sempre que um URL sai da página — um item de feed, uma etiqueta
+canónica, uma imagem OG. Dentro da página, usa um caminho.
diff --git a/docs/pt/4.reference/6.composables/2.paths-and-versions/index.md b/docs/pt/4.reference/6.composables/2.paths-and-versions/index.md
new file mode 100644
index 00000000..311efb7f
--- /dev/null
+++ b/docs/pt/4.reference/6.composables/2.paths-and-versions/index.md
@@ -0,0 +1,12 @@
+---
+title: Caminhos e versões
+description: Ler um URL, escrever outro de volta, e saber que versão está a ser lida.
+icon: lucide:route
+---
+
+O caminho de uma página de documentação e um URL não são a mesma string: o URL
+transporta o locale, o repositório e a versão. Estas quatro são a junção entre
+eles.
+
+::page-cards
+::
diff --git a/docs/pt/4.reference/6.composables/3.reading/1.use-duxt-search.md b/docs/pt/4.reference/6.composables/3.reading/1.use-duxt-search.md
new file mode 100644
index 00000000..97ef8676
--- /dev/null
+++ b/docs/pt/4.reference/6.composables/3.reading/1.use-duxt-search.md
@@ -0,0 +1,24 @@
+---
+title: useDuxtSearch()
+description: Pesquisa em todas as fontes, ordenada em conjunto.
+icon: lucide:search
+---
+
+## Assinatura
+
+```ts
+const { search, init, labelled } = useDuxtSearch();
+```
+
+## Devolve
+
+| Valor | O que é |
+| :--------- | :--------------------------------------------------------------------------- |
+| `search` | Correr uma consulta, em todas as fontes |
+| `init` | Construir o índice, chamado quando a caixa de diálogo abre pela primeira vez |
+| `labelled` | Resultados que transportam a fonte de onde vieram |
+
+## Notas
+
+O índice é construído na primeira utilização, não no carregamento da página: um
+leitor que nunca pesquisa nunca paga por ele.
diff --git a/docs/pt/4.reference/6.composables/3.reading/2.use-fuzzy-search.md b/docs/pt/4.reference/6.composables/3.reading/2.use-fuzzy-search.md
new file mode 100644
index 00000000..daef59dc
--- /dev/null
+++ b/docs/pt/4.reference/6.composables/3.reading/2.use-fuzzy-search.md
@@ -0,0 +1,21 @@
+---
+title: useFuzzySearch()
+description: Tolerância a gralhas ao lado do índice exato.
+icon: lucide:spell-check
+---
+
+## Assinatura
+
+```ts
+const { search } = useFuzzySearch(items);
+```
+
+## Devolve
+
+Uma função de pesquisa que pontua as aproximações.
+
+## Notas
+
+Só corre quando a pesquisa exata não devolve nada, e a interface di-lo — um
+leitor que escreveu mal deve receber a página, mas também deve ser informado de
+que foi isso que aconteceu.
diff --git a/docs/pt/4.reference/6.composables/3.reading/3.use-active-heading.md b/docs/pt/4.reference/6.composables/3.reading/3.use-active-heading.md
new file mode 100644
index 00000000..e4ba87fd
--- /dev/null
+++ b/docs/pt/4.reference/6.composables/3.reading/3.use-active-heading.md
@@ -0,0 +1,21 @@
+---
+title: useActiveHeading()
+description: O cabeçalho que está à vista neste momento.
+icon: lucide:heading
+---
+
+## Assinatura
+
+```ts
+const active = useActiveHeading(ids);
+```
+
+## Devolve
+
+O id do cabeçalho onde o leitor está neste momento.
+
+## Notas
+
+Observa os cabeçalhos em vez do deslocamento, por isso uma última secção curta
+continua a ser dada como alcançada — a falha habitual de um índice guiado pela
+posição de deslocamento.
diff --git a/docs/pt/4.reference/6.composables/3.reading/4.use-duxt-page-focus.md b/docs/pt/4.reference/6.composables/3.reading/4.use-duxt-page-focus.md
new file mode 100644
index 00000000..c7cf79d0
--- /dev/null
+++ b/docs/pt/4.reference/6.composables/3.reading/4.use-duxt-page-focus.md
@@ -0,0 +1,18 @@
+---
+title: useDuxtPageFocus()
+description: Move o foco para o cabeçalho depois de uma navegação do lado do cliente.
+icon: lucide:focus
+---
+
+## Assinatura
+
+```ts
+useDuxtPageFocus();
+```
+
+## Notas
+
+Uma mudança de rota do lado do cliente substitui o documento sem mover o foco,
+por isso um leitor de ecrã continua a ler a página antiga e um tab de teclado
+continua onde a página anterior ficou. Isto põe o foco no cabeçalho da nova
+página.
diff --git a/docs/pt/4.reference/6.composables/3.reading/5.use-recent-pages.md b/docs/pt/4.reference/6.composables/3.reading/5.use-recent-pages.md
new file mode 100644
index 00000000..d9116666
--- /dev/null
+++ b/docs/pt/4.reference/6.composables/3.reading/5.use-recent-pages.md
@@ -0,0 +1,16 @@
+---
+title: useRecentPages()
+description: As últimas cinco páginas que este leitor abriu.
+icon: lucide:history
+---
+
+## Assinatura
+
+```ts
+const { recent, remember, load } = useRecentPages();
+```
+
+## Notas
+
+Guardado no navegador, não num cookie: nunca é preciso durante a renderização no
+servidor, por isso não viaja com pedido nenhum.
diff --git a/docs/pt/4.reference/6.composables/3.reading/6.use-package-manager.md b/docs/pt/4.reference/6.composables/3.reading/6.use-package-manager.md
new file mode 100644
index 00000000..a750f6a1
--- /dev/null
+++ b/docs/pt/4.reference/6.composables/3.reading/6.use-package-manager.md
@@ -0,0 +1,18 @@
+---
+title: usePackageManager()
+description: O gestor de pacotes que um leitor escolheu.
+icon: lucide:package
+---
+
+## Assinatura
+
+```ts
+const manager = usePackageManager();
+```
+
+## Notas
+
+Um cookie em vez de armazenamento do navegador, e deliberadamente: o
+armazenamento é lido depois da hidratação, por isso um bloco de comandos
+desenharia um gestor e depois trocaria para outro à frente do leitor. É próprio
+do site, não identifica ninguém e transporta um valor que o leitor escolheu.
diff --git a/docs/pt/4.reference/6.composables/3.reading/7.use-duxt-toast.md b/docs/pt/4.reference/6.composables/3.reading/7.use-duxt-toast.md
new file mode 100644
index 00000000..e55fdbe7
--- /dev/null
+++ b/docs/pt/4.reference/6.composables/3.reading/7.use-duxt-toast.md
@@ -0,0 +1,23 @@
+---
+title: useDuxtToast()
+description: Notificações, embrulhadas para que nenhum componente importe a biblioteca.
+icon: lucide:bell
+---
+
+## Assinatura
+
+```ts
+const notify = useDuxtToast();
+
+notify.success(t('duxt.code.copiedToast'));
+```
+
+## Devolve
+
+`{ success, info, warning, error, loading, dismiss }`.
+
+## Notas
+
+O embrulho é a junção: quem a consome e queira outro sistema de notificações
+substitui este único composable em vez de todos os sítios que o chamam, e os
+componentes da própria camada continuam a funcionar.
diff --git a/docs/pt/4.reference/6.composables/3.reading/8.use-duxt-shortcuts.md b/docs/pt/4.reference/6.composables/3.reading/8.use-duxt-shortcuts.md
new file mode 100644
index 00000000..de213b00
--- /dev/null
+++ b/docs/pt/4.reference/6.composables/3.reading/8.use-duxt-shortcuts.md
@@ -0,0 +1,20 @@
+---
+title: useDuxtShortcuts()
+description: As teclas que o tema escuta.
+icon: lucide:keyboard
+---
+
+## Assinatura
+
+```ts
+const shortcuts = useDuxtShortcuts();
+
+onDuxtShortcut('k', () => open());
+```
+
+## Notas
+
+O único sítio que sabe que teclas estão associadas, para que a sobreposição em
+`?` e os handlers não possam discordar. `onDuxtShortcut()`, ao seu lado, associa
+uma tecla com as salvaguardas de que qualquer associação precisa — incluindo
+nunca disparar enquanto o leitor está a escrever.
diff --git a/docs/pt/4.reference/6.composables/3.reading/index.md b/docs/pt/4.reference/6.composables/3.reading/index.md
new file mode 100644
index 00000000..4f459473
--- /dev/null
+++ b/docs/pt/4.reference/6.composables/3.reading/index.md
@@ -0,0 +1,11 @@
+---
+title: Experiência de leitura
+description: Pesquisa, cabeçalhos, foco, histórico e as escolhas que um leitor faz.
+icon: lucide:book-open
+---
+
+Tudo o que faz o site comportar-se como um sítio onde alguém está a ler, e não
+como um conjunto de documentos.
+
+::page-cards
+::
diff --git a/docs/pt/4.reference/6.composables/index.md b/docs/pt/4.reference/6.composables/index.md
new file mode 100644
index 00000000..cb046320
--- /dev/null
+++ b/docs/pt/4.reference/6.composables/index.md
@@ -0,0 +1,14 @@
+---
+title: Composables
+description: As funções que a camada expõe aos teus próprios componentes, uma página cada.
+icon: lucide:function-square
+---
+
+Importados automaticamente, como qualquer composable numa camada Nuxt — sem
+linha de import.
+
+Cada página nomeia a assinatura, o que vem de volta, e a única coisa a seu
+respeito que o tipo de retorno não torna óbvia.
+
+::page-cards
+::
diff --git a/docs/pt/4.reference/7.endpoints/1.llms-txt.md b/docs/pt/4.reference/7.endpoints/1.llms-txt.md
new file mode 100644
index 00000000..02ae9d06
--- /dev/null
+++ b/docs/pt/4.reference/7.endpoints/1.llms-txt.md
@@ -0,0 +1,18 @@
+---
+title: /llms.txt
+description: A documentação como índice Markdown, segundo llmstxt.org.
+icon: lucide:file-text
+---
+
+Um índice do site, em Markdown, no caminho que a convenção llmstxt.org nomeia.
+
+## Resposta
+
+`text/plain`. O título e a descrição do site, depois cada página como entrada de
+lista com ligação e com a sua própria descrição.
+
+## Notas
+
+Gerado apenas a partir da versão por omissão. Um modelo a quem se entregam todas
+as versões de todas as páginas tem de descobrir qual delas é a atual, e a
+resposta a essa pergunta é um facto de tempo de compilação que o site já conhece.
diff --git a/docs/pt/4.reference/7.endpoints/2.llms-full-txt.md b/docs/pt/4.reference/7.endpoints/2.llms-full-txt.md
new file mode 100644
index 00000000..1af35298
--- /dev/null
+++ b/docs/pt/4.reference/7.endpoints/2.llms-full-txt.md
@@ -0,0 +1,20 @@
+---
+title: /llms-full.txt
+description: As mesmas páginas, inteiras, num único ficheiro Markdown.
+icon: lucide:file-stack
+---
+
+Todas as páginas da versão por omissão, na íntegra, concatenadas.
+
+## Resposta
+
+`text/plain`, um único documento.
+
+## Notas
+
+O índice e o texto completo respondem a duas perguntas diferentes: um agente que
+consegue ir buscar segue o [`/llms.txt`](/reference/endpoints/llms-txt) página a
+página, e um que não consegue leva tudo de uma vez.
+
+O frontmatter é retirado de cada página — o que é servido lê-se como a
+documentação e não como os ficheiros de onde veio.
diff --git a/docs/pt/4.reference/7.endpoints/3.page-markdown.md b/docs/pt/4.reference/7.endpoints/3.page-markdown.md
new file mode 100644
index 00000000..776bcc4b
--- /dev/null
+++ b/docs/pt/4.reference/7.endpoints/3.page-markdown.md
@@ -0,0 +1,21 @@
+---
+title: .md
+description: O código-fonte de qualquer página, no seu próprio URL mais .md.
+icon: lucide:file-code
+---
+
+Acrescenta `.md` a qualquer URL da documentação e a resposta é o Markdown a
+partir do qual essa página foi apresentada.
+
+```
+/duxt/getting-started → a página
+/duxt/getting-started.md → o seu código-fonte
+```
+
+## Notas
+
+É um **middleware, não uma rota**, porque `.md` é um sufixo do último segmento e
+não um segmento próprio — nenhum ficheiro de rota o consegue corresponder.
+
+É isto que o [`DuxtCopyPage`](/reference/components/page/duxt-copy-page) entrega
+a um modelo: o código-fonte, não a página apresentada.
diff --git a/docs/pt/4.reference/7.endpoints/4.mcp.md b/docs/pt/4.reference/7.endpoints/4.mcp.md
new file mode 100644
index 00000000..117cc402
--- /dev/null
+++ b/docs/pt/4.reference/7.endpoints/4.mcp.md
@@ -0,0 +1,22 @@
+---
+title: /mcp
+description: Um servidor MCP sobre o mesmo conteúdo.
+icon: lucide:bot
+---
+
+Um endpoint Model Context Protocol, para que um agente possa consultar a
+documentação em vez de lhe fazer scraping.
+
+## Ferramentas
+
+| Ferramenta | O que faz |
+| :------------ | :------------------------------------ |
+| `list_pages` | O índice de conteúdos |
+| `search_docs` | Encontra uma página por termo |
+| `read_page` | O texto completo de uma página |
+
+## Notas
+
+As mesmas coleções das páginas, por isso as respostas não podem divergir do que
+está publicado. O nome, a descrição e as instruções do servidor vêm das opções do
+módulo.
diff --git a/docs/pt/4.reference/7.endpoints/5.sitemap-and-robots.md b/docs/pt/4.reference/7.endpoints/5.sitemap-and-robots.md
new file mode 100644
index 00000000..73a736e8
--- /dev/null
+++ b/docs/pt/4.reference/7.endpoints/5.sitemap-and-robots.md
@@ -0,0 +1,21 @@
+---
+title: /sitemap.xml e /robots.txt
+description: Para onde um rastreador é apontado, e aquilo de que é mantido fora.
+icon: lucide:search
+---
+
+## Resposta
+
+`/sitemap.xml` lista as páginas da versão por omissão, por locale, com
+alternativas `hreflang`. O `/robots.txt` é gerado ao lado e aponta para o
+sitemap.
+
+## Notas
+
+Só a versão por omissão é listada. As versões mais antigas continuam alcançáveis
+e levam `canonical` mais `noindex` — um resultado de pesquisa que deixa um leitor
+na documentação do ano passado é a falha que este par existe para evitar, e o
+[aviso de versão](/reference/components/controls/duxt-version-banner) é a sua
+metade visível.
+
+Uma versão marcada como `eol` sai completamente do sitemap.
diff --git a/docs/pt/4.reference/7.endpoints/6.rss-xml.md b/docs/pt/4.reference/7.endpoints/6.rss-xml.md
new file mode 100644
index 00000000..f4a36fd5
--- /dev/null
+++ b/docs/pt/4.reference/7.endpoints/6.rss-xml.md
@@ -0,0 +1,17 @@
+---
+title: /rss.xml
+description: O feed, assim que uma secção for nomeada.
+icon: lucide:rss
+---
+
+## Resposta
+
+`application/rss+xml`, construído a partir da secção que `duxt.feed.path` nomeia.
+
+## Notas
+
+Antes de estar configurado responde **vazio em vez de 404**: a um cliente de feed
+deve dizer-se que aqui não há nada, não que o feed desapareceu.
+
+Desligado por omissão, porque um feed é uma lista de coisas que aconteceram, e
+uma página de referência editada não é um acontecimento.
diff --git a/docs/pt/4.reference/7.endpoints/7.devtools.md b/docs/pt/4.reference/7.endpoints/7.devtools.md
new file mode 100644
index 00000000..b609f191
--- /dev/null
+++ b/docs/pt/4.reference/7.endpoints/7.devtools.md
@@ -0,0 +1,27 @@
+---
+title: /_duxt/devtools
+description: A rota a partir da qual o painel das Nuxt Devtools é servido. Apenas em desenvolvimento.
+icon: lucide:wrench
+---
+
+::callout{type="danger" title="Nunca registado numa compilação"}
+O painel expõe a configuração resolvida, os caminhos de ficheiros por trás dela e
+um botão que apaga um directório de cache. Nada disso é da conta de ninguém em
+produção, e é por isso que o módulo retorna antes de registar seja o que for fora
+de um servidor de desenvolvimento.
+::
+
+## Resposta
+
+`text/html`, um documento por painel: `/_duxt/devtools` é o painel Sources e cada
+um dos outros pende dele — `/_duxt/devtools/paths`, `/_duxt/devtools/pages` e assim
+por diante, pela ordem da linha de separadores.
+
+São os mesmos documentos que as Nuxt Devtools enquadram no separador **duxt**, por
+isso a rota existe para ser aberta numa janela própria — uma tabela larga lê-se
+melhor fora de uma gaveta.
+
+## Notas
+
+Aquilo a que cada painel responde, com cada painel mostrado tal como se vê, é uma
+secção própria: [Devtools](/reference/devtools).
diff --git a/docs/pt/4.reference/7.endpoints/index.md b/docs/pt/4.reference/7.endpoints/index.md
new file mode 100644
index 00000000..8557c7ee
--- /dev/null
+++ b/docs/pt/4.reference/7.endpoints/index.md
@@ -0,0 +1,14 @@
+---
+title: Endpoints
+description: Tudo o que o site serve além das páginas, uma página cada.
+icon: lucide:route
+---
+
+É tudo gerado a partir das mesmas coleções que as páginas apresentam, por isso
+nada disto pode divergir do que está publicado.
+
+::page-cards
+::
+
+Tudo o que um modelo lê, e porque existem dois ficheiros llms, está em
+[Leitores automáticos](/concepts/machine-readers).
diff --git a/docs/pt/4.reference/8.devtools/1.sources.md b/docs/pt/4.reference/8.devtools/1.sources.md
new file mode 100644
index 00000000..b80ae5b3
--- /dev/null
+++ b/docs/pt/4.reference/8.devtools/1.sources.md
@@ -0,0 +1,41 @@
+---
+title: Sources
+description: No que `duxt.sources` se tornou — uma coleção por fonte, versão e idioma.
+icon: lucide:library
+---
+
+O primeiro painel a abrir, porque tudo o resto no separador vem depois dele. Uma
+linha por **fonte resolvida**: o que um consumidor escreveu como um punhado de
+entradas, desdobrado nas coleções que o Content gerou de facto e no prefixo de URL
+que cada uma serve.
+
+::devtools-panel{tab="sources"}
+::
+
+## Colunas
+
+| Coluna | O que diz |
+| :------------------ | :---------------------------------------------------------------- |
+| `Prefix` | O segmento de URL desta coleção; `/` para a que não tem prefixo |
+| `Collection` | O nome de coleção gerado, tal como `queryCollection` o recebe |
+| `Repository` | De onde vêm as páginas, com ligação quando é conhecido |
+| `Folder` | O `path` dentro desse repositório |
+| `Ref` | O ramo ou a etiqueta — e qual dos dois |
+| `Version` | O slug no URL, `default` na versão servida sem segmento próprio |
+| `Status` | `current`, `deprecated` ou `eol`, directo da fonte |
+| `Reserved segments` | Nomes de pasta desta fonte que um prefixo já reclamou |
+
+## Notas
+
+**Um segmento de repositório só aparece a partir do segundo repositório**, e um de
+versão só a partir da segunda ref — um segmento que só pode ter um valor não
+distingue nada. `sourceOptions.showRepo` e `showVersion` forçam-nos na mesma.
+
+**Os segmentos reservados são a armadilha para a qual esta coluna existe.** Se uma
+fonte é servida em `/sdk` e a sua pasta de documentação contém um directório
+`v1-9/`, o prefixo ganha e essa pasta fica inalcançável. O painel
+[Checks](/reference/devtools/checks) comunica isso como erro; esta coluna mostra
+que nomes estão tomados antes de acontecer.
+
+O rodapé nomeia o ficheiro de onde a lista foi lida — útil num site cujo
+`app.config.ts` não está onde esperavas.
diff --git a/docs/pt/4.reference/8.devtools/10.redirects.md b/docs/pt/4.reference/8.devtools/10.redirects.md
new file mode 100644
index 00000000..d978b342
--- /dev/null
+++ b/docs/pt/4.reference/8.devtools/10.redirects.md
@@ -0,0 +1,38 @@
+---
+title: Redirects
+description: Todos os redireccionamentos que o servidor serve, e quais deles a camada escreveu.
+icon: lucide:signpost
+---
+
+`redirectFrom` no frontmatter de uma página torna-se route rules, uma por cada
+prefixo sob o qual a página é servida. Este painel relê essas regras **do servidor
+em funcionamento** em vez de as recalcular: uma regra gerada e uma regra servida são
+afirmações diferentes, e só a segunda redirecciona alguém.
+
+::devtools-panel{tab="redirects"}
+::
+
+## Written by
+
+Reler as regras significa ler as de todos os módulos, não só as da camada — o
+`@nuxtjs/sitemap` acrescenta `/sitemap.xml` em qualquer site com mais do que um
+sitemap. Por isso cada linha diz quem a escreveu:
+
+| Emblema | Significado |
+| :--------------- | :------------------------------------------------------------- |
+| `redirectFrom` | Gerada pelo duxt a partir do frontmatter de uma página |
+| `another module` | A route rule de outra pessoa, servida pelo mesmo servidor |
+
+Nada numa route rule regista o seu autor, por isso a camada anota ao lado os
+caminhos que gerou, e o painel marca os restantes.
+
+## Notas
+
+**Uma regra por prefixo.** Um URL antigo estava guardado sob exactamente um dos
+segmentos de repositório, de versão e de idioma — e nada diz qual —, por isso todas
+as combinações redireccionam.
+
+Uma página que redirecciona para si própria é saltada em vez de servida: isso é um
+ciclo, não um redireccionamento.
+
+Mover uma página tem um guia próprio: [Mover uma página](/guides/move-a-page).
diff --git a/docs/pt/4.reference/8.devtools/2.paths.md b/docs/pt/4.reference/8.devtools/2.paths.md
new file mode 100644
index 00000000..f4487f80
--- /dev/null
+++ b/docs/pt/4.reference/8.devtools/2.paths.md
@@ -0,0 +1,40 @@
+---
+title: Paths
+description: Cola um URL e vê que fonte o reclama e o que encontra lá.
+icon: lucide:route
+---
+
+O depurador de caminhos, e o painel onde se encontra a maioria dos erros de versão.
+Todos os que esta camada teve foram um desacordo entre o aspecto de um URL e a
+fonte que o reclama — `/workflows/v0.7.0` a devolver 404,
+`/workflows/v0.7.0/v0.7.0` a ser gerado. Ambos eram aritmética de prefixos que nada
+imprimia.
+
+::devtools-panel{tab="paths"}
+::
+
+## Os passos
+
+1. **O segmento de idioma é retirado.** `/de/sdk/guides/caching` é a página
+ `/sdk/guides/caching` em alemão — o idioma traduz a interface, nunca a árvore de
+ conteúdo, por isso toda a consulta do tema se refere ao caminho encurtado.
+2. **Ganha o prefixo correspondente mais longo.** A uma página em `/sdk/v1-9`
+ também serve `/sdk`; tomar o mais curto é como uma etiqueta acaba a desenhar
+ `main`.
+3. **A coleção que a reclama é consultada** por esse caminho exacto.
+4. **A mesma página é procurada nas fontes irmãs** — as outras versões e idiomas do
+ mesmo repositório, nunca de outro.
+
+## Ler uma falha
+
+Uma página em falta aqui e presente noutra coluna é o caso do **fallback**: o
+selector de versões oferece-a, e o leitor vai para o antecessor mais próximo em vez
+de um 404.
+
+Uma página em falta em todo o lado é uma gralha, e o painel lista então os caminhos
+mais próximos da coleção — os que partilham o início mais longo com o que colaste.
+
+::callout{type="tip"}
+Cola o URL exactamente como o navegador o mostra, segmento de idioma incluído. O
+encurtamento é trabalho do painel, e vê-lo acontecer é metade da resposta.
+::
diff --git a/docs/pt/4.reference/8.devtools/3.pages.md b/docs/pt/4.reference/8.devtools/3.pages.md
new file mode 100644
index 00000000..795e8918
--- /dev/null
+++ b/docs/pt/4.reference/8.devtools/3.pages.md
@@ -0,0 +1,41 @@
+---
+title: Pages
+description: Todas as páginas de todas as coleções, com o frontmatter e os metadados de git.
+icon: lucide:files
+---
+
+Aquilo com que o Content ficou, um bloco por coleção. Este é o painel que responde
+a «a minha página está sequer na compilação?» — e, mais vezes, a «porque é que esta
+tem outro aspecto?».
+
+::devtools-panel{tab="pages"}
+::
+
+## Colunas
+
+| Coluna | O que diz |
+| :------------- | :------------------------------------------------------------------ |
+| `Path` | O URL, prefixo incluído — o que `queryCollection` guarda |
+| `Title` | Do frontmatter; `missing` é um aviso, não um palpite |
+| `Description` | Se existe alguma; alimenta os cartões, a imagem OG e o llms.txt |
+| `Icon` | O nome Iconify do frontmatter |
+| `Last updated` | Do histórico de git, não da data do ficheiro |
+| `Contributors` | Quantas pessoas o histórico nomeia |
+| `File` | Abre o ficheiro de origem no teu editor |
+
+## Notas
+
+**Uma coleção sem histórico nenhum** é normalmente uma fonte remota sem
+`history: true`. O Content clona com `--depth 1`, portanto o checkout tem um único
+commit e todas as páginas pareceriam escritas por quem cortou a ponta — a camada
+prefere dados nenhuns a dados errados.
+
+**A partir da segunda coleção está tudo fechado.** Um site com cinco versões tem
+cinco tabelas quase idênticas, e as cinco abertas ninguém lê.
+
+**O filtro aparece sobre tabelas com mais de doze linhas.** Esconde as linhas que
+não correspondem em lado nenhum, conta as que ficam, e uma recarga traz tudo de
+volta.
+
+**Uma coleção vazia é uma constatação, não uma tabela vazia** — todos os URL sob
+esse prefixo são um 404, e o painel di-lo onde as linhas teriam estado.
diff --git a/docs/pt/4.reference/8.devtools/4.versions.md b/docs/pt/4.reference/8.devtools/4.versions.md
new file mode 100644
index 00000000..f9c7c52e
--- /dev/null
+++ b/docs/pt/4.reference/8.devtools/4.versions.md
@@ -0,0 +1,36 @@
+---
+title: Versions
+description: Que versão — e que idioma — leva que página.
+icon: lucide:columns-3
+---
+
+Mais nada mostra isto. Todo o trabalho do fallback é a falha entre duas colunas
+desta grelha, e sem ela a única forma de encontrar uma é abrir um URL e ver um 404.
+
+::devtools-panel{tab="versions"}
+::
+
+## Dois eixos, não um
+
+Uma tradução não é uma versão, e compará-las como se fossem desenha uma grelha de
+colunas idênticas. Por isso o painel fixa um eixo de cada vez:
+
+- **Versões de um idioma** — `v2` contra `v1.9`, ambas em inglês.
+- **Idiomas de uma versão** — `en` contra `de`, ambos em `v2`.
+
+Um site com duas versões em dois idiomas obtém portanto quatro grelhas, cada uma
+com uma pergunta. Um repositório publicado uma vez, num idioma, não obtém nenhuma:
+uma única coluna não compara nada.
+
+Os repositórios nunca se misturam. `/sdk/guides` e `/cli/guides` são dois projectos
+que por acaso partilham um nome de pasta, e pô-los numa grelha inventaria uma falha
+que não é falha nenhuma.
+
+## Ler
+
+`✓` significa que a coleção tem essa página; `·` que não tem. O `*` ao lado de um
+cabeçalho marca a coluna servida sem segmento próprio — a versão por omissão, ou o
+idioma que vive na própria pasta da fonte.
+
+Um `·` numa coluna de idioma é uma página à espera de tradução. Um `·` na coluna de
+uma versão mais antiga costuma estar certo: o guia ainda não existia.
diff --git a/docs/pt/4.reference/8.devtools/5.checks.md b/docs/pt/4.reference/8.devtools/5.checks.md
new file mode 100644
index 00000000..965fee16
--- /dev/null
+++ b/docs/pt/4.reference/8.devtools/5.checks.md
@@ -0,0 +1,36 @@
+---
+title: Checks
+description: O validador da compilação, aplicado ao que o site está a servir agora.
+icon: lucide:check-check
+---
+
+As mesmas verificações que o `pnpm build` faz, a partir do mesmo código — não se
+trata de uma segunda opinião, mas de um sítio onde os achados ficam. Um aviso
+impresso durante uma compilação já desapareceu do ecrã quando alguém o lê.
+
+::devtools-panel{tab="checks"}
+::
+
+## O que é verificado
+
+Os **erros** são coisas que tornam um URL inalcançável:
+
+- uma **coleção sem páginas**, cujo prefixo inteiro é um 404;
+- uma **pasta com nome de segmento de repositório ou de versão**, que o prefixo
+ tapa por completo.
+
+Os **avisos** são coisas que degradam uma página em silêncio:
+
+- **sem `title` ou sem `description`** — o índice, os cartões, a imagem OG e o
+ llms.txt lêem ambos;
+- uma **ligação para uma página que ninguém serve**, resolvida exactamente como o
+ `ProseA` a resolve ao desenhar: primeiro sob o prefixo da fonte, depois nua;
+- uma **ligação para uma âncora que essa página não tem**.
+
+## Notas
+
+Os achados nomeiam o seu ficheiro entre aspas, e o painel transforma o primeiro
+numa ligação que abre o teu editor nesse ponto.
+
+As ligações são verificadas **apenas internamente**. Um URL externo não é desta
+compilação verificar, e fazê-lo poria a rede dentro dela.
diff --git a/docs/pt/4.reference/8.devtools/6.search.md b/docs/pt/4.reference/8.devtools/6.search.md
new file mode 100644
index 00000000..98441e8c
--- /dev/null
+++ b/docs/pt/4.reference/8.devtools/6.search.md
@@ -0,0 +1,29 @@
+---
+title: Search
+description: O que o índice de pesquisa contém, pela mesma consulta que a caixa de diálogo faz.
+icon: lucide:search
+---
+
+A pesquisa teve três erros distintos — um filtro a lutar contra outro filtro,
+resultados que eram fragmentos de cabeçalho, células de tabela coladas sem
+separadores — e os três eram invisíveis, porque ninguém conseguia ver as secções a
+partir das quais o índice é construído.
+
+::devtools-panel{tab="search"}
+::
+
+## Ler
+
+**Uma linha por cabeçalho**, não por página. É isso que o índice guarda e o que a
+caixa de diálogo volta a agrupar em páginas — uma página com oito cabeçalhos são
+oito linhas.
+
+A coluna do texto indexado é exactamente aquilo contra o que o FTS5 compara. Se uma
+palavra faltar lá, nenhuma consulta a encontra, por mais correcta que a página
+pareça.
+
+::callout{type="tip"}
+O FTS5 compara termos e prefixos, não subcadeias: `caching` encontra `caching`,
+`cach` também, e `aching` não encontra nada. Uma pesquisa que volta vazia é
+normalmente isto, não uma página em falta.
+::
diff --git a/docs/pt/4.reference/8.devtools/7.config.md b/docs/pt/4.reference/8.devtools/7.config.md
new file mode 100644
index 00000000..16d03a58
--- /dev/null
+++ b/docs/pt/4.reference/8.devtools/7.config.md
@@ -0,0 +1,41 @@
+---
+title: Config
+description: A configuração `duxt` fundida, e de que lado da fusão vem cada valor.
+icon: lucide:sliders-horizontal
+---
+
+Todas as chaves que o tema lê, com um emblema a dizer de onde vieram. «Porque é que
+a entrada do duxt ainda está na minha barra de navegação?» custa aqui um olhar em
+vez de uma sessão.
+
+::devtools-panel{tab="config"}
+::
+
+## Os emblemas
+
+| Emblema | Significado |
+| :--------- | :----------------------------------------------------------------- |
+| `consumer` | O teu `app.config.ts` definiu-a |
+| `layer` | É o valor por omissão da camada, intacto |
+| `build` | Foi calculada pela compilação e não está escrita em lado nenhum |
+
+## A regra de fusão
+
+**Os arrays são substituídos por inteiro, os objectos fundem chave a chave.** A
+`navigation` de um consumidor é a navegação — as entradas da camada desapareceram.
+O seu `footer.note` deixa `footer.columns` em paz.
+
+Não é o que o Nuxt faz com o `app.config` por si só: o defu concatena arrays, pelo
+que um site teria as suas entradas mais as nossas, sem forma de retirar as nossas.
+Por isso a camada não publica lista nenhuma no `app.config` e funde-as ela própria.
+
+## Notas
+
+**Os valores estruturados abrem.** Uma linha com um objecto ou uma lista mostra uma
+contagem e uma pré-visualização de uma linha; um clique imprime o valor inteiro,
+indentado. Continua na página enquanto está fechado, por isso o filtro encontra a
+linha por qualquer cadeia lá dentro — incluindo uma única tradução de um rótulo.
+
+`resolvedSources` é a única chave que ninguém escreve: é o manifesto que a
+compilação calculou a partir de `sources`, e que todos os outros painéis deste
+separador lêem.
diff --git a/docs/pt/4.reference/8.devtools/8.i18n.md b/docs/pt/4.reference/8.devtools/8.i18n.md
new file mode 100644
index 00000000..305b1541
--- /dev/null
+++ b/docs/pt/4.reference/8.devtools/8.i18n.md
@@ -0,0 +1,36 @@
+---
+title: i18n
+description: Chaves de mensagem por idioma, contra o idioma base.
+icon: lucide:languages
+---
+
+Sete idiomas e um ficheiro JSON por espaço de nomes fazem do desvio uma questão de
+tempo — e o vue-i18n responde a uma chave em falta imprimindo a chave, por isso uma
+falha aparece como um botão com o rótulo `duxt.nav.open` numa página que ninguém
+abriu nesse idioma.
+
+::devtools-panel{tab="i18n"}
+::
+
+## Colunas
+
+| Coluna | O que diz |
+| :---------------- | :------------------------------------------------------------------ |
+| `Directory` | Uma pasta em `i18n/locales`; `base` marca aquela de comparação |
+| `Keys` | Quantas chaves define ao todo |
+| `Missing vs base` | Chaves que a base tem e esta não |
+| `Not in base` | Chaves que esta tem e a base não — normalmente uma mudança de nome |
+
+## Notas
+
+**Faltar é um facto, não um veredicto.** Um ficheiro de idioma pode conter de
+propósito apenas os seus desvios: `pt-BR` substitui um punhado de cadeias e lê o
+resto de `pt` pelo recuo para o idioma base, por isso uma lista longa em «missing»
+aí é o desenho a funcionar.
+
+**«Not in base» lê-se duas vezes.** Uma chave que mais ninguém tem costuma ser uma
+gralha ou uma mudança de nome que ficou a meio — e imprime-se a si própria na
+página.
+
+A linha acima da tabela lista os idiomas que o site serve de facto, que é uma lista
+diferente das pastas em disco: vários idiomas podem partilhar uma.
diff --git a/docs/pt/4.reference/8.devtools/9.cache.md b/docs/pt/4.reference/8.devtools/9.cache.md
new file mode 100644
index 00000000..e125ed0e
--- /dev/null
+++ b/docs/pt/4.reference/8.devtools/9.cache.md
@@ -0,0 +1,32 @@
+---
+title: Cache
+description: A cache de descargas do Content, e uma forma de deitar fora uma entrada.
+icon: lucide:hard-drive
+---
+
+Todas as fontes remotas são clonadas para `.data/content` e ficam lá. Nada numa
+compilação menciona esse directório, por isso nada o sugere quando corre mal.
+
+::devtools-panel{tab="cache"}
+::
+
+## Porque interessa
+
+Um directório desactualizado custou aqui tempo a sério uma vez: uma etiqueta foi
+pedida como se fosse um ramo, a operação falhou, e a pasta escrita a meio continuou
+a responder para a configuração já corrigida até alguém a apagar à mão. A
+compilação não disse nada, porque para ela a fonte já estava descarregada.
+
+**Drop** remove uma entrada; a compilação seguinte volta a descarregá-la.
+
+## Notas
+
+O Content indexa um directório por repositório **e** ref, por isso uma entrada de
+uma ref que mudaste de nome nunca é reutilizada — nem nunca limpa. É neste painel
+que se encontram.
+
+::callout{type="warning"}
+Deitar fora uma entrada apaga um directório no teu disco. É sempre só a cache —
+nunca uma fonte de verdade —, mas é uma eliminação a sério, e é a razão pela qual
+este separador não é registado numa compilação.
+::
diff --git a/docs/pt/4.reference/8.devtools/index.md b/docs/pt/4.reference/8.devtools/index.md
new file mode 100644
index 00000000..c9843224
--- /dev/null
+++ b/docs/pt/4.reference/8.devtools/index.md
@@ -0,0 +1,48 @@
+---
+title: Devtools
+description: Dez painéis que mostram no que as tuas fontes se tornaram. Apenas em desenvolvimento.
+icon: lucide:wrench
+---
+
+`sources` é uma lista compacta. O que o site serve é um conjunto de coleções,
+prefixos de URL, redireccionamentos, catálogos de mensagens e uma cache de
+descargas que a compilação calculou a partir dela — e antes de este separador
+existir, a única forma de ver alguma dessas coisas era ler o código que as produz.
+
+## Abrir
+
+Arranca o servidor de desenvolvimento e abre as Nuxt Devtools (`Shift` + `Alt` +
+`D`, ou o botão no canto). O separador chama-se **duxt**; os painéis ficam atrás da
+linha de separadores lá dentro. As mesmas páginas respondem directamente em
+`/_duxt/devtools`, se preferires tê-las numa janela própria.
+
+::callout{type="danger" title="Nunca registado numa compilação"}
+Os painéis expõem a configuração resolvida, os caminhos de ficheiros por trás dela
+e um botão que apaga um directório de cache. Nada disso é da conta de ninguém em
+produção, e é por isso que `modules/devtools.ts` retorna antes de registar seja o
+que for fora de um servidor de desenvolvimento — a rota não existe, em vez de
+existir e recusar.
+::
+
+## Os painéis
+
+::page-cards
+::
+
+## Sobre as pré-visualizações destas páginas
+
+Cada painel abaixo está embebido tal como se desenha — não é uma captura de ecrã.
+As páginas executam as próprias funções de desenho dos painéis sobre um site de
+exemplo e guardam o resultado, por isso um painel que ganha uma coluna ganha-a
+nesta documentação no mesmo commit.
+
+Esse site de exemplo é um projecto imaginado: `acme/sdk` publicado em duas versões
+(`v2` a partir de `main`, `v1.9` a partir de uma etiqueta) em inglês e alemão, mais
+`acme/cli` numa só. Tem falhas propositadas — um guia que a versão antiga antecede,
+duas páginas que a tradução ainda não alcançou, uma página sem frontmatter —
+porque um painel sem nada a comunicar não ensina a ninguém o aspecto que tem quando
+algo corre mal.
+
+As pré-visualizações são inertes: os seus separadores mudam de painel, e tudo o
+resto — as ligações para o editor, o botão de eliminação, o formulário de pesquisa
+— não faz nada.
diff --git a/docs/pt/4.reference/index.md b/docs/pt/4.reference/index.md
new file mode 100644
index 00000000..5c24bc2e
--- /dev/null
+++ b/docs/pt/4.reference/index.md
@@ -0,0 +1,12 @@
+---
+title: Referência
+description: A superfície pública da camada — chaves de configuração, frontmatter, componentes, composables e endpoints.
+icon: lucide:list
+---
+
+O que quem a consome pode escrever, invocar ou substituir. **Um nome
+documentado aqui faz parte da superfície pública**: renomeá-lo é uma alteração
+incompatível da camada, que é o que torna estas páginas dignas de confiança.
+
+::page-cards
+::
diff --git a/docs/pt/5.credits/index.md b/docs/pt/5.credits/index.md
new file mode 100644
index 00000000..8aa8e1dc
--- /dev/null
+++ b/docs/pt/5.credits/index.md
@@ -0,0 +1,63 @@
+---
+title: Créditos
+description: Aquilo em que o duxt assenta, e aquilo de que partiu.
+icon: lucide:heart
+---
+
+O duxt é uma camada fina sobre o trabalho de outros. Quase nada do que faz é
+invenção sua — a recolha das fontes, a análise, os componentes e o estilo vêm
+todos de projetos que resolveram esses problemas primeiro, e a descrição honesta
+deste repositório é a cola entre eles mais um punhado de opiniões.
+
+## Em que assenta
+
+| Projeto | O que faz aqui |
+| :--------------------------------------------------- | :-------------------------------------------------------------------- |
+| [Vue](https://vuejs.org) | O modelo de componentes em que tudo isto está escrito |
+| [Nuxt](https://nuxt.com) | A framework, e o mecanismo de camadas em que toda a ideia assenta |
+| [Nuxt Content](https://content.nuxt.com) | Ir buscar, analisar, consultar — incluindo os repositórios git nativos |
+| [shadcn-vue](https://www.shadcn-vue.com) | A base de componentes, copiada para a camada em vez de importada |
+| [reka-ui](https://reka-ui.com) | As primitivas por baixo deles: foco, roving tabindex, ARIA |
+| [Tailwind CSS](https://tailwindcss.com) | O sistema de estilos e a camada de tokens |
+| [Shiki](https://shiki.style) | Realce de sintaxe, em tempo de compilação |
+| [Lucide](https://lucide.dev) | O conjunto de ícones |
+| [MDC](https://content.nuxt.com/docs/files/markdown) | Componentes invocáveis a partir do Markdown |
+
+A versão de cada um está no `package.json`, que é onde um número pertence — uma
+segunda cópia em prosa é uma cópia que envelhece em silêncio.
+
+## De que partiu
+
+Ideias que o duxt tirou de projetos sobre os quais não assenta. Aquilo em que
+*assenta* é a tabela acima — nada é nomeado duas vezes.
+
+- [**shadcn/ui**](https://ui.shadcn.com) — a ideia original: componentes que são
+ teus como ficheiros em vez de importados como dependência. Cada componente
+ desta camada está aqui por causa dela.
+- [**shadcn-docs-nuxt**](https://shadcn-docs-nuxt.vercel.app) — o vizinho mais
+ próximo, e a prova de que vale a pena ter um modelo de documentação sobre Nuxt
+ Content e shadcn-vue.
+- [**Docus**](https://docus.dev) — a ergonomia original de «estende uma camada,
+ ficas com um site de documentação» no ecossistema Nuxt.
+- [**Nuxt UI**](https://ui.nuxt.com) — pelo lado legível por máquinas:
+ `llms.txt` e um endpoint de documentação que um agente pode chamar, tratados
+ como resultado da compilação e não como um extra.
+- [**VitePress**](https://vitepress.dev) e
+ [**Starlight**](https://starlight.astro.build) — pelo que um tema de
+ documentação deve por omissão a quem lê: um seletor de versões que sobrevive à
+ navegação, um índice que acompanha, pesquisa que está lá sem configuração.
+
+::callout{type="tip" title="Onde o duxt difere"}
+Todos os projetos acima documentam um repositório numa versão. A razão de existir
+do duxt começa onde isso pára: vários repositórios, várias versões de cada um, e
+uma lista `sources` a gerar as coleções para todos eles — ver
+[Fontes](/concepts/sources).
+::
+
+## O que não está creditado aqui
+
+Duas coisas estão deliberadamente ausentes. As dependências da própria camada
+estão listadas no `package.json` e não precisam de uma segunda cópia mantida à
+mão; e quem escreveu uma dada página é nomeado nessa página, a partir do
+histórico git por trás dela, e não numa lista aqui que ficaria desatualizada no
+commit seguinte.
diff --git a/docs/pt/6.conventions/1.branding.md b/docs/pt/6.conventions/1.branding.md
new file mode 100644
index 00000000..757d0b4e
--- /dev/null
+++ b/docs/pt/6.conventions/1.branding.md
@@ -0,0 +1,83 @@
+---
+title: Identidade
+description: A marca [d]uxt — onde é usado cada recurso, por que os parênteses do ícone foram redesenhados, e que cores são fixas.
+icon: lucide:shapes
+---
+
+::callout{type="warning" title="Arte provisória"}
+O logótipo é trabalho provisório assistido por IA — desenhado numa sessão com um
+agente de programação com IA, não por uma pessoa designer, e um substituto a
+trocar a certa altura, sem prazo fixo. Nenhum gerador de imagens esteve envolvido:
+é composto tipograficamente, não desenhado.
+::
+
+A marca é o nome do pacote com a primeira letra entre parênteses retos — `[d]uxt` —
+porque é assim que o pacote se escreve onde é usado:
+
+```ts
+extends: ['@kirchdev/duxt'];
+```
+
+Um array com uma entrada. Está composta em [IBM Plex Mono](https://github.com/IBM/plex)
+SemiBold e convertida em contornos, para que nenhum recurso dependa de a fonte
+estar instalada em lado nenhum.
+
+## Os recursos
+
+| Recurso | Caminho | Usado por |
+| :-------------------- | :------------------------------------------------------------ | :----------------------------------- |
+| Logótipo | `.github/assets/wordmark-light.svg`, `wordmark-dark.svg` | O hero do README, via `` |
+| Ícones do site | `www/public/favicon.svg`, `apple-touch-icon.png` (180×180) | O separador do navegador |
+| Logótipo no site | `www/public/wordmark.svg`, `wordmark-dark.svg` | `duxt.logo` em `www/` |
+
+O logótipo do README estreita as células dos parênteses em 120 unidades
+tipográficas: o Plex Mono dá ao `[` uma célula de 600 unidades para 311 unidades de
+tinta, e à largura monoespaçada completa os parênteses ficam num espaço de palavra
+só deles.
+
+O favicon leva a sua própria regra `prefers-color-scheme`, para que ambas as cores
+clareiem contra uma barra de separadores escura. O PNG existe só porque o iOS
+ignora ícones SVG.
+
+## Os parênteses do ícone estão redesenhados, o `d` não
+
+O parêntese do Plex mede 311 unidades contra um `d` de 486, e a 16 px essa divisão
+fecha o olho numa mancha. O ícone mantém a haste (104) e o braço (88) da fonte, mas
+encurta o braço para 185, o que quadra a marca de 1,45∶1 para 1,13∶1 e dá ao olho
+os pixéis de que precisa.
+
+O logótipo não tem essa restrição e continua Plex puro. É toda a diferença entre os
+dois ficheiros e a razão por que não podem ser gerados a partir de uma só fonte.
+
+## Cores
+
+Os parênteses levam a cor; as letras tomam a cor do texto em redor. Só o par de
+parênteses é fixo.
+
+| Função | Hex | OKLCH |
+| :------------------- | :-------- | :----------------------- |
+| Parênteses (claro) | `#4F46E5` | `oklch(0.511 0.230 277)` |
+| Parênteses (escuro) | `#7D7BF5` | `oklch(0.644 0.178 281)` |
+| Letras (claro) | `#15171E` | `oklch(0.206 0.014 273)` |
+| Letras (escuro) | `#E8EBF2` | `oklch(0.940 0.010 267)` |
+
+## Licenciamento
+
+O IBM Plex está sob [SIL OFL 1.1](https://github.com/IBM/plex/blob/master/LICENSE.txt),
+e a cláusula 5 resolve este caso explicitamente: _"The requirement for fonts to
+remain under this license does not apply to any document created using the Font
+Software."_ Um SVG com contornos é um desses documentos, nenhum ficheiro de fonte é
+redistribuído, e o nome reservado "Plex" não aparece em recurso nenhum. Nada tem de
+ser pago, atribuído ou relicenciado — a menção acima é cortesia.
+
+## Nada disto é publicado
+
+::callout{type="warning" title="A camada não leva identidade"}
+`duxt.logo` não está definido por omissão, e o `DuxtBrand` recai no próprio
+`duxt.title` do consumidor ao lado de um ícone genérico. Um site que estende o duxt
+mostra o seu próprio nome no cabeçalho e no rodapé e o seu próprio ícone no
+separador, nunca este. Todos os recursos acima pertencem a este repositório e a
+`www/` — nenhum está no pacote publicado.
+::
+
+Definir a tua é [Dá identidade ao teu site](/guides/brand-your-site).
diff --git a/docs/pt/6.conventions/index.md b/docs/pt/6.conventions/index.md
new file mode 100644
index 00000000..45a30515
--- /dev/null
+++ b/docs/pt/6.conventions/index.md
@@ -0,0 +1,18 @@
+---
+title: Convenções
+description: Regras próprias do projeto — as que não se deduzem do código.
+icon: lucide:ruler
+---
+
+Regras a que este projeto se obriga, escritas porque o código não as consegue
+enunciar. Vinculam o trabalho dentro deste repositório; nada aqui restringe um
+site que estenda a camada.
+
+Uma regra diz o que se faz; uma [decisão de arquitetura](/adr) diz por que foi
+escolhida em vez das alternativas, e continua legível depois de o raciocínio ter
+saído da cabeça de toda a gente. O registo de decisões é apenas de adição — por
+isso fica ao lado destas páginas e não entre elas: estas são editadas quando
+mudam.
+
+::page-cards
+::
diff --git a/docs/pt/99.adr/0001-build-duxt-as-a-layer-carrying-a-module.md b/docs/pt/99.adr/0001-build-duxt-as-a-layer-carrying-a-module.md
new file mode 100644
index 00000000..97b1afd2
--- /dev/null
+++ b/docs/pt/99.adr/0001-build-duxt-as-a-layer-carrying-a-module.md
@@ -0,0 +1,62 @@
+---
+title: Construir o duxt como uma camada que transporta um módulo
+description: Distribuir o tema como uma camada Nuxt cuja raiz é o pacote, e não como um modelo inicial.
+status: accepted
+date: 2026-09-06
+---
+
+## Contexto
+
+Um tema de documentação pode ser distribuído de duas maneiras. Um **modelo
+inicial** é gerado dentro do repositório de quem o consome, onde cada ficheiro
+passa a ser seu para editar e nenhuma melhoria lhe volta a chegar a não ser como
+um diff que alguém aplica à mão. Uma **camada** continua a ser uma dependência:
+quem a consome estende-a, substitui os ficheiros de que discorda, e leva o resto
+das melhorias com um salto de versão.
+
+Parte do que o tema tem de fazer não é algo que uma camada consiga exprimir como
+ficheiros. Gerar coleções, resolver uma lista de fontes em prefixos de URL,
+transformar frontmatter em regras de rota e validar o resultado é trabalho de
+tempo de compilação, e trabalho de tempo de compilação no Nuxt é um módulo.
+
+## Decisão
+
+Distribuímos o duxt como uma **camada Nuxt que transporta os seus próprios
+módulos**, consumida com uma entrada `extends`. A raiz do repositório *é* a
+camada: a configuração do Nuxt, a configuração do Content e `app/` ficam na
+raiz, e o manifesto do pacote aponta para eles, por isso
+`extends: ['@kirchdev/duxt']` resolve sem passo de compilação. O site que a
+consome vive ao lado dela no mesmo repositório e é o alvo de desenvolvimento.
+
+## Consequências
+
+Quem a consome herda tema, páginas, componentes, predefinições de configuração e
+coleções, e substitui qualquer um deles criando um ficheiro com o mesmo nome.
+Atualizar é um salto de versão.
+
+Nada que seja relativo à camada resolve como se lê. Um caminho escrito na camada
+é lido a partir do diretório de quem a consome, a não ser que tenha sido
+resolvido contra a localização da própria camada, e o alias `@` pertence a quem
+estende a camada, não à camada — por isso as importações da própria camada
+precisam de um alias próprio. Isto já custou bugs reais, e é o preço do arranjo
+e não um descuido.
+
+Os nomes substituíveis tornam-se superfície pública. Um componente, uma página
+ou uma chave de configuração que quem a consome pode sombrear é um nome do qual
+depende, por isso renomear um é um lançamento incompatível, e é a superfície
+documentada que o fixa.
+
+O site de desenvolvimento e um modelo inicial são artefactos separados. O site
+ao lado da camada quer casos-limite, frontmatter feio, várias fontes e uma tag
+de onde ler; um estranho que clone um modelo inicial quer o contrário.
+Confundi-los tornaria um dos dois mau.
+
+## Alternativas consideradas
+
+**Um modelo inicial.** Liberdade total para quem o consome, nenhum caminho de
+atualização para ninguém — a razão por que a camada ganhou.
+
+**Um módulo sem camada.** Um módulo consegue registar componentes e rotas, mas a
+substância do tema são ficheiros que quem a consome tem de conseguir sombrear, e
+entregá-los através de um módulo significa injetá-los em vez de deixar a
+resolução de camadas do próprio Nuxt fazê-lo.
diff --git a/docs/pt/99.adr/0002-generate-the-collections-from-one-source-list.md b/docs/pt/99.adr/0002-generate-the-collections-from-one-source-list.md
new file mode 100644
index 00000000..abc6d691
--- /dev/null
+++ b/docs/pt/99.adr/0002-generate-the-collections-from-one-source-list.md
@@ -0,0 +1,57 @@
+---
+title: Gerar as coleções a partir de uma só lista de fontes
+description: Calcular as coleções do Content a partir de uma lista de fontes compacta ao carregar a configuração, em vez de fazer quem a consome declará-las.
+status: accepted
+date: 2026-09-06
+---
+
+## Contexto
+
+O Nuxt Content vai buscar uma coleção a um repositório git num ramo ou numa tag,
+autentica-se contra um repositório privado e guarda a descarga em cache por
+hash. Isso foi verificado lendo o próprio Content antes de nada disto ser
+construído, e significa que a metade difícil da documentação versionada e
+multirrepositório já existia e não precisava de ser refeita.
+
+O que não existia era a ergonomia. Um site que serve várias versões de vários
+projetos declara uma coleção por repositório × ref, à mão: três versões em
+catorze repositórios são quarenta e duas declarações, e cortar um lançamento
+edita as catorze. O Content também não oferece nenhum hook para injetar
+coleções — mas carrega a configuração de conteúdo de cada camada através do c12,
+o que faz desse ficheiro código executado e não um ficheiro de dados, podendo
+calcular as suas coleções quando é carregado.
+
+## Decisão
+
+Quem a consome declara uma **lista de fontes** compacta — uma pasta,
+opcionalmente um repositório, opcionalmente refs — na configuração de aplicação
+do próprio site, e a configuração de conteúdo da camada calcula a partir dela
+uma coleção por fonte × ref no carregamento. A mesma lista é resolvida uma
+segunda vez pela compilação num manifesto que nomeia que coleção serve que
+prefixo de URL, e é esse manifesto que o tema lê.
+
+## Consequências
+
+A lista tem o comprimento do número de projetos e não o do produto de projetos
+por versões, e a pasta única sem versões não precisa de configuração nenhuma.
+
+O atalho exprime menos do que uma coleção escrita à mão, e vai exprimir sempre.
+Isso só é suportável porque o Content combina a configuração de conteúdo de cada
+camada, ganhando a posterior: quem a consome e precise de algo que o atalho não
+consegue dizer escreve um ficheiro próprio e assume por completo.
+
+Várias funcionalidades deixam de precisar de configuração própria, porque uma
+fonte já nomeia um repositório, uma ref e uma pasta — as ligações de volta à
+fonte, a data da última alteração e a lista de quem contribuiu derivam todas
+dela.
+
+Os nomes das coleções tornam-se dados. Um site com dois repositórios não tem
+nenhuma coleção chamada `docs`, por isso nada na camada pode nomear uma, e o
+tema lê antes o nome a partir do manifesto. Código que fixa um nome de coleção
+funciona num site de fonte única e quebra em todos os outros.
+
+Os caminhos das coleções resolvem contra a camada e não contra quem a consome,
+porque o Content regista como raiz de uma coleção a camada que a declarou. A
+camada calcula por isso caminhos absolutos, o que só é possível porque a
+configuração é código executado — a mesma propriedade sobre a qual assenta todo
+o atalho.
diff --git a/docs/pt/99.adr/0003-decide-the-url-prefixes-at-build-time.md b/docs/pt/99.adr/0003-decide-the-url-prefixes-at-build-time.md
new file mode 100644
index 00000000..83d69b68
--- /dev/null
+++ b/docs/pt/99.adr/0003-decide-the-url-prefixes-at-build-time.md
@@ -0,0 +1,49 @@
+---
+title: Decidir os prefixos de URL em tempo de compilação
+description: Se um segmento de repositório ou de versão aparece é decidido pela lista de fontes antes do primeiro pedido, nunca por pedido.
+status: accepted
+date: 2026-09-06
+---
+
+## Contexto
+
+Um site construído a partir de várias fontes tem de servir um segmento de
+repositório e um segmento de versão nos seus URL, e um site construído a partir
+de uma só pasta não pode — ninguém quer `/my-project/main/guides/deploying` para
+um projeto com uma única pasta de documentação sem versões.
+
+Tornar cada segmento opcional por pedido não funciona. Com ambos opcionais, o
+primeiro segmento de `/guides/…` podia ser uma pasta, um repositório ou uma
+versão, e só consultando os três se saberia qual. Essa ambiguidade não é um
+incómodo de encaminhamento; faz o significado de um URL depender do que por
+acaso existe.
+
+## Decisão
+
+Cada prefixo é ligado **para o site inteiro, em tempo de compilação, a partir da
+lista de fontes**: um segmento de repositório assim que há mais do que um
+repositório publicado ou uma opção o forçar, um segmento de versão assim que uma
+fonte publica mais do que uma ref ou uma opção o forçar. Uma ref por repositório
+é servida sem segmento de versão nenhum.
+
+## Consequências
+
+A forma de cada URL fica fixada antes do primeiro pedido, por isso o router
+nunca adivinha e uma ligação escrita numa página pode ser resolvida por uma
+verificação na compilação em vez de por tentativa.
+
+Uma única pasta sem versões serve caminhos que não denunciam que repositórios ou
+versões sequer existem, e é isso que torna gratuito o caso mais simples.
+
+Passar um site de uma fonte para duas muda todos os URL que serve. Isso é uma
+migração, e é a maquinaria de redirecionamentos que a torna suportável.
+
+Resta uma colisão que não se consegue projetar para fora: uma pasta de
+documentação com o nome de um repositório ou de um segmento de versão, onde o
+prefixo ganha e a pasta fica inalcançável. A compilação rejeita-a com uma
+mensagem em vez de a resolver em silêncio para um dos lados.
+
+Como o prefixo de uma página só é conhecido pela compilação, as ligações dentro
+das páginas são escritas como caminhos de documentação nus e resolvidas contra a
+fonte da página quando são apresentadas. Uma página que fixa o seu próprio
+prefixo está correta em exatamente um site.
diff --git a/docs/pt/99.adr/0004-render-markdown-components-with-mdc.md b/docs/pt/99.adr/0004-render-markdown-components-with-mdc.md
new file mode 100644
index 00000000..07e3d634
--- /dev/null
+++ b/docs/pt/99.adr/0004-render-markdown-components-with-mdc.md
@@ -0,0 +1,49 @@
+---
+title: Desenhar os componentes Markdown com MDC
+description: Usar a própria sintaxe MDC do Content para componentes dentro do Markdown, em vez de adotar MDX.
+status: accepted
+date: 2026-09-06
+---
+
+## Contexto
+
+Um tema de documentação precisa de componentes dentro da prosa — callouts,
+código em separadores, campos de parâmetros, árvores. Havia duas sintaxes
+disponíveis. O **MDX** compila o Markdown num módulo de componente e deixa uma
+página importar e escrever JSX; o **MDC** é a sintaxe de componentes em bloco e
+em linha que o Content já traz e já analisa.
+
+As páginas também são lidas por algo que não é um navegador. O tema publica o
+Markdown de origem de cada página para os modelos, por isso a sintaxe em que as
+páginas estão escritas é aquilo que um modelo recebe.
+
+## Decisão
+
+Os componentes em Markdown são **MDC**. Os componentes que uma página pode
+chamar vivem num diretório de conteúdo dedicado, onde um ficheiro de quem a
+consome com o mesmo nome substitui o da camada.
+
+## Consequências
+
+Não é preciso instalar nem configurar nada para uma página chamar um componente,
+e uma página continua a ser um ficheiro Markdown em vez de se tornar um módulo.
+
+Um bloco MDC sobrevive a ser entregue a um modelo como texto: lê-se como uma
+chamada de componente com argumentos nomeados. JSX compilado não sobreviveria.
+
+A sintaxe é do Content, por isso as suas capacidades e os seus limites também
+são do Content. Tudo o que uma página queira e o MDC não consiga exprimir tem de
+ser resolvido como um componente e não como uma expressão na página — o que é
+uma restrição para quem escreve e, num site de documentação, uma restrição
+desejável.
+
+Os nomes dos componentes no diretório de conteúdo fazem parte da superfície
+pública, já que uma página escrita para um deles é um ficheiro de quem a
+consome.
+
+## Alternativas consideradas
+
+**MDX.** Mais expressivo, e cada bocado dessa expressividade é JavaScript numa
+página de documentação. Precisaria ainda de um módulo e de um caminho de
+compilação que o Content não tem, para acabar por ter páginas que são piores de
+entregar a um modelo.
diff --git a/docs/pt/99.adr/0005-ship-the-layer-without-owner-specific-links.md b/docs/pt/99.adr/0005-ship-the-layer-without-owner-specific-links.md
new file mode 100644
index 00000000..8a7319b1
--- /dev/null
+++ b/docs/pt/99.adr/0005-ship-the-layer-without-owner-specific-links.md
@@ -0,0 +1,44 @@
+---
+title: Entregar a camada sem ligações específicas do proprietário
+description: Toda a predefinição que nomearia um projeto concreto — repositório, gestor de issues, comunidade, aviso legal — é entregue vazia.
+status: accepted
+date: 2026-09-06
+---
+
+## Contexto
+
+Um tema de documentação desenha várias linhas de ligações: ligações com ícone na
+barra de navegação, um bloco de comunidade ao lado do índice, ligações legais no
+rodapé, botões na página inicial. Preenchê-las com o projeto do próprio tema faz
+um site de demonstração parecer acabado — e entrega a quem o consome um botão
+«dar estrela a este repositório» que marca o trabalho de outra pessoa, uma
+comunidade que não é a sua e um aviso legal que é juridicamente errado para si.
+
+Foi a linha legal do rodapé que fixou o princípio primeiro, porque um site alemão
+tem de mostrar um aviso legal e fornecê-lo não cabe, sem margem para dúvidas, ao
+modelo.
+
+## Decisão
+
+Qualquer predefinição que nomeasse um projeto ou uma organização concretos é
+entregue **vazia**. As ligações do próprio tema vivem na configuração do site que
+o consome, onde são um exemplo e não uma predefinição. Os elementos genéricos da
+interface que não nomeiam ninguém — um cabeçalho de coluna, uma ação «ler a
+documentação» — ficam na camada.
+
+## Consequências
+
+Um estranho que estenda a camada recebe uma linha vazia em vez de uma errada,
+e uma linha vazia nota-se em falta enquanto uma ligação errada parece correta.
+
+O site de demonstração transporta mais configuração do que precisa quem a consome
+de forma mínima, e é essa a função do site: é o exemplo resolvido de cada linha
+que quem a consome tem de preencher.
+
+As chaves de mensagens da própria camada ficam para trás quando as suas ligações
+saem, e essas chaves são internas. Quem a consome e lhes fosse buscar valores
+ficaria dependente de um nome que pode ser renomeado sem um lançamento maior, e
+uma chave em falta é impressa como a própria chave — por isso a quebra chegaria a
+um leitor antes de chegar a uma compilação. Quem a consome escreve as suas
+próprias cadeias de texto; a
+[página de configuração](/getting-started/configuration) diz como.
diff --git a/docs/pt/99.adr/0006-rebuild-on-a-schedule-rather-than-refresh-at-runtime.md b/docs/pt/99.adr/0006-rebuild-on-a-schedule-rather-than-refresh-at-runtime.md
new file mode 100644
index 00000000..1b0e8a9b
--- /dev/null
+++ b/docs/pt/99.adr/0006-rebuild-on-a-schedule-rather-than-refresh-at-runtime.md
@@ -0,0 +1,36 @@
+---
+title: Recompilar de forma agendada em vez de atualizar em tempo de execução
+description: Um site que lê outro repositório apanha as suas alterações quando compila, e não é oferecida nenhuma atualização em tempo de execução.
+status: accepted
+date: 2026-09-06
+---
+
+## Contexto
+
+Um site que lê documentação a partir de outros repositórios tem um desejo óbvio:
+apanhar um push sem um deploy. Só que o Content descarrega um repositório remoto
+durante a compilação e compila-o para a base de dados que a compilação entrega.
+Atualizar isso num servidor a correr significaria reconstruir a base de dados no
+lugar — para o que o Content não oferece nenhum caminho suportado, e a única via
+disponível é a mesma base de dados do lado do cliente que a pesquisa lê.
+
+## Decisão
+
+O Content é lido em tempo de compilação e nunca atualizado em tempo de execução.
+Um site cujas fontes avançaram é recompilado: de forma agendada, ou desencadeado
+a partir do repositório de origem.
+
+## Consequências
+
+Um deployment é imutável e uma compilação é reproduzível — as páginas servidas
+são exatamente as páginas que foram compiladas, e o mesmo commit produz o mesmo
+site. A saída estática continua possível, o que é a predefinição certa para
+documentação.
+
+A documentação fica atrás da sua fonte pelo intervalo de recompilação, e um
+repositório de origem que queira a sua documentação em direto tem de desencadear
+a compilação do site. É esse o custo, e é pago em operações e não na camada.
+
+Nada no tema pode assumir que consegue reler uma fonte. Uma funcionalidade que
+queira conteúdo mais recente do que aquele que a compilação tem está a pedir uma
+arquitetura diferente, não uma opção de configuração.
diff --git a/docs/pt/99.adr/0007-serve-translations-as-collections-of-their-own.md b/docs/pt/99.adr/0007-serve-translations-as-collections-of-their-own.md
new file mode 100644
index 00000000..8bfe69f0
--- /dev/null
+++ b/docs/pt/99.adr/0007-serve-translations-as-collections-of-their-own.md
@@ -0,0 +1,103 @@
+---
+title: Servir as traduções como coleções próprias
+description: Acrescentar uma dimensão de locale a uma fonte, em vez de um segmento de locale ao caminho de conteúdo.
+status: accepted
+date: 2026-09-06
+---
+
+## Contexto
+
+A camada traduzia a sua interface em sete locales e servia um só conjunto de
+páginas a todos eles: `useDuxtPath()` retirava o segmento de locale antes de cada
+consulta de conteúdo, por isso `/de-DE/guides/deploying` e `/guides/deploying`
+resolviam para o mesmo ficheiro. O Content v3 não tem noção de locale — uma
+coleção é uma árvore — por isso as páginas traduzidas precisavam de uma decisão e
+não de uma opção de configuração.
+
+O que os geradores comparáveis fazem foi lido, não presumido. Starlight,
+VitePress, Docusaurus e MkDocs põem todos as traduções numa pasta por idioma; só
+o Starlight tem um recurso documentado para uma página que falta a um idioma. A
+partir de certa dimensão a tradução sai por completo da ferramenta: o React
+mantém `de.react.dev` como repositório próprio, o Vue uma organização
+`vuejs-translations` inteira, porque quem traduz trabalha ao seu próprio
+calendário e à sua própria revisão. O OpenCode construiu um agente que traduzia a
+sua documentação em CI, correu-o e desligou-o; dezassete idiomas ficaram parados
+desde então.
+
+O custo foi medido antes de a forma ser escolhida: compilações de 1 a 200
+coleções escalam linearmente, a cerca de 2,2 s e 0,63 MB de base de dados cada,
+sem nenhum joelho na curva. A matriz não tem um teto que forçasse a mão ao
+desenho.
+
+## Decisão
+
+Uma fonte ganha uma lista `locales`, e uma ref também, resolvida como o `status`
+já é (`ref.locales ?? source.locales`). Uma string é uma pasta dentro do `path`
+da fonte; um objeto move esse idioma para uma pasta, um repositório ou uma ref
+próprios.
+
+**O locale por omissão é a própria árvore em `path`, sem pasta**, por isso
+acrescentar a chave não move nenhum URL que um site já sirva.
+
+**O locale não faz parte do caminho de conteúdo.** Pertence ao encaminhamento do
+i18n, que de qualquer maneira o coloca à frente do caminho. Original e tradução
+vivem por isso sob caminhos de conteúdo idênticos, em coleções separadas.
+
+Uma página que falte num idioma recai ao longo de uma cadeia — o locale, a sua
+língua base, uma região irmã, o `fallbackLocale` do vue-i18n, o original por
+traduzir — e o leitor é informado, num banner, em que idioma lhe está a ser
+mostrada.
+
+## Consequências
+
+Todas as comparações de caminhos no tema ficam intactas: a navegação, os
+redirecionamentos, o trilho, a pontuação da página mais próxima do 404 e o
+seletor de idioma continuam todos a funcionar sobre um caminho que nunca
+transportou um locale. O recurso é mais uma consulta ao mesmo caminho, e não um
+redirecionamento ou um segundo esquema de resolução.
+
+`useDuxtNavigation` e a pesquisa seguem `useDuxtCollection`, por isso ambos
+passaram a conhecer o locale sem serem alterados.
+
+Um site que não define nada obtém exatamente o que tinha: uma coleção chamada
+`docs`, uma entrada no manifesto, uma consulta por página.
+
+Duas configurações podem agora divergir de uma maneira que produz uma página
+vazia em vez de um erro — `content.config.ts` resolve o locale por omissão sem
+acesso à configuração do Nuxt. O módulo duxt verifica por isso
+`sourceOptions.defaultLocale` contra `i18n.defaultLocale` e faz falhar a
+compilação quando divergem, em vez de injetar um no outro e deixar o Content a
+calcular a outra resposta.
+
+As traduções multiplicam as coleções, e a compilação paga por cada uma
+linearmente. O número pertence à documentação, porque é quem a consome que decide
+a matriz.
+
+Os partials seguem as páginas. `_partials/` é uma coleção POR IDIOMA, nomeada
+como as coleções de páginas — `duxt_partials` para o original, `duxt_partials_de`
+ao lado — e `:partial{name}` percorre a mesma cadeia de recurso que a página
+percorreu. Tem de ser a mesma cadeia: uma página e os blocos que ela inclui a
+recorrerem a idiomas diferentes é exatamente como se chega a uma página meio
+traduzida sem que nada o diga.
+
+A primeira versão desta decisão partilhava UMA coleção de partials por traduzir
+entre todos os idiomas. Era defensável enquanto nada traduzia um partial, e
+deixou de o ser no momento em que uma passagem de tradução produziu
+`docs/de/_partials/` — ficheiros que nenhuma coleção lia, e uma página alemã a
+apresentar uma nota de instalação em inglês sem sinal nenhum de que tal tinha
+acontecido.
+
+## Alternativas consideradas
+
+**Um segmento de locale no prefixo de conteúdo.** Simétrico com `repo` e
+`version`, e teria obrigado todas as comparações de caminhos no tema a aprender
+sobre locales — para um URL que o i18n já prefixa, e que ficaria assim escrito
+duas vezes.
+
+**Um sufixo no ficheiro — `installation.de-DE.md` ao lado do original.** Nenhuma
+multiplicação de coleções, e falha no caso que os projetos grandes têm de facto:
+obriga a tradução a viver no mesmo repositório e na mesma ref que o original.
+
+**404 para uma tradução em falta.** O que o VitePress faz por não fazer nada.
+Castiga o leitor por uma lacuna que quem escreveu deixou, e esconde de toda a
+gente que a tradução está incompleta.
diff --git a/docs/pt/99.adr/0008-build-the-theme-on-owned-shadcn-vue-components.md b/docs/pt/99.adr/0008-build-the-theme-on-owned-shadcn-vue-components.md
new file mode 100644
index 00000000..397025c5
--- /dev/null
+++ b/docs/pt/99.adr/0008-build-the-theme-on-owned-shadcn-vue-components.md
@@ -0,0 +1,72 @@
+---
+title: Construir o tema sobre componentes shadcn-vue próprios
+description: A camada guarda o código-fonte dos seus componentes de interface em vez de os importar de uma biblioteca de componentes ou de um tema de documentação já feito.
+status: accepted
+date: 2026-09-08
+---
+
+## Contexto
+
+Uma camada de documentação é sobretudo interface: um cabeçalho, uma barra
+lateral, um índice, blocos de código, uma caixa de pesquisa. Alguma coisa tem de
+os desenhar, e essa escolha decide quanto um consumidor pode alterar sem
+bifurcar.
+
+Toda a proposta da camada é ser estendida em vez de gerada —
+`extends: ['@kirchdev/duxt']`, e cada ficheiro continua substituível. Um tema cuja
+aparência só fosse alcançável pelas opções em que o seu autor pensou contradiria
+isso logo na primeira coisa que um consumidor quisesse diferente. A resolução de
+camadas do Nuxt já dá ao consumidor a substituição ao nível do ficheiro — a
+pergunta era o que deviam ser esses ficheiros.
+
+## Decisão
+
+A camada possui o código-fonte dos seus componentes. O shadcn-vue serve de fonte
+das primitivas: a sua CLI escreve o código de um componente no repositório e, a
+partir daí, o ficheiro pertence à camada e não a uma dependência. O
+`components.json` aponta a CLI ao alias da própria camada, portanto acrescentar
+uma primitiva é um comando, e o Tailwind fornece o estilo por baixo.
+
+A paleta são propriedades personalizadas de CSS. Os componentes leem tokens e não
+guardam cores, que é o que faz uma substituição de uma linha chegar a todas as
+superfícies.
+
+## Consequências
+
+Um consumidor pode alterar qualquer parte da interface, à profundidade que a
+alteração exigir: redefinir um token, sombrear um componente pelo nome ou
+acrescentar uma primitiva própria. Nada disso exige uma bifurcação, e nada espera
+que a camada exponha uma opção para o efeito.
+
+O custo é a manutenção. Uma correção a uma primitiva não chega com uma subida de
+versão — chega quando alguém volta a correr a CLI para esse componente. A camada
+assume isso pelas primitivas que entrega; um consumidor que substitui uma assume-o
+a partir de então. É em torno dessa troca que o guia de substituição está
+construído, e é a razão de ele existir.
+
+Os ficheiros copiados que a CLI não alcança são o gume da mesma troca: uma folha
+de estilo sem entrada de registo só pode ser atualizada descarregando-a de novo,
+pelo que as alterações locais se perdem em silêncio em vez de colidirem
+ruidosamente.
+
+## Alternativas consideradas
+
+**Um tema de documentação já feito.** O Docus é o encaixe óbvio — o tema do próprio
+Nuxt Content, e tudo o que um site de documentação precisa no primeiro arranque.
+Rejeitado pela mesma razão que o torna atraente: o site que produz é o do seu
+autor, e remodelá-lo significa ou uma opção que existe ou uma bifurcação. Uma
+camada cujos consumidores deviam distinguir-se uns dos outros não podia aceitar
+esse teto.
+
+**Uma biblioteca de componentes como dependência.** O Nuxt UI teria fornecido as
+primitivas sem a manutenção, e a camada teria acompanhado as suas versões em vez
+de copiar código. Rejeitado porque os componentes de uma dependência só se podem
+alterar até onde as suas props permitem, e a camada teria então de inventar um
+segundo mecanismo de substituição para o resto — com dois, nenhum seria o óbvio a
+que recorrer.
+
+**Componentes escritos à mão, sem upstream nenhum.** Isto elimina por completo a
+questão da dependência e foi rejeitado pelo custo: as primitivas acessíveis são
+difíceis exatamente nos pontos em que é fácil errar, e uma caixa de diálogo ou uma
+combobox escritas de raiz seriam piores do que uma adaptada de uma fonte que já
+resolveu o problema.
diff --git a/docs/pt/99.adr/0009-take-the-seo-stack-from-the-nuxt-seo-bundle.md b/docs/pt/99.adr/0009-take-the-seo-stack-from-the-nuxt-seo-bundle.md
new file mode 100644
index 00000000..92e991f1
--- /dev/null
+++ b/docs/pt/99.adr/0009-take-the-seo-stack-from-the-nuxt-seo-bundle.md
@@ -0,0 +1,94 @@
+---
+title: Tirar a base de SEO do pacote Nuxt SEO
+description: A camada instala o @nuxtjs/seo e entrega-lhe as etiquetas de head e os dados estruturados que antes escrevia à mão, guardando apenas as regras que dependem de versões e traduções.
+status: accepted
+date: 2026-09-08
+---
+
+## Contexto
+
+A camada já distribuía três dos módulos do Nuxt SEO — robots, sitemap e imagem OG
+—, escolhidos um a um à medida que cada necessidade surgia. Tudo o que eles não
+cobrem estava escrito à mão: uma ligação canónica, um bloco `og:`/`twitter:` por
+página e um `@graph` de JSON-LD montado dentro de um literal de modelo em
+`[...slug].vue`.
+
+Funcionava e era invisível. É aí que está o problema: nada disso estava coberto
+por um teste, porque o SEO vive no HTML renderizado e não na lógica, e os testes
+deste repositório cobrem deliberadamente apenas lógica pura. A página inicial — a
+que mais provavelmente é partilhada — não tinha cartão social nenhum, a página de
+erro era indexável e sete idiomas eram publicados sem um único `og:locale`. Cada
+uma dessas falhas era uma omissão que ninguém conseguia ver.
+
+A metade escrita à mão era também a que cresce. Os dados estruturados são uma
+especificação de superfície ampla e com validadores próprios; cada nó acrescentado
+à mão é um nó cuja forma é preciso acertar lendo a especificação.
+
+## Decisão
+
+A camada depende do `@nuxtjs/seo` e carrega-o como um único módulo, no lugar onde
+estavam os três módulos nomeados — antes do `@nuxt/content`, porque a integração
+do sitemap com o Content assim o exige.
+
+O pacote é um alias, não um invólucro: a sua própria documentação afirma que «não
+contém lógica própria». O que traz são os quatro módulos que faltavam —
+`nuxt-schema-org` para o grafo, `nuxt-seo-utils` para a canónica automática e as
+etiquetas sociais derivadas, `nuxt-link-checker`, e `nuxt-site-config` como o
+único sítio de onde se lê `site.url` — mais o painel partilhado de devtools, que
+dá conta dos que estiverem instalados.
+
+Três predefinições do `nuxt-seo-utils` ficam desligadas, cada uma por uma razão
+que a camada não consegue contornar por desenho: `canonicalLowercase`, porque um
+prefixo de idioma distingue maiúsculas e `/de-DE/` não é `/de-de/`;
+`fallbackTitle`, porque um título inventado a partir de um slug taparia o
+validador de compilação que falha perante uma página sem título; e
+`mergeWithSiteConfig`, porque o `app.vue` é dono do modelo do título.
+
+Continua escrito à mão aquilo que os módulos não podem saber: a canónica de uma
+página versionada aponta para a versão atual e não para a página que está a ser
+renderizada, e `noindex` decorre de uma versão ser antiga ou de uma página ser
+servida num idioma para o qual não foi traduzida.
+
+A verificação de ligações relata em vez de falhar. O `modules/validate.ts` já faz
+uma compilação falhar perante uma ligação que não leva a lado nenhum, e é a
+verificação que percebe versões e recuos de idioma.
+
+## Consequências
+
+Cada consumidor da camada instala sete módulos onde instalava três. É o preço da
+decisão, e é pago também por sítios que não usem nenhum dos quatro novos.
+
+As regras que antes eram afirmações num comentário são agora asserções em
+`scripts/check-seo.ts`, que lê as páginas construídas e falha perante uma segunda
+canónica, um `hreflang` em falta, uma página de erro indexável ou um grafo que
+não se analisa. Corre no `check` ao lado do `check:a11y`, pela mesma razão: estas
+etiquetas só existem no HTML renderizado.
+
+Duas dessas regras não eram sequer verificáveis antes, porque só existem quando o
+sítio conhece a sua própria origem, e o `www` não declara domínio nenhum de
+propósito. A verificação entrega então ao servidor construído o seu próprio
+endereço, através das variáveis de ambiente que os módulos já leem, em vez de um
+domínio fixado numa configuração que um consumidor copiaria.
+
+Publicar uma `Organization` exige um facto que a camada não deve inventar, pelo
+que fica à espera de uma nova chave `duxt.organization` e permanece ausente até
+que um consumidor a preencha — a mesma postura da ADR-0005.
+
+## Alternativas consideradas
+
+**Manter os três módulos e acrescentar só o `nuxt-schema-org`.** A alteração mais
+estreita, descartada pela canónica: a regra de versão e a canónica automática têm
+de ser conciliadas de qualquer forma, e fazê-lo sem o `nuxt-seo-utils` significa
+manter o bloco `og:`/`twitter:` escrito à mão que já tinha sido esquecido em duas
+páginas.
+
+**Escrever os dados estruturados à mão e mantê-los.** Funcionava, não tinha
+dependência, e foi descartado porque o grafo já era a parte com mais
+probabilidade de estar errada e menos de dar nas vistas — e porque uma segunda
+página a pedir um segundo nó teria significado uma segunda cópia da identidade do
+sítio embutida nela.
+
+**Deixar o verificador de ligações partir a compilação.** Descartado porque dois
+guardiões sobre uma mesma regra deixam o mais permissivo decidir quando uma
+compilação parte. A verificação que percebe as versões e os recuos de idioma
+desta camada é a sua própria.
diff --git a/docs/pt/99.adr/index.md b/docs/pt/99.adr/index.md
new file mode 100644
index 00000000..63bc6df6
--- /dev/null
+++ b/docs/pt/99.adr/index.md
@@ -0,0 +1,24 @@
+---
+title: Decisões de arquitetura
+description: O registo de decisões — todas as decisões de arquitetura registadas para o duxt.
+icon: lucide:gavel
+---
+
+Uma decisão merece um ADR quando condiciona o trabalho que vem depois e o seu
+raciocínio se perderia de outro modo: uma escolha entre alternativas reais, uma
+convenção que todas as partes do projeto têm de seguir, um compromisso que parece
+um erro até se conhecer a razão. Os registos só admitem acrescentos — uma decisão
+revertida escreve-se como um novo ADR que substitui o antigo, nunca como uma
+edição dele.
+
+| ADR | Decisão | Estado | Data |
+| :------------------------------------------------------------------------- | :-------------------------------------------------------------------- | :------- | :--------- |
+| [ADR-0001](/adr/0001-build-duxt-as-a-layer-carrying-a-module) | Construir o duxt como uma camada que transporta um módulo | accepted | 2026-09-06 |
+| [ADR-0002](/adr/0002-generate-the-collections-from-one-source-list) | Gerar as coleções a partir de uma só lista de fontes | accepted | 2026-09-06 |
+| [ADR-0003](/adr/0003-decide-the-url-prefixes-at-build-time) | Decidir os prefixos de URL em tempo de compilação | accepted | 2026-09-06 |
+| [ADR-0004](/adr/0004-render-markdown-components-with-mdc) | Desenhar os componentes Markdown com MDC | accepted | 2026-09-06 |
+| [ADR-0005](/adr/0005-ship-the-layer-without-owner-specific-links) | Entregar a camada sem ligações específicas do proprietário | accepted | 2026-09-06 |
+| [ADR-0006](/adr/0006-rebuild-on-a-schedule-rather-than-refresh-at-runtime) | Recompilar de forma agendada em vez de atualizar em tempo de execução | accepted | 2026-09-06 |
+| [ADR-0007](/adr/0007-serve-translations-as-collections-of-their-own) | Servir as traduções como coleções próprias | accepted | 2026-09-06 |
+| [ADR-0008](/adr/0008-build-the-theme-on-owned-shadcn-vue-components) | Construir o tema sobre componentes shadcn-vue próprios | accepted | 2026-09-08 |
+| [ADR-0009](/adr/0009-take-the-seo-stack-from-the-nuxt-seo-bundle) | Tirar a base de SEO do pacote Nuxt SEO | accepted | 2026-09-08 |
diff --git a/docs/pt/_partials/install.md b/docs/pt/_partials/install.md
new file mode 100644
index 00000000..21dbb64f
--- /dev/null
+++ b/docs/pt/_partials/install.md
@@ -0,0 +1,6 @@
+---
+title: Instalação
+---
+
+::package-managers{command="add -D @kirchdev/duxt"}
+::
diff --git a/docs/pt/index.md b/docs/pt/index.md
new file mode 100644
index 00000000..649d780b
--- /dev/null
+++ b/docs/pt/index.md
@@ -0,0 +1,21 @@
+---
+title: Documentação
+description: Uma camada Nuxt que transforma a pasta docs de um repositório num site de documentação versionado e multirrepositório.
+icon: lucide:book-open-text
+navigation: false
+---
+
+Tudo o que o duxt faz, em quatro partes. Comece pela esquerda se for novo, ou
+escolha a parte que veio procurar.
+
+::page-cards
+::
+
+Duas coisas não estão aqui de propósito. Como contribuir para o próprio duxt
+está em
+[`CONTRIBUTING.md`](https://github.com/kirchDev/duxt/blob/main/CONTRIBUTING.md),
+e as decisões por trás da camada estão registadas nas
+[Decisões de arquitetura](/adr).
+
+Aquilo em que a camada assenta, e aquilo de que partiu, está nos
+[Créditos](/credits).
diff --git a/duxt-app-config.ts b/duxt-app-config.ts
index 86bd6ae1..872db21d 100644
--- a/duxt-app-config.ts
+++ b/duxt-app-config.ts
@@ -11,6 +11,15 @@ export interface DuxtBuildConfig {
sourceOptions?: DuxtSourcesOptions;
/** Which of the layer's locales this site serves. */
locales?: string[];
+ /**
+ * The site's name — also what the MCP server calls itself.
+ *
+ * Typed structurally rather than as `DuxtText`: this file is loaded by the
+ * build, outside the Nuxt runtime whose generated types carry that global.
+ * Same reason `scripts/check-previews.ts` declares the preview module's
+ * shape instead of importing it.
+ */
+ title?: string | Record;
}
/**
diff --git a/i18n/locales/de/duxt/defaults.json b/i18n/locales/de/duxt/defaults.json
index b3649273..788d1360 100644
--- a/i18n/locales/de/duxt/defaults.json
+++ b/i18n/locales/de/duxt/defaults.json
@@ -1,51 +1,16 @@
{
"duxt": {
"defaults": {
+ "title": "Dokumentation",
"navigation": {
- "docs": "Docs",
- "resources": "Ressourcen"
- },
- "resources": {
- "nuxt": "Das Framework darunter",
- "content": "Beschaffen, parsen und abfragen",
- "shadcn": "Die Komponentenbasis",
- "tailwind": "Das Styling-System",
- "mdc": "Komponenten in Markdown"
+ "docs": "Docs"
},
"landing": {
- "headline": "Dokumentation für Nuxt, versioniert und über mehrere Repositories",
- "description": "Einen Layer erweitern, und dein docs/-Ordner wird zur Website. Zeig damit auf andere Repositories oder auf Tags desselben, und daraus werden Versionen.",
"actions": {
"docs": "Dokumentation lesen"
},
"preview": "Live-Vorschau der Dokumentation",
"previewOpen": "Öffnen",
- "features": {
- "extend": {
- "title": "Erweitern statt generieren",
- "description": "Eine Zeile Konfiguration bringt Theme, Seiten und Komponenten — jede Datei bleibt überschreibbar."
- },
- "sources": {
- "title": "Quellen als Liste",
- "description": "Eine Deklaration pro Quelle statt einer Collection pro Version und Repository."
- },
- "git": {
- "title": "Git-nativ, nicht nachgebaut",
- "description": "Branches, Tags, private Repositories und Caching kommen aus Content v3 selbst."
- },
- "shadcn": {
- "title": "shadcn-vue",
- "description": "Komponenten werden kopiert, nicht importiert. Umgestalten heißt eine Datei bearbeiten."
- },
- "mdc": {
- "title": "Komponenten in Markdown",
- "description": "MDC kommt mit Content — eine Vue-Komponente per Block-Syntax aufrufen."
- },
- "machine": {
- "title": "Maschinenlesbar",
- "description": "llms.txt und eine MCP-Route über denselben Inhalt, geplant als Build-Ausgabe."
- }
- },
"featuresTitle": "Funktionen"
},
"aside": {
diff --git a/i18n/locales/de/duxt/devtools.json b/i18n/locales/de/duxt/devtools.json
new file mode 100644
index 00000000..4bdcbc1c
--- /dev/null
+++ b/i18n/locales/de/duxt/devtools.json
@@ -0,0 +1,7 @@
+{
+ "duxt": {
+ "devtools": {
+ "preview": "Das Devtools-Panel {tab}"
+ }
+ }
+}
diff --git a/i18n/locales/de/duxt/nav.json b/i18n/locales/de/duxt/nav.json
index 18a30526..e304f394 100644
--- a/i18n/locales/de/duxt/nav.json
+++ b/i18n/locales/de/duxt/nav.json
@@ -5,7 +5,6 @@
"sections": "Bereiche",
"previous": "Zurück",
"next": "Weiter",
- "anchor": "Link zu diesem Abschnitt",
"skip": "Zum Inhalt springen",
"main": "Hauptnavigation",
"docs": "Dokumentation",
diff --git a/i18n/locales/de/duxt/page.json b/i18n/locales/de/duxt/page.json
index 397d03d7..60fe1925 100644
--- a/i18n/locales/de/duxt/page.json
+++ b/i18n/locales/de/duxt/page.json
@@ -21,7 +21,11 @@
"claude": "In Claude öffnen",
"prompt": "Lies {url}, damit ich dir dazu Fragen stellen kann."
},
- "view": "Quelle ansehen"
+ "view": "Quelle ansehen",
+ "untranslated": {
+ "title": "Diese Seite ist noch nicht in deiner Sprache verfügbar",
+ "description": "Du liest sie auf {language}."
+ }
}
}
}
diff --git a/i18n/locales/en/duxt/defaults.json b/i18n/locales/en/duxt/defaults.json
index 4a0fc975..e09d5393 100644
--- a/i18n/locales/en/duxt/defaults.json
+++ b/i18n/locales/en/duxt/defaults.json
@@ -1,51 +1,16 @@
{
"duxt": {
"defaults": {
+ "title": "Documentation",
"navigation": {
- "docs": "Docs",
- "resources": "Resources"
- },
- "resources": {
- "nuxt": "The framework underneath",
- "content": "Sourcing, parsing and querying",
- "shadcn": "The component base",
- "tailwind": "The styling system",
- "mdc": "Components inside Markdown"
+ "docs": "Docs"
},
"landing": {
- "headline": "Documentation for Nuxt, versioned and multi-repo",
- "description": "Extend one layer and your docs/ folder becomes a site. Point it at other repositories, or at tags of the same one, and those become versions.",
"actions": {
"docs": "Read the docs"
},
"preview": "Live preview of the documentation",
"previewOpen": "Open",
- "features": {
- "extend": {
- "title": "Extend, don't scaffold",
- "description": "One line of config brings theme, pages and components — override any file."
- },
- "sources": {
- "title": "Sources as a list",
- "description": "One declaration per source instead of one collection per version and repo."
- },
- "git": {
- "title": "Git-native, not reinvented",
- "description": "Branches, tags, private repos and caching come from Content v3 itself."
- },
- "shadcn": {
- "title": "shadcn-vue",
- "description": "Components are copied in, not imported. Restyling one is editing a file."
- },
- "mdc": {
- "title": "Components in Markdown",
- "description": "MDC ships with Content — call a Vue component with block syntax."
- },
- "machine": {
- "title": "Machine-readable",
- "description": "llms.txt and an MCP route over the same content, planned as build output."
- }
- },
"featuresTitle": "Features"
},
"aside": {
diff --git a/i18n/locales/en/duxt/devtools.json b/i18n/locales/en/duxt/devtools.json
new file mode 100644
index 00000000..9b0b0f5c
--- /dev/null
+++ b/i18n/locales/en/duxt/devtools.json
@@ -0,0 +1,7 @@
+{
+ "duxt": {
+ "devtools": {
+ "preview": "The {tab} devtools panel"
+ }
+ }
+}
diff --git a/i18n/locales/en/duxt/nav.json b/i18n/locales/en/duxt/nav.json
index de1f1480..d9385d79 100644
--- a/i18n/locales/en/duxt/nav.json
+++ b/i18n/locales/en/duxt/nav.json
@@ -5,7 +5,6 @@
"sections": "Sections",
"previous": "Previous",
"next": "Next",
- "anchor": "Link to this section",
"skip": "Skip to content",
"main": "Main navigation",
"docs": "Documentation",
diff --git a/i18n/locales/en/duxt/page.json b/i18n/locales/en/duxt/page.json
index 2f8ec650..cebc8017 100644
--- a/i18n/locales/en/duxt/page.json
+++ b/i18n/locales/en/duxt/page.json
@@ -21,7 +21,11 @@
"claude": "Open in Claude",
"prompt": "Read {url} so I can ask you questions about it."
},
- "view": "View source"
+ "view": "View source",
+ "untranslated": {
+ "title": "This page is not available in your language yet",
+ "description": "You are reading it in {language}."
+ }
}
}
}
diff --git a/i18n/locales/es/duxt/defaults.json b/i18n/locales/es/duxt/defaults.json
index 9b578d70..df9584d9 100644
--- a/i18n/locales/es/duxt/defaults.json
+++ b/i18n/locales/es/duxt/defaults.json
@@ -1,51 +1,16 @@
{
"duxt": {
"defaults": {
+ "title": "Documentación",
"navigation": {
- "docs": "Docs",
- "resources": "Recursos"
- },
- "resources": {
- "nuxt": "El framework de base",
- "content": "Obtención, análisis y consulta",
- "shadcn": "La base de componentes",
- "tailwind": "El sistema de estilos",
- "mdc": "Componentes dentro de Markdown"
+ "docs": "Docs"
},
"landing": {
- "headline": "Documentación para Nuxt, versionada y multirrepositorio",
- "description": "Extiende una capa y tu carpeta docs/ se convierte en un sitio. Apúntala a otros repositorios, o a etiquetas del mismo, y estos se convierten en versiones.",
"actions": {
"docs": "Leer la documentación"
},
"preview": "Vista previa en vivo de la documentación",
"previewOpen": "Abrir",
- "features": {
- "extend": {
- "title": "Extender, no generar",
- "description": "Una línea de configuración aporta tema, páginas y componentes: cualquier archivo se puede sobrescribir."
- },
- "sources": {
- "title": "Fuentes como una lista",
- "description": "Una declaración por fuente en lugar de una colección por versión y repositorio."
- },
- "git": {
- "title": "Nativo de Git, no reinventado",
- "description": "Ramas, etiquetas, repositorios privados y caché vienen del propio Content v3."
- },
- "shadcn": {
- "title": "shadcn-vue",
- "description": "Los componentes se copian, no se importan. Rediseñar uno es editar un archivo."
- },
- "mdc": {
- "title": "Componentes en Markdown",
- "description": "MDC viene con Content: llama a un componente Vue con sintaxis de bloque."
- },
- "machine": {
- "title": "Legible por máquinas",
- "description": "llms.txt y una ruta MCP sobre el mismo contenido, previstas como salida de compilación."
- }
- },
"featuresTitle": "Funcionalidades"
},
"aside": {
diff --git a/i18n/locales/es/duxt/devtools.json b/i18n/locales/es/duxt/devtools.json
new file mode 100644
index 00000000..ebd95a8b
--- /dev/null
+++ b/i18n/locales/es/duxt/devtools.json
@@ -0,0 +1,7 @@
+{
+ "duxt": {
+ "devtools": {
+ "preview": "El panel {tab} de devtools"
+ }
+ }
+}
diff --git a/i18n/locales/es/duxt/nav.json b/i18n/locales/es/duxt/nav.json
index 2a85eab1..e95f7839 100644
--- a/i18n/locales/es/duxt/nav.json
+++ b/i18n/locales/es/duxt/nav.json
@@ -5,7 +5,6 @@
"sections": "Secciones",
"previous": "Anterior",
"next": "Siguiente",
- "anchor": "Enlace a esta sección",
"skip": "Saltar al contenido",
"main": "Navegación principal",
"docs": "Documentación",
diff --git a/i18n/locales/es/duxt/page.json b/i18n/locales/es/duxt/page.json
index 4709ff95..67a6ccb7 100644
--- a/i18n/locales/es/duxt/page.json
+++ b/i18n/locales/es/duxt/page.json
@@ -21,7 +21,11 @@
"claude": "Abrir en Claude",
"prompt": "Lee {url} para que pueda hacerte preguntas sobre ello."
},
- "view": "Ver la fuente"
+ "view": "Ver la fuente",
+ "untranslated": {
+ "title": "Esta página aún no está disponible en tu idioma",
+ "description": "La estás leyendo en {language}."
+ }
}
}
}
diff --git a/i18n/locales/fr/duxt/defaults.json b/i18n/locales/fr/duxt/defaults.json
index 414dd39e..059d2322 100644
--- a/i18n/locales/fr/duxt/defaults.json
+++ b/i18n/locales/fr/duxt/defaults.json
@@ -1,51 +1,16 @@
{
"duxt": {
"defaults": {
+ "title": "Documentation",
"navigation": {
- "docs": "Docs",
- "resources": "Ressources"
- },
- "resources": {
- "nuxt": "Le framework sous-jacent",
- "content": "Récupération, analyse et requêtes",
- "shadcn": "La base de composants",
- "tailwind": "Le système de styles",
- "mdc": "Des composants dans le Markdown"
+ "docs": "Docs"
},
"landing": {
- "headline": "Documentation pour Nuxt, versionnée et multidépôt",
- "description": "Étendez une couche et votre dossier docs/ devient un site. Pointez-la vers d'autres dépôts, ou vers des tags du même, et ceux-ci deviennent des versions.",
"actions": {
"docs": "Lire la documentation"
},
"preview": "Aperçu en direct de la documentation",
"previewOpen": "Ouvrir",
- "features": {
- "extend": {
- "title": "Étendre, pas générer",
- "description": "Une ligne de configuration apporte thème, pages et composants — chaque fichier reste remplaçable."
- },
- "sources": {
- "title": "Les sources comme une liste",
- "description": "Une déclaration par source au lieu d'une collection par version et par dépôt."
- },
- "git": {
- "title": "Natif Git, pas réinventé",
- "description": "Branches, tags, dépôts privés et cache viennent de Content v3 lui-même."
- },
- "shadcn": {
- "title": "shadcn-vue",
- "description": "Les composants sont copiés, pas importés. En restyler un, c'est modifier un fichier."
- },
- "mdc": {
- "title": "Des composants dans le Markdown",
- "description": "MDC est livré avec Content — appelez un composant Vue en syntaxe de bloc."
- },
- "machine": {
- "title": "Lisible par une machine",
- "description": "llms.txt et une route MCP sur le même contenu, prévues comme sortie de build."
- }
- },
"featuresTitle": "Fonctionnalités"
},
"aside": {
diff --git a/i18n/locales/fr/duxt/devtools.json b/i18n/locales/fr/duxt/devtools.json
new file mode 100644
index 00000000..52fc0aab
--- /dev/null
+++ b/i18n/locales/fr/duxt/devtools.json
@@ -0,0 +1,7 @@
+{
+ "duxt": {
+ "devtools": {
+ "preview": "Le panneau devtools {tab}"
+ }
+ }
+}
diff --git a/i18n/locales/fr/duxt/nav.json b/i18n/locales/fr/duxt/nav.json
index 35385705..10e25ad4 100644
--- a/i18n/locales/fr/duxt/nav.json
+++ b/i18n/locales/fr/duxt/nav.json
@@ -5,7 +5,6 @@
"sections": "Sections",
"previous": "Précédent",
"next": "Suivant",
- "anchor": "Lien vers cette section",
"skip": "Aller au contenu",
"main": "Navigation principale",
"docs": "Documentation",
diff --git a/i18n/locales/fr/duxt/page.json b/i18n/locales/fr/duxt/page.json
index 3179cdf4..103433ae 100644
--- a/i18n/locales/fr/duxt/page.json
+++ b/i18n/locales/fr/duxt/page.json
@@ -21,7 +21,11 @@
"claude": "Ouvrir dans Claude",
"prompt": "Lis {url} afin que je puisse te poser des questions dessus."
},
- "view": "Voir la source"
+ "view": "Voir la source",
+ "untranslated": {
+ "title": "Cette page n’est pas encore disponible dans votre langue",
+ "description": "Vous la lisez en {language}."
+ }
}
}
}
diff --git a/i18n/locales/pt-BR/duxt/defaults.json b/i18n/locales/pt-BR/duxt/defaults.json
deleted file mode 100644
index efa65799..00000000
--- a/i18n/locales/pt-BR/duxt/defaults.json
+++ /dev/null
@@ -1,19 +0,0 @@
-{
- "duxt": {
- "defaults": {
- "landing": {
- "features": {
- "extend": {
- "description": "Uma linha de configuração traz tema, páginas e componentes — qualquer arquivo pode ser substituído."
- },
- "git": {
- "description": "Branches, tags, repositórios privados e cache vêm do próprio Content v3."
- },
- "shadcn": {
- "description": "Os componentes são copiados, não importados. Redesenhar um é editar um arquivo."
- }
- }
- }
- }
- }
-}
diff --git a/i18n/locales/pt-BR/duxt/nav.json b/i18n/locales/pt-BR/duxt/nav.json
index d4b9f368..8e414796 100644
--- a/i18n/locales/pt-BR/duxt/nav.json
+++ b/i18n/locales/pt-BR/duxt/nav.json
@@ -3,7 +3,6 @@
"nav": {
"sections": "Seções",
"next": "Próximo",
- "anchor": "Link para esta seção",
"skip": "Pular para o conteúdo",
"breadcrumb": "Trilha de navegação"
}
diff --git a/i18n/locales/pt-BR/duxt/page.json b/i18n/locales/pt-BR/duxt/page.json
index a214344b..45ff9e67 100644
--- a/i18n/locales/pt-BR/duxt/page.json
+++ b/i18n/locales/pt-BR/duxt/page.json
@@ -4,6 +4,9 @@
"lastUpdated": "Última atualização em {date}",
"helpful": {
"question": "Esta página foi útil?"
+ },
+ "untranslated": {
+ "description": "Você está lendo em {language}."
}
}
}
diff --git a/i18n/locales/pt/duxt/defaults.json b/i18n/locales/pt/duxt/defaults.json
index 55799422..7ea6134e 100644
--- a/i18n/locales/pt/duxt/defaults.json
+++ b/i18n/locales/pt/duxt/defaults.json
@@ -1,51 +1,16 @@
{
"duxt": {
"defaults": {
+ "title": "Documentação",
"navigation": {
- "docs": "Docs",
- "resources": "Recursos"
- },
- "resources": {
- "nuxt": "A framework subjacente",
- "content": "Obtenção, análise e consulta",
- "shadcn": "A base de componentes",
- "tailwind": "O sistema de estilos",
- "mdc": "Componentes dentro do Markdown"
+ "docs": "Docs"
},
"landing": {
- "headline": "Documentação para Nuxt, com versões e vários repositórios",
- "description": "Estenda uma camada e a sua pasta docs/ torna-se um site. Aponte-a para outros repositórios, ou para tags do mesmo, e estes tornam-se versões.",
"actions": {
"docs": "Ler a documentação"
},
"preview": "Pré-visualização ao vivo da documentação",
"previewOpen": "Abrir",
- "features": {
- "extend": {
- "title": "Estender, não gerar",
- "description": "Uma linha de configuração traz tema, páginas e componentes — qualquer ficheiro pode ser substituído."
- },
- "sources": {
- "title": "Fontes como uma lista",
- "description": "Uma declaração por fonte em vez de uma coleção por versão e repositório."
- },
- "git": {
- "title": "Nativo de Git, não reinventado",
- "description": "Ramos, tags, repositórios privados e cache vêm do próprio Content v3."
- },
- "shadcn": {
- "title": "shadcn-vue",
- "description": "Os componentes são copiados, não importados. Redesenhar um é editar um ficheiro."
- },
- "mdc": {
- "title": "Componentes em Markdown",
- "description": "O MDC vem com o Content — chame um componente Vue com sintaxe de bloco."
- },
- "machine": {
- "title": "Legível por máquinas",
- "description": "llms.txt e uma rota MCP sobre o mesmo conteúdo, previstos como saída da build."
- }
- },
"featuresTitle": "Funcionalidades"
},
"aside": {
diff --git a/i18n/locales/pt/duxt/devtools.json b/i18n/locales/pt/duxt/devtools.json
new file mode 100644
index 00000000..99275475
--- /dev/null
+++ b/i18n/locales/pt/duxt/devtools.json
@@ -0,0 +1,7 @@
+{
+ "duxt": {
+ "devtools": {
+ "preview": "O painel {tab} das devtools"
+ }
+ }
+}
diff --git a/i18n/locales/pt/duxt/nav.json b/i18n/locales/pt/duxt/nav.json
index 40d2a66d..51ba3a1c 100644
--- a/i18n/locales/pt/duxt/nav.json
+++ b/i18n/locales/pt/duxt/nav.json
@@ -5,7 +5,6 @@
"sections": "Secções",
"previous": "Anterior",
"next": "Seguinte",
- "anchor": "Ligação para esta secção",
"skip": "Saltar para o conteúdo",
"main": "Navegação principal",
"docs": "Documentação",
diff --git a/i18n/locales/pt/duxt/page.json b/i18n/locales/pt/duxt/page.json
index 3f24d703..28159f0c 100644
--- a/i18n/locales/pt/duxt/page.json
+++ b/i18n/locales/pt/duxt/page.json
@@ -21,7 +21,11 @@
"claude": "Abrir no Claude",
"prompt": "Lê {url} para que eu te possa fazer perguntas sobre isso."
},
- "view": "Ver a fonte"
+ "view": "Ver a fonte",
+ "untranslated": {
+ "title": "Esta página ainda não está disponível no seu idioma",
+ "description": "Está a lê-la em {language}."
+ }
}
}
}
diff --git a/lint-staged.config.ts b/lint-staged.config.ts
index c77bed84..88cd9d79 100644
--- a/lint-staged.config.ts
+++ b/lint-staged.config.ts
@@ -16,7 +16,10 @@ const config: Configuration = {
const files = filenames.filter((f) => !f.includes('pnpm-lock.yaml'));
return files.length > 0 ? `pnpm exec oxfmt ${files.join(' ')}` : [];
},
- '*.{js,ts,mjs,cjs}': (filenames) => [
+ // `.vue` rides along with the scripts: oxlint and oxfmt both read an SFC,
+ // and without it here the theme's components — nearly every file this repo
+ // changes — passed the hook unchecked.
+ '*.{js,ts,mjs,cjs,vue}': (filenames) => [
`pnpm exec oxlint --fix --deny-warnings ${filenames.join(' ')}`,
`pnpm exec oxfmt ${filenames.join(' ')}`
]
diff --git a/modules/config.ts b/modules/config.ts
index b799796f..79461d68 100644
--- a/modules/config.ts
+++ b/modules/config.ts
@@ -1,6 +1,7 @@
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import type { Nuxt } from '@nuxt/schema';
+import { enableWriteAheadLog } from '../content-cache';
import { readDuxtBuildConfig } from '../duxt-app-config';
import { duxtSourceManifest } from '../sources-resolve';
import { resolveLatestRefs } from '../sources-git';
@@ -25,6 +26,12 @@ import { resolveLatestRefs } from '../sources-git';
* stays i18n's own key.
*/
export default function duxtConfig(_options: unknown, nuxt: Nuxt) {
+ // FIRST, and before anything else touches the file: this is the earliest of
+ // duxt's modules, so it is the only one that runs while Content's cache
+ // database is still closed — and the journal mode can only be changed then.
+ // See `enableWriteAheadLog`.
+ enableWriteAheadLog(nuxt);
+
const layerDir = fileURLToPath(new URL('..', import.meta.url));
const dirs = [
@@ -54,11 +61,71 @@ export default function duxtConfig(_options: unknown, nuxt: Nuxt) {
resolvedSources
} as typeof nuxt.options.appConfig.duxt;
+ checkSourceLocales(nuxt, config, resolvedSources);
restrictLocales(nuxt, config?.locales);
shareSiteUrl(nuxt);
+ nameMcpServer(nuxt, config?.title);
excludeOldVersionsFromSitemap(nuxt, resolvedSources);
}
+/**
+ * The two ways a translated source can be configured into silence.
+ *
+ * NEITHER is caught by anything else. `content.config.ts` has no access to the
+ * Nuxt config — it is loaded by c12 in Content's own pass — so it resolves the
+ * default locale from `sourceOptions.defaultLocale` alone, and if that
+ * disagrees with `i18n.defaultLocale` the collections are named for one
+ * language while the theme queries for another. The result is not an error but
+ * an empty page, which is the failure mode this layer exists to stop.
+ *
+ * So the value is NOT injected from i18n here — injecting it would fix the
+ * manifest and leave `content.config.ts` computing the other answer, which is
+ * the same bug one layer deeper. It is checked instead.
+ */
+function checkSourceLocales(
+ nuxt: Nuxt,
+ config:
+ | { locales?: string[]; sourceOptions?: { defaultLocale?: string } }
+ | undefined,
+ resolved: ReturnType
+) {
+ const translated = resolved.filter((source) => source.locale);
+ if (!translated.length) return;
+
+ const defaultLocale = nuxt.options.i18n?.defaultLocale;
+ const assumed = resolved.find((source) => source.isDefaultLocale)?.locale;
+
+ if (defaultLocale && assumed && assumed !== defaultLocale) {
+ throw new Error(
+ `duxt: sources treat "${assumed}" as the untranslated original, but ` +
+ `i18n.defaultLocale is "${defaultLocale}". Content declares the ` +
+ 'collections without access to the Nuxt config, so the two must agree: ' +
+ `set duxt.sourceOptions.defaultLocale to "${defaultLocale}".`
+ );
+ }
+
+ // A translation nobody can reach is a folder parsed, stored and served to no
+ // one — worth a message rather than a silently larger build.
+ const served = config?.locales;
+ if (!served?.length) return;
+
+ const stranded = [
+ ...new Set(
+ translated
+ .filter((source) => source.locale && !served.includes(source.locale))
+ .map((source) => source.locale!)
+ )
+ ];
+
+ if (stranded.length) {
+ console.warn(
+ `[duxt] sources are translated into ${stranded.join(', ')}, which ` +
+ `duxt.locales does not serve (${served.join(', ')}). Those ` +
+ 'collections are built and never read.'
+ );
+ }
+}
+
/**
* The layer's own version and repository, for the footer line.
*
@@ -108,6 +175,44 @@ function shareSiteUrl(nuxt: Nuxt) {
};
}
+/**
+ * The MCP server's name, from the site's own title.
+ *
+ * `mcp.name` is a nuxt.config key, not an app.config one, so it is the single
+ * duxt-facing option a consumer cannot set beside the others. Left as a literal
+ * it published duxt's own name from every downstream site. Derived here
+ * instead: the title a consumer already writes in `app.config.ts` names the
+ * server too, and anyone wanting a different one still writes `mcp: { name }`
+ * in `nuxt.config.ts`, which defu keeps ahead of this.
+ *
+ * A record is resolved against `i18n.defaultLocale`, because the server has one
+ * name and no request to read a language from. A consumer who wrote an i18n KEY
+ * as their title gets that key — the build has no translator, which is why a
+ * literal or a record is the documented form.
+ *
+ * The module's own default is the empty string, so this must always answer.
+ */
+function nameMcpServer(
+ nuxt: Nuxt,
+ title: string | Record | undefined
+) {
+ const options = nuxt.options as {
+ mcp?: { name?: string };
+ i18n?: { defaultLocale?: string };
+ };
+ if (!options.mcp || options.mcp.name) return;
+
+ const locale = options.i18n?.defaultLocale;
+ const name =
+ typeof title === 'string'
+ ? title
+ : title
+ ? ((locale && title[locale]) ?? Object.values(title)[0])
+ : undefined;
+
+ options.mcp.name = name ? `${name} documentation` : 'Documentation';
+}
+
/**
* Keep the versions the page tells a crawler to ignore out of the sitemap.
*
@@ -197,6 +302,29 @@ function restrictLocales(nuxt: Nuxt, wanted: string[] | undefined) {
);
}
+ /**
+ * The same question, asked of the OTHER fallback — the one that decides where
+ * a visitor landing on `/` is sent.
+ *
+ * `detectBrowserLanguage.fallbackLocale` is nested, and defu merges nested
+ * objects key by key: a consumer setting `i18n: { defaultLocale: 'de-DE' }`
+ * does NOT displace the layer's `en-GB` here. Narrow `duxt.locales` to
+ * exclude it and the site stops serving that language while still redirecting
+ * root visitors to it — nothing throws, and the page is empty. Exactly the
+ * class of silent failure the check above exists to stop, one key over.
+ */
+ const browserFallback = (
+ nuxt.options.i18n as { detectBrowserLanguage?: { fallbackLocale?: string } }
+ )?.detectBrowserLanguage?.fallbackLocale;
+
+ if (browserFallback && !wanted.includes(browserFallback)) {
+ throw new Error(
+ `duxt: i18n.detectBrowserLanguage.fallbackLocale is "${browserFallback}", ` +
+ `which app.config duxt.locales does not list (${wanted.join(', ')}). ` +
+ 'Set it to one of them, or widen duxt.locales.'
+ );
+ }
+
// The flags were derived from the full list at config time. Prune them too,
// so a site serving two languages does not inline seven it never draws.
const keep = new Set(
diff --git a/modules/devtools.ts b/modules/devtools.ts
index 41cd4c03..08b110b4 100644
--- a/modules/devtools.ts
+++ b/modules/devtools.ts
@@ -43,6 +43,13 @@ export default function duxtDevtools(_options: unknown, nuxt: Nuxt) {
appConfigFile: appConfigOf(nuxt),
locales: localeCodes(nuxt),
defaultLocale: (nuxt.options as { i18n?: { defaultLocale?: string } }).i18n
+ ?.defaultLocale,
+ // The Paths panel picks a collection per locale, through the same chain the
+ // theme walks. vue-i18n's own `fallbackLocale` lives in `i18n.config.ts`,
+ // which is a runtime module Nuxt does not evaluate here — and the default
+ // locale is where that chain ends regardless, because the untranslated
+ // original is the collection a page falls back to when nothing else has it.
+ fallbackLocale: (nuxt.options as { i18n?: { defaultLocale?: string } }).i18n
?.defaultLocale
};
diff --git a/modules/redirects.ts b/modules/redirects.ts
index 841ba532..7d70d792 100644
--- a/modules/redirects.ts
+++ b/modules/redirects.ts
@@ -65,7 +65,24 @@ export default function duxtRedirects(_options: unknown, nuxt: Nuxt) {
if (!Object.keys(rules).length) return;
+ // Which of the shipped rules are the layer's own, recorded because nothing
+ // in a route rule says who wrote it. The devtools panel reads the rules
+ // back out of the running server rather than recomputing them — the honest
+ // source, and the reason it also lists foreign ones: @nuxtjs/sitemap ships
+ // `/sitemap.xml` the moment a site has more than one sitemap, and a reader
+ // debugging a moved page should not have to wonder which rules are theirs.
+ //
+ // Taken BEFORE the merge below and filtered by what was already claimed:
+ // an existing rule wins, so a path someone else had is not ours to own.
+ const owned = Object.keys(rules).filter(
+ (path) => !nitro.routeRules?.[path]
+ );
+
nitro.routeRules = { ...rules, ...nitro.routeRules };
+ nitro.runtimeConfig = {
+ ...nitro.runtimeConfig,
+ duxt: { ...(nitro.runtimeConfig?.duxt as object), redirects: owned }
+ };
});
}
diff --git a/modules/validate.ts b/modules/validate.ts
index da5fcecf..9c6a629e 100644
--- a/modules/validate.ts
+++ b/modules/validate.ts
@@ -24,7 +24,11 @@ import { report, walk } from '../validate-report';
* costs is unreachable and no amount of reading the site would say why;
* - a broken link or a missing `title` is a WARNING naming the file, because
* the page still renders and a remote source can go stale between releases
- * without that being this build's fault.
+ * without that being this build's fault;
+ * - what each language carries, and what has stood still behind its original,
+ * is a NOTE — a state of the site rather than a defect in it. It is here
+ * because nothing else in the layer ever says it out loud, and a translation
+ * that quietly stopped moving is the failure this whole file is about.
*/
export default function duxtValidate(_options: unknown, nuxt: Nuxt) {
@@ -74,12 +78,24 @@ export default function duxtValidate(_options: unknown, nuxt: Nuxt) {
? entry.content.description
: undefined,
anchors,
- links
+ links,
+ lastUpdated:
+ typeof entry.content.lastUpdated === 'string'
+ ? entry.content.lastUpdated
+ : undefined
};
});
const problems = report(sources, pages);
+ // The translation report is INFORMATION, not a finding: an untranslated
+ // page is a state of the site rather than a defect in it, and printing it
+ // as a warning would train everyone to ignore the warnings.
+ if (problems.notes.length) {
+ console.info('[duxt] translations');
+ for (const note of problems.notes) console.info(`[duxt] ${note}`);
+ }
+
for (const warning of problems.warnings) {
console.warn(`[duxt] ${warning}`);
}
diff --git a/nuxt.config.ts b/nuxt.config.ts
index dc17cc9b..f1b72e0c 100644
--- a/nuxt.config.ts
+++ b/nuxt.config.ts
@@ -61,6 +61,7 @@ const locales = [
name: 'English (UK)',
files: [
'en/duxt/code.json',
+ 'en/duxt/devtools.json',
'en/duxt/defaults.json',
'en/duxt/error.json',
'en/duxt/footer.json',
@@ -80,6 +81,7 @@ const locales = [
name: 'English (US)',
files: [
'en/duxt/code.json',
+ 'en/duxt/devtools.json',
'en/duxt/defaults.json',
'en/duxt/error.json',
'en/duxt/footer.json',
@@ -99,6 +101,7 @@ const locales = [
name: 'Deutsch',
files: [
'de/duxt/code.json',
+ 'de/duxt/devtools.json',
'de/duxt/defaults.json',
'de/duxt/error.json',
'de/duxt/footer.json',
@@ -118,6 +121,7 @@ const locales = [
name: 'Español',
files: [
'es/duxt/code.json',
+ 'es/duxt/devtools.json',
'es/duxt/defaults.json',
'es/duxt/error.json',
'es/duxt/footer.json',
@@ -137,6 +141,7 @@ const locales = [
name: 'Français',
files: [
'fr/duxt/code.json',
+ 'fr/duxt/devtools.json',
'fr/duxt/defaults.json',
'fr/duxt/error.json',
'fr/duxt/footer.json',
@@ -156,6 +161,7 @@ const locales = [
name: 'Português',
files: [
'pt/duxt/code.json',
+ 'pt/duxt/devtools.json',
'pt/duxt/defaults.json',
'pt/duxt/error.json',
'pt/duxt/footer.json',
@@ -175,6 +181,7 @@ const locales = [
name: 'Português (Brasil)',
files: [
'pt/duxt/code.json',
+ 'pt/duxt/devtools.json',
'pt/duxt/defaults.json',
'pt/duxt/error.json',
'pt/duxt/footer.json',
@@ -186,7 +193,6 @@ const locales = [
'pt/duxt/theme.json',
'pt/duxt/toc.json',
'pt/duxt/version.json',
- 'pt-BR/duxt/defaults.json',
'pt-BR/duxt/error.json',
'pt-BR/duxt/nav.json',
'pt-BR/duxt/page.json',
@@ -227,16 +233,23 @@ export default defineNuxtConfig({
* @nuxtjs/sitemap wires itself into Content's collections, and it says so
* out loud when it is loaded second — "this may cause issues with the
* integration". It does: the sitemap then lists the site's routes and not
- * one documentation page.
+ * one documentation page. The bundle loads its modules in its own order,
+ * so the whole bundle goes here rather than three named modules.
*
- * All four read `site.url`, which the duxt module fills in from
- * `i18n.baseUrl` so a consumer states its origin once rather than four
+ * `@nuxtjs/seo` is an ALIAS, not a wrapper — its own documentation says it
+ * "contains no logic of its own". What it buys is the four modules this
+ * layer used to do by hand: schema.org from `nuxt-schema-org`, the
+ * automatic canonical and og/twitter tags from `nuxt-seo-utils`, the link
+ * check, and `nuxt-site-config` as the one place `site.url` is read from.
+ * It also completes the shared devtools panel, which every one of these
+ * modules feeds and which lists the ones that are missing.
+ *
+ * They all read `site.url`, which the duxt module fills in from
+ * `i18n.baseUrl` so a consumer states its origin once rather than five
* times; without one they degrade to relative output rather than inventing
* a domain.
*/
- '@nuxtjs/robots',
- '@nuxtjs/sitemap',
- 'nuxt-og-image',
+ '@nuxtjs/seo',
'@nuxt/image',
'@nuxt/content',
@@ -271,8 +284,10 @@ export default defineNuxtConfig({
// A real MCP server at /mcp, through the official SDK, instead of a JSON
// endpoint someone else has to wrap. Tools live in server/mcp/tools.
+ // No `name`: the module's own default is the empty string, so
+ // `modules/config.ts` derives one from the site's own title instead. Left as
+ // a literal here it published duxt's name from every downstream site.
mcp: {
- name: 'duxt documentation',
description: 'The documentation this site publishes, readable by an agent.',
instructions:
'Call list_pages for the table of contents, search_docs to find a page by ' +
@@ -337,6 +352,17 @@ export default defineNuxtConfig({
* page then tells it to drop. Same for an `eol` version, which is excluded
* whether or not it is the default.
*/
+ // The devtools previews in `public/` are fixtures for one reference page, not
+ // pages of anybody's site. Every consumer serves them, because Nuxt serves
+ // every layer's `public/` — and none of them wants ten documents titled
+ // "duxt — Sources" indexed against their own domain.
+ //
+ // defu concatenates the list across layers, so a consumer's own `disallow`
+ // is added to this rather than replacing it.
+ robots: {
+ disallow: ['/devtools/']
+ },
+
sitemap: {
// Content pages reach the sitemap through the module's own Content
// integration; the duxt module adds what the version rules exclude.
@@ -348,6 +374,50 @@ export default defineNuxtConfig({
exclude: ['/_partials/**', '/*/_partials/**']
},
+ /**
+ * nuxt-seo-utils, whose defaults are written for a site that sets no head
+ * tags of its own. Three of them are wrong HERE, and each for a reason this
+ * layer cannot design away.
+ */
+ seo: {
+ /**
+ * A LOCALE PREFIX IS CASE-SENSITIVE. i18n routes this site's locales under
+ * their own codes — `/de-DE/guides`, `/pt-BR/guides` — and lowercasing a
+ * canonical would point every translated page at a URL that 404s. The
+ * option exists for sites whose paths differ only in case; ours do not.
+ */
+ canonicalLowercase: false,
+
+ /**
+ * A title invented from the last slug segment is a title nobody wrote.
+ * Every page here carries one from its frontmatter, and `modules/validate`
+ * fails the build over a page that does not — so the fallback can only ever
+ * mask that check.
+ */
+ fallbackTitle: false,
+
+ /**
+ * `app.vue` owns the title template — it appends the site's own name, which
+ * is what a tab and a search result need. Letting site config inject a
+ * second one leaves two templates competing over one title.
+ */
+ mergeWithSiteConfig: false
+ },
+
+ /**
+ * The link check REPORTS, it does not fail the build: `modules/validate.ts`
+ * already fails it over a link pointing nowhere, and it is the one that knows
+ * about versions and locale fallbacks. Two gates over one rule means the
+ * looser one decides when a build breaks, which is the wrong way round.
+ *
+ * What the module adds over that is the devtools panel and the live check
+ * while writing, which is where a broken link is cheapest to fix.
+ */
+ linkChecker: {
+ failOnError: false,
+ excludeLinks: ['/devtools/**']
+ },
+
colorMode: {
// shadcn switches on a `dark` class, not a data attribute or a media query.
classSuffix: '',
diff --git a/package.json b/package.json
index 25c1801b..d463877a 100644
--- a/package.json
+++ b/package.json
@@ -35,6 +35,7 @@
"mdc.config.ts",
"modules",
"nuxt.config.ts",
+ "public",
"server",
"sources-git.ts",
"sources-resolve.ts",
@@ -58,8 +59,10 @@
"format": "oxfmt --check .",
"format:fix": "oxfmt .",
"typecheck": "tsc --noEmit",
- "check": "pnpm lint && pnpm format && pnpm typecheck && pnpm typecheck:app && pnpm test && pnpm check:policy && pnpm build:app && pnpm check:a11y",
+ "check": "pnpm lint && pnpm format && pnpm typecheck && pnpm typecheck:app && pnpm test && pnpm check:policy && pnpm check:previews && pnpm build:app && pnpm check:a11y && pnpm check:seo",
"check:fix": "pnpm lint:fix && pnpm format:fix",
+ "previews": "node scripts/build-devtools-previews.ts",
+ "check:previews": "node scripts/check-previews.ts",
"check:policy": "node scripts/check-policy-parity.ts",
"skills:update": "pnpm dlx skills@latest update -p -y",
"taze": "taze",
@@ -69,7 +72,8 @@
"test": "vitest run",
"test:watch": "vitest",
"build:app": "pnpm --filter www build",
- "check:a11y": "node scripts/check-a11y.ts"
+ "check:a11y": "node scripts/check-a11y.ts",
+ "check:seo": "node scripts/check-seo.ts"
},
"dependencies": {
"@iconify-json/flag": "^1.2.12",
@@ -82,8 +86,7 @@
"@nuxtjs/color-mode": "^4.0.1",
"@nuxtjs/i18n": "^10.6.0",
"@nuxtjs/mcp-toolkit": "^0.19.0",
- "@nuxtjs/robots": "^6.2.0",
- "@nuxtjs/sitemap": "^8.5.0",
+ "@nuxtjs/seo": "^5.3.14",
"@resvg/resvg-js": "^2.6.2",
"@shikijs/transformers": "^4.4.3",
"@tailwindcss/vite": "^4.3.3",
@@ -93,7 +96,6 @@
"fuse.js": "^7.5.0",
"jiti": "^2.7.0",
"mermaid": "^11.17.2",
- "nuxt-og-image": "^6.7.8",
"reka-ui": "^2.10.4",
"satori": "^0.33.4",
"shadcn-nuxt": "^2.8.2",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 502faa7e..1b970241 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -142,12 +142,9 @@ importers:
'@nuxtjs/mcp-toolkit':
specifier: ^0.19.0
version: 0.19.0(@vue/compiler-sfc@3.5.42)(h3@1.15.11)(magic-string@1.2.3)(magicast@0.5.4)(oxc-parser@0.143.0)(rolldown@1.2.6)(rollup@4.63.1)(supports-color@10.2.2)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.6)(rollup@4.63.1)(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0)))(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0))(vue@3.5.42(typescript@6.0.3))(zod@4.5.4)
- '@nuxtjs/robots':
- specifier: ^6.2.0
- version: 6.2.0(952b4292758e5e3fd4af119e921ecab4)
- '@nuxtjs/sitemap':
- specifier: ^8.5.0
- version: 8.5.0(952b4292758e5e3fd4af119e921ecab4)
+ '@nuxtjs/seo':
+ specifier: ^5.3.14
+ version: 5.3.14(58787ef646c952a216fb828544ef6b9f)
'@resvg/resvg-js':
specifier: ^2.6.2
version: 2.6.2
@@ -175,9 +172,6 @@ importers:
mermaid:
specifier: ^11.17.2
version: 11.17.2
- nuxt-og-image:
- specifier: ^6.7.8
- version: 6.7.8(8a2ca2ccaeb949734b1ff346e9fa6f05)
reka-ui:
specifier: ^2.10.4
version: 2.10.4(vue@3.5.42(typescript@6.0.3))
@@ -1756,6 +1750,11 @@ packages:
zod:
optional: true
+ '@nuxtjs/seo@5.3.14':
+ resolution: {integrity: sha512-kJsX/QuRdViEI6AB6rr670NDcKf/RslKMzmSaCBcxwQrVIiGNvGVgcR99xUbGcIx2OnNc9KsYPlpB9eiLvP01w==}
+ peerDependencies:
+ nuxt: ^3.16.0 || ^4.0.0 || ^5.0.0
+
'@nuxtjs/sitemap@8.5.0':
resolution: {integrity: sha512-DfhUZzP29Wzr9ro3L2mBpYUocmLBKvGDAGThvLdHWKGR1kBFnbY5VBhJaZG4Jeuba5gVKCpDzH9PhKNW11d46Q==}
engines: {node: '>=18.0.0'}
@@ -3224,79 +3223,6 @@ packages:
peerDependencies:
vite: ^5.2.0 || ^6 || ^7 || ^8
- '@takumi-rs/core-darwin-arm64@2.13.5':
- resolution: {integrity: sha512-o4ZGtb8OQH5rODzm6Pa/k2Jzu/CSo/1MvfafvETLTaYlOZUujMTeR9eUS1XWbBF0sKLO97cyARHYnCkgWHVbCw==}
- engines: {node: '>=18'}
- cpu: [arm64]
- os: [darwin]
-
- '@takumi-rs/core-darwin-x64@2.13.5':
- resolution: {integrity: sha512-LkiHNZs0vU7sEouksXJxgS90lZLG7ze4kc4mwIR083ffLUO+xnIJS2KzqTFF9QBcRXq7jVh7Gme3J0COTUs4UQ==}
- engines: {node: '>=18'}
- cpu: [x64]
- os: [darwin]
-
- '@takumi-rs/core-linux-arm64-gnu@2.13.5':
- resolution: {integrity: sha512-hYhbvMr3aSwZllclTwJ++j/lKpU33T7wcXMxTaboBHj/fmVVLQghWf+ppKE8q1F/Uaw0Y1sn6JY6Iv6CYdjl3w==}
- engines: {node: '>=18'}
- cpu: [arm64]
- os: [linux]
- libc: [glibc]
-
- '@takumi-rs/core-linux-arm64-musl@2.13.5':
- resolution: {integrity: sha512-pPeI+3Ex9iWQ4OfxGkX1njfdV2OVPgjQKJ19haT4cLizGk/txoj5QGR91XTQjF3y2n09QJJvW/UodeYTMEMBhQ==}
- engines: {node: '>=18'}
- cpu: [arm64]
- os: [linux]
- libc: [musl]
-
- '@takumi-rs/core-linux-x64-gnu@2.13.5':
- resolution: {integrity: sha512-8/Egq49ieXh/u0cXUgqELIFUPVkrWk6fR2hPX8giD40D61G36ZErQyJR4W9GUlQctP/buwqLwD6FzYT0OsZ0Gw==}
- engines: {node: '>=18'}
- cpu: [x64]
- os: [linux]
- libc: [glibc]
-
- '@takumi-rs/core-linux-x64-musl@2.13.5':
- resolution: {integrity: sha512-Fuv6Q52H7OwyESW25ovG6A0ZI4eyvqPjmGaDsAsgOV6MgANwiee74pS6cSR+5cJyxplVWbghiy9ajs9E2SORfg==}
- engines: {node: '>=18'}
- cpu: [x64]
- os: [linux]
- libc: [musl]
-
- '@takumi-rs/core-win32-arm64-msvc@2.13.5':
- resolution: {integrity: sha512-nsF5mo6Vgb4dJSOqmTJ/lX6xlwKoJ63X6fEFKh0OfHAZXvvQzUjYsNYc/NRWRbD7gBvxfLKPTtmSTFdaZ0rToQ==}
- engines: {node: '>=18'}
- cpu: [arm64]
- os: [win32]
-
- '@takumi-rs/core-win32-x64-msvc@2.13.5':
- resolution: {integrity: sha512-21LlzKViiUACIiVKd1P/b57HiKw0bkALoBbzG4HJ2fQckv/b0iQaRq0AtRVtbdWuBel/PvDOu5vd2Uwrd5wLSg==}
- engines: {node: '>=18'}
- cpu: [x64]
- os: [win32]
-
- '@takumi-rs/core@2.13.5':
- resolution: {integrity: sha512-aQSKB00nvgXP1V10XmFwzuQJ5Cis6U70C4MZ07enxNmg5fqikqOUJShUdHtK8PJX+3zUqpRza3Qnlcw3uaoHdw==}
- engines: {node: '>=18'}
- peerDependencies:
- csstype: '*'
- peerDependenciesMeta:
- csstype:
- optional: true
-
- '@takumi-rs/helpers@2.13.5':
- resolution: {integrity: sha512-ZuIVNJ258DocE8Wd05gC69vQJoArVpdpYMj+kp9/8pWyci6oyTFtKSrR50zDDTVwpObRHEU3uB9OHeSDHYUVQQ==}
- engines: {node: '>=18'}
- peerDependencies:
- preact: ^10.0.0
- react: ^18.0.0 || ^19.0.0
- peerDependenciesMeta:
- preact:
- optional: true
- react:
- optional: true
-
'@tanstack/table-core@8.21.3':
resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==}
engines: {node: '>=12'}
@@ -4808,6 +4734,10 @@ packages:
resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==}
engines: {node: '>=0.3.1'}
+ diff@9.0.0:
+ resolution: {integrity: sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==}
+ engines: {node: '>=0.3.1'}
+
dom-serializer@2.0.0:
resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
@@ -6364,6 +6294,11 @@ packages:
nuxt-define@1.0.0:
resolution: {integrity: sha512-CYZ2WjU+KCyCDVzjYUM4eEpMF0rkPmkpiFrybTqqQCRpUbPt2h3snswWIpFPXTi+osRCY6Og0W/XLAQgDL4FfQ==}
+ nuxt-link-checker@5.3.0:
+ resolution: {integrity: sha512-TIwN5LM85hwGI/kTHX3jcF/De+ae2DjmVs20KyyEQ6igBGNDQEnkBwMN+Eo+neK6Ihrffpm6DmjAc5UYthpwAg==}
+ peerDependencies:
+ nuxt: ^3.9.0 || ^4.0.0 || ^5.0.0
+
nuxt-og-image@6.7.8:
resolution: {integrity: sha512-G7Vm+QNZf5LT85BupX0k8xuA+xjXUB/dY4stLuAIdFmv1CeNbnr5SkFGceRk35FHIK66nIim7wgRU4hGmxzH8Q==}
engines: {node: '>=18.0.0'}
@@ -6415,6 +6350,42 @@ packages:
zod:
optional: true
+ nuxt-schema-org@6.3.1:
+ resolution: {integrity: sha512-pZQyLERx8iho4QDCDNK0WxfdHwmZWXk8Nrp9DUfR79aKkwivm83SEvwWp0D9/YuBfaXhm+9YxVBpCDA2kr8B+g==}
+ peerDependencies:
+ '@unhead/vue': ^2.0.7 || ^3.0.0
+ unhead: ^2.0.7 || ^3.0.0
+ zod: '>=3'
+ peerDependenciesMeta:
+ '@unhead/vue':
+ optional: true
+ unhead:
+ optional: true
+ zod:
+ optional: true
+
+ nuxt-seo-utils@8.5.0:
+ resolution: {integrity: sha512-vq5rP7xBLEYGUBskzllqg0tmRTt8EyhzX1tuvWEGRt4JKAhw2HN75GUP1RpAM4n6QYqHyh6U8YkJZjEuot0wpQ==}
+ hasBin: true
+ peerDependencies:
+ '@unhead/vue': ^2.0.7 || ^3.0.0
+ esbuild: '>=0.17.0'
+ lightningcss: '>=1.20.0'
+ nuxt: ^3.0.0 || ^4.0.0 || ^5.0.0
+ rolldown: '>=1.0.0-beta.0'
+ unhead: ^2.0.7 || ^3.0.0
+ peerDependenciesMeta:
+ '@unhead/vue':
+ optional: true
+ esbuild:
+ optional: true
+ lightningcss:
+ optional: true
+ rolldown:
+ optional: true
+ unhead:
+ optional: true
+
nuxt-site-config-kit@4.2.3:
resolution: {integrity: sha512-xAU061MrxDlv1CjHjP9+Qg3mAdKxQgO9sFzoy+LblKW+DDLRMGjaEIna/QUk0F0kWzhrf4dSpOVTW8OZv26xVg==}
@@ -10828,6 +10799,75 @@ snapshots:
- vite
- vue
+ '@nuxtjs/seo@5.3.14(58787ef646c952a216fb828544ef6b9f)':
+ dependencies:
+ '@nuxt/kit': 4.5.2(magic-string@1.2.3)(magicast@0.5.4)(oxc-parser@0.143.0)(rolldown@1.2.6)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.6)(rollup@4.63.1)(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0)))
+ '@nuxtjs/robots': 6.2.0(952b4292758e5e3fd4af119e921ecab4)
+ '@nuxtjs/sitemap': 8.5.0(952b4292758e5e3fd4af119e921ecab4)
+ nuxt: 4.5.2(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@oxc-project/types@0.147.0)(@types/node@26.4.0)(@vue/compiler-sfc@3.5.42)(db0@0.3.4)(esbuild@0.28.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(ioredis@5.11.1(supports-color@10.2.2))(lightningcss@1.33.0)(magicast@0.5.4)(optionator@0.9.4)(oxc-parser@0.143.0)(oxlint@1.80.0)(rolldown@1.2.6)(rollup-plugin-visualizer@7.1.1(rolldown@1.2.6)(rollup@4.63.1))(rollup@4.63.1)(srvx@0.11.22)(supports-color@10.2.2)(terser@5.51.2)(typescript@6.0.3)(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0))(vue-tsc@3.3.11(typescript@6.0.3))(yaml@2.9.0)
+ nuxt-link-checker: 5.3.0(6ef46d2520121deca4253d69d1e83f86)
+ nuxt-og-image: 6.7.8(f93c58cf95cd5073f61bff248dd2f949)
+ nuxt-schema-org: 6.3.1(3e587587ff29ff196284b76a2b7d910d)
+ nuxt-seo-utils: 8.5.0(995d17c507be5c9fa9455c9624bf8407)
+ nuxt-site-config: 4.2.3(952b4292758e5e3fd4af119e921ecab4)
+ nuxtseo-shared: 5.3.14(1b03c8dc1a8a10818f5a562670f6efcf)
+ transitivePeerDependencies:
+ - '@azure/app-configuration'
+ - '@azure/cosmos'
+ - '@azure/data-tables'
+ - '@azure/identity'
+ - '@azure/keyvault-secrets'
+ - '@azure/storage-blob'
+ - '@capacitor/preferences'
+ - '@deno/kv'
+ - '@farmfe/core'
+ - '@netlify/blobs'
+ - '@nuxt/schema'
+ - '@oxc-project/types'
+ - '@planetscale/database'
+ - '@resvg/resvg-js'
+ - '@resvg/resvg-wasm'
+ - '@rspack/core'
+ - '@takumi-rs/core'
+ - '@takumi-rs/wasm'
+ - '@types/node'
+ - '@unhead/vue'
+ - '@unocss/config'
+ - '@unocss/core'
+ - '@upstash/redis'
+ - '@vercel/blob'
+ - '@vercel/functions'
+ - '@vercel/kv'
+ - aws4fetch
+ - bun-types-no-globals
+ - db0
+ - esbuild
+ - fontless
+ - idb-keyval
+ - ioredis
+ - lightningcss
+ - magic-string
+ - magicast
+ - nitropack
+ - oxc-parser
+ - playwright-core
+ - rolldown
+ - rollup
+ - satori
+ - sharp
+ - supports-color
+ - tailwindcss
+ - unhead
+ - unifont
+ - unloader
+ - unplugin
+ - unstorage
+ - uploadthing
+ - vite
+ - vue
+ - webpack
+ - zod
+
'@nuxtjs/sitemap@8.5.0(952b4292758e5e3fd4af119e921ecab4)':
dependencies:
'@nuxt/kit': 4.5.2(magic-string@1.2.3)(magicast@0.5.4)(oxc-parser@0.143.0)(rolldown@1.2.6)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.6)(rollup@4.63.1)(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0)))
@@ -11704,51 +11744,6 @@ snapshots:
tailwindcss: 4.3.3
vite: 8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0)
- '@takumi-rs/core-darwin-arm64@2.13.5':
- optional: true
-
- '@takumi-rs/core-darwin-x64@2.13.5':
- optional: true
-
- '@takumi-rs/core-linux-arm64-gnu@2.13.5':
- optional: true
-
- '@takumi-rs/core-linux-arm64-musl@2.13.5':
- optional: true
-
- '@takumi-rs/core-linux-x64-gnu@2.13.5':
- optional: true
-
- '@takumi-rs/core-linux-x64-musl@2.13.5':
- optional: true
-
- '@takumi-rs/core-win32-arm64-msvc@2.13.5':
- optional: true
-
- '@takumi-rs/core-win32-x64-msvc@2.13.5':
- optional: true
-
- '@takumi-rs/core@2.13.5(csstype@3.2.3)':
- dependencies:
- '@takumi-rs/helpers': 2.13.5
- optionalDependencies:
- '@takumi-rs/core-darwin-arm64': 2.13.5
- '@takumi-rs/core-darwin-x64': 2.13.5
- '@takumi-rs/core-linux-arm64-gnu': 2.13.5
- '@takumi-rs/core-linux-arm64-musl': 2.13.5
- '@takumi-rs/core-linux-x64-gnu': 2.13.5
- '@takumi-rs/core-linux-x64-musl': 2.13.5
- '@takumi-rs/core-win32-arm64-msvc': 2.13.5
- '@takumi-rs/core-win32-x64-msvc': 2.13.5
- csstype: 3.2.3
- transitivePeerDependencies:
- - preact
- - react
- optional: true
-
- '@takumi-rs/helpers@2.13.5':
- optional: true
-
'@tanstack/table-core@8.21.3': {}
'@tanstack/virtual-core@3.17.8': {}
@@ -13375,6 +13370,8 @@ snapshots:
diff@8.0.4: {}
+ diff@9.0.0: {}
+
dom-serializer@2.0.0:
dependencies:
domelementtype: 2.3.0
@@ -15534,7 +15531,57 @@ snapshots:
nuxt-define@1.0.0: {}
- nuxt-og-image@6.7.8(8a2ca2ccaeb949734b1ff346e9fa6f05):
+ nuxt-link-checker@5.3.0(6ef46d2520121deca4253d69d1e83f86):
+ dependencies:
+ '@nuxt/kit': 4.5.2(magic-string@1.2.3)(magicast@0.5.4)(oxc-parser@0.143.0)(rolldown@1.2.6)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.6)(rollup@4.63.1)(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0)))
+ '@vueuse/core': 14.4.0(vue@3.5.42(typescript@6.0.3))
+ consola: 3.4.2
+ defu: 6.1.7
+ diff: 9.0.0
+ fuse.js: 7.5.0
+ h3: 1.15.11
+ magic-string: 1.2.3
+ nuxt: 4.5.2(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@oxc-project/types@0.147.0)(@types/node@26.4.0)(@vue/compiler-sfc@3.5.42)(db0@0.3.4)(esbuild@0.28.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(ioredis@5.11.1(supports-color@10.2.2))(lightningcss@1.33.0)(magicast@0.5.4)(optionator@0.9.4)(oxc-parser@0.143.0)(oxlint@1.80.0)(rolldown@1.2.6)(rollup-plugin-visualizer@7.1.1(rolldown@1.2.6)(rollup@4.63.1))(rollup@4.63.1)(srvx@0.11.22)(supports-color@10.2.2)(terser@5.51.2)(typescript@6.0.3)(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0))(vue-tsc@3.3.11(typescript@6.0.3))(yaml@2.9.0)
+ nuxt-site-config: 4.2.3(952b4292758e5e3fd4af119e921ecab4)
+ nuxtseo-shared: 5.3.14(1b03c8dc1a8a10818f5a562670f6efcf)
+ ofetch: 1.5.1
+ pathe: 2.0.3
+ pkg-types: 2.3.1
+ radix3: 1.1.2
+ site-config-stack: 4.2.3(vue@3.5.42(typescript@6.0.3))
+ ufo: 1.6.4
+ ultrahtml: 1.7.0
+ unstorage: 1.17.5(db0@0.3.4)(ioredis@5.11.1(supports-color@10.2.2))
+ transitivePeerDependencies:
+ - '@azure/app-configuration'
+ - '@azure/cosmos'
+ - '@azure/data-tables'
+ - '@azure/identity'
+ - '@azure/keyvault-secrets'
+ - '@azure/storage-blob'
+ - '@capacitor/preferences'
+ - '@deno/kv'
+ - '@netlify/blobs'
+ - '@nuxt/schema'
+ - '@planetscale/database'
+ - '@upstash/redis'
+ - '@vercel/blob'
+ - '@vercel/functions'
+ - '@vercel/kv'
+ - aws4fetch
+ - db0
+ - idb-keyval
+ - ioredis
+ - magicast
+ - oxc-parser
+ - rolldown
+ - unplugin
+ - uploadthing
+ - vite
+ - vue
+ - zod
+
+ nuxt-og-image@6.7.8(f93c58cf95cd5073f61bff248dd2f949):
dependencies:
'@clack/prompts': 1.7.0
'@nuxt/kit': 4.5.2(magic-string@1.2.3)(magicast@0.5.4)(oxc-parser@0.143.0)(rolldown@1.2.6)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.6)(rollup@4.63.1)(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0)))
@@ -15570,7 +15617,6 @@ snapshots:
unstorage: 1.17.5(db0@0.3.4)(ioredis@5.11.1(supports-color@10.2.2))
optionalDependencies:
'@resvg/resvg-js': 2.6.2
- '@takumi-rs/core': 2.13.5(csstype@3.2.3)
fontless: 0.2.1(db0@0.3.4)(ioredis@5.11.1(supports-color@10.2.2))(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0))
nitropack: 2.13.4(oxc-parser@0.143.0)(rolldown@1.2.6)(srvx@0.11.22)(supports-color@10.2.2)(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0))
satori: 0.33.4
@@ -15593,6 +15639,63 @@ snapshots:
- vue
- webpack
+ nuxt-schema-org@6.3.1(3e587587ff29ff196284b76a2b7d910d):
+ dependencies:
+ '@nuxt/kit': 4.5.2(magic-string@1.2.3)(magicast@0.5.4)(oxc-parser@0.143.0)(rolldown@1.2.6)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.6)(rollup@4.63.1)(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0)))
+ defu: 6.1.7
+ nuxt-site-config: 4.2.3(952b4292758e5e3fd4af119e921ecab4)
+ nuxtseo-shared: 5.3.14(1b03c8dc1a8a10818f5a562670f6efcf)
+ pkg-types: 2.3.1
+ ufo: 1.6.4
+ optionalDependencies:
+ '@unhead/vue': 3.4.0(@oxc-project/types@0.147.0)(esbuild@0.28.2)(lightningcss@1.33.0)(oxc-parser@0.143.0)(rolldown@1.2.6)(rollup@4.63.1)(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0))(vue@3.5.42(typescript@6.0.3))
+ unhead: 3.4.0(esbuild@0.28.2)(rolldown@1.2.6)(rollup@4.63.1)(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0))
+ zod: 4.5.4
+ transitivePeerDependencies:
+ - '@nuxt/schema'
+ - magic-string
+ - magicast
+ - nuxt
+ - oxc-parser
+ - rolldown
+ - unplugin
+ - vite
+ - vue
+
+ nuxt-seo-utils@8.5.0(995d17c507be5c9fa9455c9624bf8407):
+ dependencies:
+ '@nuxt/kit': 4.5.2(magic-string@1.2.3)(magicast@0.5.4)(oxc-parser@0.143.0)(rolldown@1.2.6)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.6)(rollup@4.63.1)(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0)))
+ citty: 0.2.2
+ consola: 3.4.2
+ defu: 6.1.7
+ escape-string-regexp: 5.0.0
+ exsolve: 1.1.1
+ nuxt: 4.5.2(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@oxc-project/types@0.147.0)(@types/node@26.4.0)(@vue/compiler-sfc@3.5.42)(db0@0.3.4)(esbuild@0.28.2)(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(ioredis@5.11.1(supports-color@10.2.2))(lightningcss@1.33.0)(magicast@0.5.4)(optionator@0.9.4)(oxc-parser@0.143.0)(oxlint@1.80.0)(rolldown@1.2.6)(rollup-plugin-visualizer@7.1.1(rolldown@1.2.6)(rollup@4.63.1))(rollup@4.63.1)(srvx@0.11.22)(supports-color@10.2.2)(terser@5.51.2)(typescript@6.0.3)(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0))(vue-tsc@3.3.11(typescript@6.0.3))(yaml@2.9.0)
+ nuxt-site-config: 4.2.3(952b4292758e5e3fd4af119e921ecab4)
+ nuxtseo-shared: 5.3.14(1b03c8dc1a8a10818f5a562670f6efcf)
+ pathe: 2.0.3
+ pkg-types: 2.3.1
+ scule: 1.3.0
+ tinyglobby: 0.2.17
+ ufo: 1.6.4
+ optionalDependencies:
+ '@unhead/vue': 3.4.0(@oxc-project/types@0.147.0)(esbuild@0.28.2)(lightningcss@1.33.0)(oxc-parser@0.143.0)(rolldown@1.2.6)(rollup@4.63.1)(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0))(vue@3.5.42(typescript@6.0.3))
+ esbuild: 0.28.2
+ lightningcss: 1.33.0
+ rolldown: 1.2.6
+ sharp: 0.35.4(@types/node@26.4.0)
+ unhead: 3.4.0(esbuild@0.28.2)(rolldown@1.2.6)(rollup@4.63.1)(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0))
+ transitivePeerDependencies:
+ - '@nuxt/schema'
+ - '@types/node'
+ - magic-string
+ - magicast
+ - oxc-parser
+ - unplugin
+ - vite
+ - vue
+ - zod
+
nuxt-site-config-kit@4.2.3(magic-string@1.2.3)(magicast@0.5.4)(oxc-parser@0.141.0)(rolldown@1.2.6)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.6)(rollup@4.63.1)(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0)))(vue@3.5.42(typescript@6.0.3)):
dependencies:
'@nuxt/kit': 4.5.2(magic-string@1.2.3)(magicast@0.5.4)(oxc-parser@0.141.0)(rolldown@1.2.6)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.6)(rollup@4.63.1)(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0)))
diff --git a/public/devtools/cache.html b/public/devtools/cache.html
new file mode 100644
index 00000000..0b334ae1
--- /dev/null
+++ b/public/devtools/cache.html
@@ -0,0 +1,318 @@
+
+
+duxt — Cache
+
+
+ Cache
+ Content's download cache under .data/content, and a way to drop an entry.
+ 3 cached sources
9.3 MB on disk
/srv/acme-docs/.data/content
Dropping an entry makes the next build download it again. Content keys a directory by repository and ref, so an entry for a ref you have renamed is never reused and never cleaned up either.
+
+
+
\ No newline at end of file
diff --git a/public/devtools/checks.html b/public/devtools/checks.html
new file mode 100644
index 00000000..1c26f1c3
--- /dev/null
+++ b/public/devtools/checks.html
@@ -0,0 +1,309 @@
+
+
+duxt — Checks
+
+
+ Checks
+ The build validator, run against what the site is serving now.
+ 30 pages checked
5 collections
0 errors
4 warnings
Warnings Translations
+
+
+
\ No newline at end of file
diff --git a/public/devtools/config.html b/public/devtools/config.html
new file mode 100644
index 00000000..d5b32dba
--- /dev/null
+++ b/public/devtools/config.html
@@ -0,0 +1,421 @@
+
+
+duxt — Config
+
+
+ Config
+ The merged duxt config, and which side of the merge each value came from.
+ 15 keys
6 from the site
8 from the layer
1 computed
Arrays are replaced whole, objects merge key by key. A row marked build was computed by modules/config.ts and is written nowhere.
+
+
+
\ No newline at end of file
diff --git a/public/devtools/i18n.html b/public/devtools/i18n.html
new file mode 100644
index 00000000..7ab4e80f
--- /dev/null
+++ b/public/devtools/i18n.html
@@ -0,0 +1,309 @@
+
+
+duxt — i18n
+
+
+ i18n
+ Message keys per locale, against the base language.
+ 4 locale files
8 keys in en
2 with a gap
Served locales: en de fr pt-BR, default en.
+
+
+
\ No newline at end of file
diff --git a/public/devtools/pages.html b/public/devtools/pages.html
new file mode 100644
index 00000000..8bcc0ed3
--- /dev/null
+++ b/public/devtools/pages.html
@@ -0,0 +1,341 @@
+
+
+duxt — Pages
+
+
+ Pages
+ Every page in every collection, with the frontmatter and git metadata attached to it.
+ 30 pages
5 collections
26 with history
3 without a title
+ docs_sdk 8 pages
+
+
+
+ docs_de_sdk 6 pages
+
+
+
+ docs_sdk_v1_9 7 pages
+
+
+
+ docs_de_sdk_v1_9 5 pages
+
+
+
+ docs_cli 4 pages
+
+
+
+
+
+
\ No newline at end of file
diff --git a/public/devtools/paths.html b/public/devtools/paths.html
new file mode 100644
index 00000000..9b4da96b
--- /dev/null
+++ b/public/devtools/paths.html
@@ -0,0 +1,315 @@
+
+
+duxt — Paths
+
+
+ Paths
+ Resolve a URL the way the theme does, step by step — and say which step failed.
+
+
+ Resolve
+ Locale segment stripped: /de/sdk/v1-9/guides/caching → /sdk/v1-9/guides/caching, language de. The locale translates the interface, never the content tree — but it does pick the collection. Longest matching prefix is /sdk → collection docs_de_sdk, version v2, language de. Falls back to docs_sdk. A page is translated whole or not at all, so this is a chain of collections rather than a merge. stops here Collection docs_de_sdk has no page at /sdk/v1-9/guides/caching.The same page in other sources Nearest paths in docs_de_sdk
+
+
+
\ No newline at end of file
diff --git a/public/devtools/redirects.html b/public/devtools/redirects.html
new file mode 100644
index 00000000..d5b3f8e1
--- /dev/null
+++ b/public/devtools/redirects.html
@@ -0,0 +1,309 @@
+
+
+duxt — Redirects
+
+
+ Redirects
+ Every redirect the server ships, with the rules redirectFrom generated marked as the layer’s own.
+ 4 rules
3 from redirectFrom
1 from elsewhere
One rule per prefix a page is served under — the repository segment, the version segment and every locale — because an old URL was bookmarked under exactly one of them and nothing says which.
+
+
+
\ No newline at end of file
diff --git a/public/devtools/search.html b/public/devtools/search.html
new file mode 100644
index 00000000..8853debb
--- /dev/null
+++ b/public/devtools/search.html
@@ -0,0 +1,324 @@
+
+
+duxt — Search
+
+
+ Search
+ Ask the search index what it actually holds.
+
+
+ Search
+ 5 sections indexed
2 matching
One row per heading — which is what the dialog groups back into pages. The text column is exactly what FTS5 matches against.
+
+
+
\ No newline at end of file
diff --git a/public/devtools/sources.html b/public/devtools/sources.html
new file mode 100644
index 00000000..671111d5
--- /dev/null
+++ b/public/devtools/sources.html
@@ -0,0 +1,309 @@
+
+
+duxt — Sources
+
+
+ Sources
+ What duxt.sources became: one collection per source and version, and the URL prefix each serves.
+ 5 collections
2 repositories
4 versions
2 not current
Configured in /srv/acme-docs/app/app.config.ts.
+
+
+
\ No newline at end of file
diff --git a/public/devtools/versions.html b/public/devtools/versions.html
new file mode 100644
index 00000000..86f96b81
--- /dev/null
+++ b/public/devtools/versions.html
@@ -0,0 +1,318 @@
+
+
+duxt — Versions
+
+
+ Versions
+ Which versions carry which page. The view the fallback is debugged from.
+ acme/sdk en * the version served without a version segment.
acme/sdk de * the version served without a version segment.
acme/sdk v2 * the language served from the source path itself, without a folder.
acme/sdk v1.9 * the language served from the source path itself, without a folder.
+
+
+
\ No newline at end of file
diff --git a/scripts/build-devtools-previews.ts b/scripts/build-devtools-previews.ts
new file mode 100644
index 00000000..8608aa1b
--- /dev/null
+++ b/scripts/build-devtools-previews.ts
@@ -0,0 +1,59 @@
+import { mkdirSync, writeFileSync } from 'node:fs';
+import { join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { createJiti } from 'jiti';
+
+/**
+ * The devtools panels, rendered from fixtures into static files.
+ *
+ * The documentation embeds these, and they are generated rather than captured:
+ * a screenshot of a panel is out of date the day a column is added, and the
+ * real route only exists inside a dev server — the tab is not registered in a
+ * build at all. Rendering them through the panels' own functions means the
+ * pictures in the documentation cannot disagree with the tab they describe.
+ *
+ * Loaded through jiti rather than imported, for the reason `duxt-app-config.ts`
+ * uses it: the layer's own sources are extensionless TypeScript, which Vite
+ * resolves and plain Node does not.
+ *
+ * The output is committed, so a clone renders the docs without running this
+ * first. `pnpm check:previews` regenerates and fails on a difference — the same
+ * proof `check:policy` gives for the two permission files.
+ */
+const jiti = createJiti(import.meta.url, { interopDefault: true });
+
+/**
+ * The preview module's shape, declared rather than imported.
+ *
+ * `tsc` here compiles the meta scripts under Node's own resolution, and the
+ * layer's sources are extensionless TypeScript meant for Vite — importing one
+ * for its types drags the whole layer, its ambient globals included, into a
+ * project built for four scripts. jiti loads it at runtime; this says what
+ * comes back, and `pnpm check:previews` is what notices if that ever changes.
+ */
+interface PreviewModule {
+ PREVIEW_TABS: { slug: string; title: string; file: string }[];
+ previewPage: (slug: string) => string;
+}
+
+const { PREVIEW_TABS, previewPage } = (await jiti.import(
+ '../server/devtools/preview'
+)) as PreviewModule;
+
+/**
+ * The LAYER's public directory, not `www`'s.
+ *
+ * The pages that embed these ship inside the package, so the documents they
+ * ask for have to ship inside it too — served from `www` they resolve on this
+ * repo's own site and on no other, which is ten empty frames for anyone else
+ * rendering the reference. Nuxt serves every layer's `public/`, so a consumer
+ * gets them by extending.
+ */
+const OUT = fileURLToPath(new URL('../public/devtools/', import.meta.url));
+
+mkdirSync(OUT, { recursive: true });
+
+for (const tab of PREVIEW_TABS) {
+ writeFileSync(join(OUT, tab.file), previewPage(tab.slug));
+ console.log(`wrote ${tab.file}`);
+}
diff --git a/scripts/check-a11y.ts b/scripts/check-a11y.ts
index 92745f7c..bc2e8827 100644
--- a/scripts/check-a11y.ts
+++ b/scripts/check-a11y.ts
@@ -38,15 +38,23 @@ const server = join(root, 'www', '.output', 'server', 'index.mjs');
*
* The chrome is what this checks, and the chrome differs by layout: the landing
* page has no sidebar, a section root has no table of contents, a deep page has
- * everything, and the 404 has its own view. A sixth page of the same shape
- * would add runtime and no coverage.
+ * everything, an embedded panel has an iframe, and the 404 has its own view. A
+ * sixth page of the same shape would add runtime and no coverage.
+ *
+ * WRITE THE ROUTES THE SITE ACTUALLY SERVES. Three of these carried a `/duxt/`
+ * prefix and a `/workflows/v0.7.0` from a multi-source configuration `www` no
+ * longer has, so they 404'd — and a 404 renders the error view, which passes.
+ * The gate reported five pages while checking the same error page three times.
+ * A route that moves must move here too; `curl -o /dev/null -w '%{http_code}'`
+ * over this list against the built server is the whole check.
*/
const ROUTES = [
'/',
- '/duxt/getting-started',
- '/duxt/reference/mdc-components',
- '/workflows/v0.7.0',
- '/duxt/does-not-exist'
+ '/getting-started',
+ '/reference/mdc-components',
+ // The one page with an iframe, so `frame-title` has something to judge.
+ '/reference/devtools/sources',
+ '/does-not-exist'
];
const PORT = 3123;
diff --git a/scripts/check-previews.ts b/scripts/check-previews.ts
new file mode 100644
index 00000000..93112276
--- /dev/null
+++ b/scripts/check-previews.ts
@@ -0,0 +1,75 @@
+import { readdirSync, readFileSync } from 'node:fs';
+import { join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { createJiti } from 'jiti';
+
+/**
+ * Are the committed panel previews still what the panels render?
+ *
+ * The documentation embeds generated HTML rather than screenshots so it cannot
+ * go stale — but only if something notices when the generator's output moves.
+ * This is that something: it renders the fixtures again and compares, exactly
+ * as `check:policy` proves the two permission files still agree.
+ *
+ * Regenerate with `pnpm previews`.
+ */
+const jiti = createJiti(import.meta.url, { interopDefault: true });
+
+/**
+ * The preview module's shape, declared rather than imported.
+ *
+ * `tsc` here compiles the meta scripts under Node's own resolution, and the
+ * layer's sources are extensionless TypeScript meant for Vite — importing one
+ * for its types drags the whole layer, its ambient globals included, into a
+ * project built for four scripts. jiti loads it at runtime; this says what
+ * comes back, and `pnpm check:previews` is what notices if that ever changes.
+ */
+interface PreviewModule {
+ PREVIEW_TABS: { slug: string; title: string; file: string }[];
+ previewPage: (slug: string) => string;
+}
+
+const { PREVIEW_TABS, previewPage } = (await jiti.import(
+ '../server/devtools/preview'
+)) as PreviewModule;
+
+const OUT = fileURLToPath(new URL('../public/devtools/', import.meta.url));
+
+const stale: string[] = [];
+const expected = new Set(PREVIEW_TABS.map((tab) => tab.file));
+
+for (const tab of PREVIEW_TABS) {
+ let committed: string;
+ try {
+ committed = readFileSync(join(OUT, tab.file), 'utf8');
+ } catch {
+ stale.push(`${tab.file} is missing`);
+ continue;
+ }
+
+ if (committed !== previewPage(tab.slug)) {
+ stale.push(`${tab.file} differs from what the panel renders`);
+ }
+}
+
+// A tab that was renamed leaves its old file behind, and a docs page can go on
+// embedding it for months — the file is there, it simply describes nothing.
+let present: string[] = [];
+try {
+ present = readdirSync(OUT).filter((file) => file.endsWith('.html'));
+} catch {
+ stale.push('public/devtools/ does not exist');
+}
+
+for (const file of present) {
+ if (!expected.has(file)) stale.push(`${file} belongs to no panel`);
+}
+
+if (stale.length) {
+ console.error('Devtools previews are out of date:');
+ for (const line of stale) console.error(` - ${line}`);
+ console.error('\nRun `pnpm previews` and commit the result.');
+ process.exit(1);
+}
+
+console.log(`${PREVIEW_TABS.length} devtools previews match their panels.`);
diff --git a/scripts/check-seo.ts b/scripts/check-seo.ts
new file mode 100644
index 00000000..97a969df
--- /dev/null
+++ b/scripts/check-seo.ts
@@ -0,0 +1,344 @@
+#!/usr/bin/env node
+
+/**
+ * Reads the SEO tags off the BUILT pages and fails the build over the ones the
+ * theme promises.
+ *
+ * Every rule here was, until this file existed, a comment claiming something no
+ * test could see. That matters more for SEO than for most things, because none
+ * of it is visible: a canonical pointing at the wrong version of a page, a
+ * missing `noindex` on an old one, an `hreflang` set that names a locale the
+ * site does not serve — each is invisible in the browser, silent in the build,
+ * and wrong for months.
+ *
+ * WHY THE SITE IS SERVED WITH AN ORIGIN IT DOES NOT HAVE. Half of this only
+ * exists when the site knows where it is served from: `hreflang` is invalid
+ * relative, and a canonical without an origin cannot be checked for one. `www`
+ * states no domain, deliberately — it is a development site, and a made-up
+ * domain in a committed config is a made-up domain a consumer would copy. So
+ * the check hands the built server its own address instead, through the two
+ * environment variables the modules already read, and the assertions run
+ * against that.
+ *
+ * Run after `build:app`, beside `check:a11y`, for the same reason: what it
+ * reads is the rendered HTML, which is the only place these tags exist.
+ */
+
+import { spawn } from 'node:child_process';
+import { fileURLToPath } from 'node:url';
+import { dirname, join } from 'node:path';
+import { JSDOM } from 'jsdom';
+
+const root = join(dirname(fileURLToPath(import.meta.url)), '..');
+const server = join(root, 'www', '.output', 'server', 'index.mjs');
+
+const PORT = 3124;
+const ORIGIN = `http://localhost:${PORT}`;
+
+/**
+ * The locales `www` serves, in the order i18n lists them, and the one served
+ * without a prefix.
+ *
+ * Written out rather than imported: the layer's config is a `.ts` module Nuxt
+ * compiles, and reading it from a plain node script means loading Nuxt. If a
+ * locale is added, this list is the second place to say so — and the check
+ * fails loudly rather than silently skipping the new one.
+ */
+const LOCALES = ['en-GB', 'en-US', 'de-DE', 'es-ES', 'fr-FR', 'pt-PT', 'pt-BR'];
+
+/** A page of each kind, as `check:a11y` picks them, plus one translation. */
+const PAGES = {
+ landing: '/',
+ doc: '/getting-started',
+ translated: '/de-DE/getting-started',
+ missing: '/does-not-exist'
+};
+
+async function main() {
+ const child = spawn(process.execPath, [server], {
+ env: {
+ ...process.env,
+ PORT: String(PORT),
+ NITRO_PORT: String(PORT),
+ // The two names the modules read for the same fact: i18n's for the
+ // alternate links and duxt's own `absolute()`, site config's for
+ // everything under @nuxtjs/seo.
+ NUXT_PUBLIC_I18N_BASE_URL: ORIGIN,
+ NUXT_SITE_URL: ORIGIN
+ },
+ stdio: ['ignore', 'ignore', 'pipe']
+ });
+
+ let stderr = '';
+ child.stderr.on('data', (chunk: Buffer) => (stderr += chunk.toString()));
+
+ try {
+ await waitForServer();
+
+ const failures = [
+ ...(await checkCanonicals()),
+ ...(await checkAlternates()),
+ ...(await checkRobots()),
+ ...(await checkSocial()),
+ ...(await checkSchemaOrg())
+ ];
+
+ if (failures.length) {
+ console.error(`\nSEO check failed:\n - ${failures.join('\n - ')}\n`);
+ process.exitCode = 1;
+ return;
+ }
+
+ console.log(
+ `SEO check passed over ${Object.keys(PAGES).length} pages ` +
+ `(canonical, hreflang, robots, Open Graph, schema.org).`
+ );
+ } catch (error) {
+ console.error(`\nSEO check could not run: ${String(error)}`);
+ if (stderr.trim()) console.error(stderr.trim());
+ process.exitCode = 1;
+ } finally {
+ child.kill('SIGTERM');
+ }
+}
+
+async function waitForServer() {
+ for (let attempt = 0; attempt < 60; attempt++) {
+ try {
+ await fetch(`${ORIGIN}/`);
+ return;
+ } catch {
+ await new Promise((resolve) => setTimeout(resolve, 500));
+ }
+ }
+
+ throw new Error(
+ `the built server did not answer on port ${PORT}. Run \`pnpm build:app\` first.`
+ );
+}
+
+async function head(route: string) {
+ const response = await fetch(`${ORIGIN}${route}`, {
+ // Without the header Nitro answers an error route with JSON, and the check
+ // then reads no tags at all off a page that renders plenty.
+ headers: { accept: 'text/html' }
+ });
+
+ const { window } = new JSDOM(await response.text(), {
+ url: `${ORIGIN}${route}`
+ });
+
+ return window.document;
+}
+
+function meta(document: Document, name: string) {
+ return (
+ document
+ .querySelector(`meta[property="${name}"]`)
+ ?.getAttribute('content') ??
+ document.querySelector(`meta[name="${name}"]`)?.getAttribute('content') ??
+ undefined
+ );
+}
+
+/**
+ * ONE canonical per page, absolute, and spelled as the route is.
+ *
+ * The count is the point: nuxt-seo-utils writes one of its own, and the version
+ * rules in `[...slug].vue` write the one that has to win. Two shipping together
+ * is the failure this cannot be allowed to have — a crawler picking either is a
+ * crawler picking at random.
+ */
+async function checkCanonicals() {
+ const failures: string[] = [];
+
+ for (const [kind, route] of Object.entries(PAGES)) {
+ if (kind === 'missing') continue;
+
+ const document = await head(route);
+ const links = [...document.querySelectorAll('link[rel="canonical"]')];
+
+ if (links.length !== 1) {
+ failures.push(
+ `${route}: expected exactly one canonical, found ${links.length}`
+ );
+ continue;
+ }
+
+ const href = links[0]?.getAttribute('href') ?? '';
+
+ if (!href.startsWith(ORIGIN)) {
+ failures.push(`${route}: canonical is not absolute against the site url`);
+ }
+
+ // `canonicalLowercase: false` in nuxt.config, asserted: a locale prefix is
+ // case-sensitive, and lowercasing it points every translated page at a URL
+ // that does not exist.
+ if (kind === 'translated' && !href.includes('/de-DE/')) {
+ failures.push(
+ `${route}: canonical lost the locale's case — got ${href || '(empty)'}`
+ );
+ }
+ }
+
+ return failures;
+}
+
+/**
+ * The alternate links name every locale the site serves, both ways, plus
+ * `x-default`.
+ *
+ * A partial set is worse than none: it tells a search engine these three pages
+ * are translations of one another and leaves the fourth looking like a
+ * duplicate of whichever it matched first.
+ */
+async function checkAlternates() {
+ const failures: string[] = [];
+
+ for (const route of [PAGES.doc, PAGES.translated]) {
+ const document = await head(route);
+ const found = new Set(
+ [...document.querySelectorAll('link[rel="alternate"][hreflang]')].map(
+ (link) => link.getAttribute('hreflang') ?? ''
+ )
+ );
+
+ for (const locale of LOCALES) {
+ if (!found.has(locale)) {
+ failures.push(`${route}: no hreflang for ${locale}`);
+ }
+ }
+
+ if (!found.has('x-default')) {
+ failures.push(
+ `${route}: no x-default, so no locale is named as the fallback`
+ );
+ }
+ }
+
+ return failures;
+}
+
+/** What must not be indexed, is not. */
+async function checkRobots() {
+ const failures: string[] = [];
+
+ const missing = meta(await head(PAGES.missing), 'robots');
+ if (!missing?.includes('noindex')) {
+ failures.push(
+ `${PAGES.missing}: the error page is indexable (robots: ${missing ?? 'unset'})`
+ );
+ }
+
+ // The mirror of it: an ordinary page in the default locale must NOT carry
+ // noindex, or the rule above would pass a site nobody can find at all.
+ const doc = meta(await head(PAGES.doc), 'robots');
+ if (doc?.includes('noindex')) {
+ failures.push(`${PAGES.doc}: an ordinary page carries noindex`);
+ }
+
+ return failures;
+}
+
+/**
+ * The tags a share card is built from — including the two this layer never
+ * writes by hand, because nuxt-seo-utils derives them from the title and
+ * description.
+ */
+async function checkSocial() {
+ const failures: string[] = [];
+
+ for (const route of [PAGES.landing, PAGES.doc]) {
+ const document = await head(route);
+
+ for (const tag of ['og:title', 'og:description', 'og:image']) {
+ if (!meta(document, tag)) failures.push(`${route}: no ${tag}`);
+ }
+
+ const locale = meta(document, 'og:locale');
+ if (!locale) {
+ failures.push(`${route}: no og:locale`);
+ } else if (locale.includes('-')) {
+ failures.push(
+ `${route}: og:locale is ${locale}; Open Graph spells it with an underscore`
+ );
+ }
+ }
+
+ // The default locale's page names the others as alternates, which is what
+ // makes a share card come back in the reader's language.
+ const alternates = [
+ ...(await head(PAGES.doc)).querySelectorAll(
+ 'meta[property="og:locale:alternate"]'
+ )
+ ].length;
+
+ if (alternates !== LOCALES.length - 1) {
+ failures.push(
+ `${PAGES.doc}: ${alternates} og:locale:alternate tags for ` +
+ `${LOCALES.length - 1} other locales`
+ );
+ }
+
+ return failures;
+}
+
+/**
+ * One graph per page, and the nodes each kind of page owes.
+ *
+ * The count matters here too: the hand-written `@graph` this replaced lived in
+ * the page, and a second script tag would mean the two are both still shipping
+ * — the same silent duplication as the canonical.
+ */
+async function checkSchemaOrg() {
+ const failures: string[] = [];
+
+ const expected: Record = {
+ [PAGES.landing]: ['WebSite'],
+ [PAGES.doc]: ['WebSite', 'TechArticle', 'BreadcrumbList']
+ };
+
+ for (const [route, types] of Object.entries(expected)) {
+ const document = await head(route);
+ const scripts = [
+ ...document.querySelectorAll('script[type="application/ld+json"]')
+ ];
+
+ if (scripts.length !== 1) {
+ failures.push(
+ `${route}: expected one ld+json graph, found ${scripts.length}`
+ );
+ continue;
+ }
+
+ let graph: unknown;
+ try {
+ graph = JSON.parse(scripts[0]?.textContent ?? '');
+ } catch (error) {
+ failures.push(`${route}: the ld+json does not parse — ${String(error)}`);
+ continue;
+ }
+
+ const nodes = (graph as { '@graph'?: { '@type'?: unknown }[] })['@graph'];
+ if (!Array.isArray(nodes)) {
+ failures.push(`${route}: the ld+json has no @graph`);
+ continue;
+ }
+
+ const present = new Set(
+ nodes.flatMap((node) =>
+ Array.isArray(node['@type'])
+ ? (node['@type'] as string[])
+ : [String(node['@type'])]
+ )
+ );
+
+ for (const type of types) {
+ if (!present.has(type)) failures.push(`${route}: no ${type} node`);
+ }
+ }
+
+ return failures;
+}
+
+await main();
diff --git a/server/devtools/content.ts b/server/devtools/content.ts
index d0784592..5890253e 100644
--- a/server/devtools/content.ts
+++ b/server/devtools/content.ts
@@ -4,44 +4,21 @@ import {
queryCollectionSearchSections
} from '@nuxt/content/nitro';
import type { PageRecord } from '../../validate-report';
-import { report, walk } from '../../validate-report';
-import {
- code,
- dim,
- editor,
- escape,
- filter,
- resolvedSources,
- row,
- stat,
- stats,
- table,
- tag
-} from './shell';
+import { walk } from '../../validate-report';
+import { context, resolvedSources } from './context';
+import type { Doc, DocGroup, IndexedSection } from './render/content';
+import { renderChecks, renderPages, renderSearch } from './render/content';
/**
- * The three panels that read the content itself, rather than the config.
+ * The queries behind the three content panels.
*
* All three ask the running site — `queryCollection` against the database the
* pages render from — instead of re-reading Markdown off disk. A panel that
- * parsed its own copy would answer about a site that does not exist.
+ * parsed its own copy would answer about a site that does not exist. What comes
+ * back goes to `render/content.ts`, which turns it into the tables.
*/
-interface Doc {
- path: string;
- title?: string;
- description?: string;
- icon?: string;
- id?: string;
- lastUpdated?: string;
- contributors?: { name: string }[];
- body?: unknown;
-}
-
-async function docsOf(
- event: H3Event,
- fields?: string[]
-): Promise<{ collection: string; docs: Doc[] }[]> {
+async function docsOf(event: H3Event, fields?: string[]): Promise {
const sources = resolvedSources();
return Promise.all(
@@ -77,72 +54,10 @@ export async function pagesPanel(event: H3Event): Promise {
'contributors'
]);
- const total = groups.reduce((sum, group) => sum + group.docs.length, 0);
- const untitled = groups.reduce(
- (sum, group) => sum + group.docs.filter((doc) => !doc.title).length,
- 0
- );
- const dated = groups.reduce(
- (sum, group) => sum + group.docs.filter((doc) => doc.lastUpdated).length,
- 0
- );
-
- const summary = stats([
- stat(total, 'pages'),
- stat(groups.length, 'collections'),
- stat(dated, 'with history', dated ? '' : 'warn'),
- stat(untitled, 'without a title', untitled ? 'warn' : 'ok')
- ]);
-
- // One block per collection, the first opened: a site with five versions has
- // five near-identical tables, and all five expanded is a page nobody reads.
- const sections = groups.map(({ collection, docs }, index) => {
- const rows = docs.map((doc) =>
- row([
- code(doc.path),
- escape(doc.title) || tag('missing', 'warn'),
- doc.description ? dim('yes') : tag('missing', 'warn'),
- doc.icon ? code(doc.icon) : dim('—'),
- doc.lastUpdated
- ? escape(doc.lastUpdated.slice(0, 10))
- : dim('no history'),
- doc.contributors?.length
- ? escape(String(doc.contributors.length))
- : dim('—'),
- editor(doc.id, doc.id?.split('/').slice(1).join('/'))
- ])
- );
-
- return `
- ${escape(collection)} ${docs.length} pages
- ${docs.length > 12 ? filter(`Filter ${collection}`) : ''}
- ${table(
- [
- 'Path',
- 'Title',
- 'Description',
- 'Icon',
- 'Last updated',
- 'Contributors',
- 'File'
- ],
- rows,
- 'Content produced no pages here. Every URL under this prefix is a 404 — check the source\u2019s `path` and `refs`; the Checks panel reports it as an error.'
- )}
- `;
- });
-
- return `${summary}${sections.join('')}`;
+ return renderPages(groups, context.rootDir);
}
-/**
- * The build validator, run against the served site.
- *
- * Identical checks to `pnpm build`, from `validate-report.ts` — the point is
- * not a second opinion but a place where the findings stay put. A warning
- * printed during a build has scrolled away by the time anyone reads it, and
- * "last updated" on a broken link is exactly when you want the file name.
- */
+/** The build validator, run against the served site. */
export async function checksPanel(event: H3Event): Promise {
const sources = resolvedSources();
const groups = await docsOf(event);
@@ -165,63 +80,14 @@ export async function checksPanel(event: H3Event): Promise {
})
);
- const { errors, warnings } = report(sources, pages);
-
- const list = (findings: string[], kind: string) =>
- table(
- ['', kind === 'error' ? 'Error' : 'Warning'],
- findings.map((finding) => row([tag(kind, kind), fileLinked(finding)]))
- );
-
- const summary = stats([
- stat(pages.length, 'pages checked'),
- stat(sources.length, 'collections'),
- stat(errors.length, 'errors', errors.length ? 'error' : 'ok'),
- stat(warnings.length, 'warnings', warnings.length ? 'warn' : 'ok')
- ]);
-
- return `${summary}${errors.length ? `Errors ${list(errors, 'error')}` : ''}${
- warnings.length ? `Warnings ${list(warnings, 'warn')}` : ''
- }`;
+ return renderChecks(sources, pages, context.rootDir);
}
-/**
- * Findings name their file in quotes; turn the first one into an editor link.
- *
- * The message stays exactly the string the build prints — the two must not
- * drift — so the link is added by reading it rather than by threading a second
- * field through the report.
- */
-function fileLinked(finding: string): string {
- const match = /"([^"]+\.md)"/.exec(finding);
- if (!match) return escape(finding);
-
- const [quoted, file] = match;
-
- return (
- escape(finding.slice(0, match.index)) +
- editor(file, quoted) +
- escape(finding.slice(match.index + quoted.length))
- );
-}
-
-/**
- * What the search index holds.
- *
- * The search had three separate bugs — a filter fighting a filter, results that
- * were heading fragments, table cells run together without separators — and all
- * three were invisible because nobody could see the sections the index is built
- * from. This is that list, with the same query the client runs.
- */
+/** What the search index holds, through the same query the client runs. */
export async function searchPanel(
event: H3Event,
term: string
): Promise {
- const form = `
-
- Search
- `;
-
const sources = resolvedSources();
const sections = (
@@ -238,50 +104,13 @@ export async function searchPanel(
return found.map((section) => ({
collection: source.collection,
...section
- }));
+ })) as IndexedSection[];
} catch {
- return [];
+ return [] as IndexedSection[];
}
})
)
).flat();
- const needle = term.trim().toLowerCase();
-
- const matching = needle
- ? sections.filter(
- (section) =>
- section.title?.toLowerCase().includes(needle) ||
- section.content?.toLowerCase().includes(needle)
- )
- : sections;
-
- // The id is the longest string in the table and the least worth reading in
- // full, so it is the one that gets clipped — otherwise it takes the width the
- // indexed text needs, and the column that answers the question is a sliver.
- const rows = matching.slice(0, 200).map(
- (section) => `
- ${code(section.collection)}
- ${escape(section.id)}
- ${escape(section.title)}
- ${escape((section.titles ?? []).join(' › ')) || dim('—')}
- ${escape((section.content ?? '').slice(0, 180))}${(section.content ?? '').length > 180 ? dim(' …') : ''}
- `
- );
-
- const summary = stats([
- stat(sections.length, 'sections indexed'),
- needle
- ? stat(matching.length, 'matching', matching.length ? '' : 'warn')
- : undefined,
- matching.length > 200 ? stat(200, 'shown', 'warn') : undefined
- ]);
-
- return `${form}${summary}One row per heading — which is what the dialog groups back into pages. The text column is exactly what FTS5 matches against.
${table(
- ['Collection', 'Id', 'Heading', 'Under', 'Indexed text'],
- rows,
- needle
- ? `Nothing in the index matches \u201c${escape(term)}\u201d. FTS5 matches terms and prefixes, so a typo returns nothing.`
- : 'The index is empty.'
- )}`;
+ return renderSearch(term, sections);
}
diff --git a/server/devtools/context.ts b/server/devtools/context.ts
new file mode 100644
index 00000000..ec48eba2
--- /dev/null
+++ b/server/devtools/context.ts
@@ -0,0 +1,35 @@
+// @ts-expect-error virtual module, generated in modules/devtools.ts
+import { devtools } from '#duxt-devtools';
+import type { DevtoolsContext } from './shell';
+
+/**
+ * The two things a panel cannot render itself: where this build sits on disk,
+ * and what the running app config resolved to.
+ *
+ * Split from `shell.ts` so that file stays free of both the virtual module and
+ * the Nitro auto-imports. Everything in `shell.ts` and `render/` is then plain
+ * data in, HTML out — importable from a plain Node script, which is how the
+ * documentation renders a panel of its own with fixture data instead of a
+ * screenshot that goes stale.
+ */
+export const context = devtools as DevtoolsContext;
+
+/** The collections the site is serving, straight out of the running app config. */
+export function resolvedSources(): DuxtResolvedSource[] {
+ const { duxt } = useAppConfig() as { duxt?: Partial };
+
+ return (
+ duxt?.resolvedSources ?? [
+ {
+ collection: 'docs',
+ prefix: '',
+ path: 'docs',
+ isDefault: true,
+ status: 'current',
+ // A source read off disk is a full checkout, so its history is
+ // readable without asking.
+ history: true
+ }
+ ]
+ );
+}
diff --git a/server/devtools/handler.ts b/server/devtools/handler.ts
index 101d38cc..e27dbb67 100644
--- a/server/devtools/handler.ts
+++ b/server/devtools/handler.ts
@@ -1,6 +1,7 @@
import { rmSync } from 'node:fs';
import type { H3Event } from 'h3';
-import { context, page, PANELS } from './shell';
+import { context } from './context';
+import { page, PANELS } from './shell';
import { pathsPanel, sourcesPanel, versionsPanel } from './sources';
import { checksPanel, pagesPanel, searchPanel } from './content';
import { cacheEntryToDrop } from './entry-path';
diff --git a/server/devtools/preview.ts b/server/devtools/preview.ts
new file mode 100644
index 00000000..726286e3
--- /dev/null
+++ b/server/devtools/preview.ts
@@ -0,0 +1,417 @@
+import type { DuxtSource } from '../../sources-resolve';
+import { resolveSources } from '../../sources-resolve';
+import type { PageRecord } from '../../validate-report';
+import { page, PANELS } from './shell';
+import type { Doc, DocGroup, IndexedSection } from './render/content';
+import { renderChecks, renderPages, renderSearch } from './render/content';
+import { renderPaths, renderSources, renderVersions } from './render/sources';
+import type { RedirectRule } from './render/system';
+import {
+ renderCache,
+ renderConfig,
+ renderI18n,
+ renderRedirects
+} from './render/system';
+
+/**
+ * Every panel, rendered from a site that does not exist.
+ *
+ * The documentation has to show what a panel looks like, and the two obvious
+ * ways are both bad: a screenshot goes stale the day a column is added, and
+ * pointing the reader at the real route only works inside a dev server — the
+ * tab is not registered in a build at all, so a published page would frame a
+ * 404.
+ *
+ * So the panels are rendered here through the SAME functions the tab uses,
+ * over a fixture site. What the reader sees in the documentation is the real
+ * renderer's output; when a panel gains a column, the pictures in the docs
+ * gain it in the same commit, because there are no pictures.
+ *
+ * The fixture is one imagined site, not ten unrelated tables: `acme/sdk` in two
+ * versions and two languages, `acme/cli` in one — which is the smallest shape
+ * that makes every panel say something. A single-source site would render a
+ * Versions tab with nothing in it.
+ */
+
+const SOURCES: DuxtSource[] = [
+ {
+ path: 'docs',
+ repo: 'acme/sdk',
+ refs: [
+ { branch: 'main', label: 'v2' },
+ { tag: 'v1.9.4', label: 'v1.9', status: 'deprecated' }
+ ],
+ locales: ['en', 'de'],
+ history: true
+ },
+ { path: 'docs', repo: 'acme/cli' }
+];
+
+const RESOLVED = resolveSources(SOURCES, { defaultLocale: 'en' });
+
+const ROOT = '/srv/acme-docs';
+
+/** The layer's own config, as this fixture site would have written it. */
+const CONSUMER = {
+ title: 'Acme SDK',
+ version: 'v2.4.0',
+ sources: SOURCES,
+ resolvedSources: RESOLVED,
+ landing: {
+ headline: {
+ en: 'Ship the SDK, not the glue',
+ de: 'Weniger Kleber, mehr SDK'
+ },
+ command: 'npm i @acme/sdk'
+ },
+ footer: { note: '© Acme Inc.' }
+} as unknown as Record;
+
+/* -------------------------------------------------------------------------- */
+/* The pages the fixture site serves */
+/* -------------------------------------------------------------------------- */
+
+interface PageSeed {
+ path: string;
+ title?: string;
+ german?: string;
+ description?: boolean;
+ icon?: string;
+ file: string;
+ updated?: string;
+ contributors?: number;
+}
+
+const SDK_PAGES: PageSeed[] = [
+ {
+ path: '/',
+ title: 'Acme SDK',
+ german: 'Acme SDK',
+ description: true,
+ icon: 'lucide:book-open-text',
+ file: 'index.md',
+ updated: '2026-02-11',
+ contributors: 6
+ },
+ {
+ path: '/getting-started',
+ title: 'Getting started',
+ german: 'Erste Schritte',
+ description: true,
+ icon: 'lucide:rocket',
+ file: '1.getting-started/index.md',
+ updated: '2026-02-09',
+ contributors: 4
+ },
+ {
+ path: '/getting-started/install',
+ title: 'Install',
+ german: 'Installation',
+ description: true,
+ file: '1.getting-started/1.install.md',
+ updated: '2026-01-28',
+ contributors: 3
+ },
+ {
+ path: '/guides/deploying',
+ title: 'Deploying',
+ german: 'Deployen',
+ description: true,
+ icon: 'lucide:ship',
+ file: '2.guides/1.deploying.md',
+ updated: '2026-02-10',
+ contributors: 2
+ },
+ {
+ path: '/guides/caching',
+ title: 'Caching',
+ description: true,
+ file: '2.guides/2.caching.md',
+ updated: '2026-02-12',
+ contributors: 1
+ },
+ {
+ path: '/reference/client',
+ title: 'Client',
+ german: 'Client',
+ description: true,
+ icon: 'lucide:plug',
+ file: '3.reference/1.client.md',
+ updated: '2026-02-04',
+ contributors: 5
+ },
+ {
+ // The row every Pages panel is opened for: a page that made it into the
+ // build with no frontmatter to speak of.
+ path: '/reference/errors',
+ file: '3.reference/2.errors.md',
+ updated: '2025-12-19',
+ contributors: 1
+ },
+ {
+ path: '/changelog',
+ title: 'Changelog',
+ description: true,
+ icon: 'lucide:history',
+ file: '4.changelog.md',
+ updated: '2026-02-12',
+ contributors: 8
+ }
+];
+
+const CLI_PAGES: PageSeed[] = [
+ {
+ path: '/',
+ title: 'Acme CLI',
+ description: true,
+ icon: 'lucide:terminal',
+ file: 'index.md'
+ },
+ {
+ path: '/getting-started',
+ title: 'Getting started',
+ description: true,
+ file: '1.getting-started.md'
+ },
+ {
+ path: '/commands',
+ title: 'Commands',
+ description: true,
+ icon: 'lucide:list',
+ file: '2.commands/index.md'
+ },
+ {
+ path: '/commands/run',
+ title: 'acme run',
+ description: true,
+ file: '2.commands/1.run.md'
+ }
+];
+
+/**
+ * Which logical page each collection is missing.
+ *
+ * The gaps are the point. A translation lagging two pages behind and a version
+ * that predates a guide are exactly what the Versions matrix exists to show,
+ * and a fixture where every column is a full house would show nothing at all.
+ */
+const MISSING: Record = {
+ docs_de_sdk: ['/guides/caching', '/changelog'],
+ docs_sdk_v1_9: ['/guides/caching'],
+ docs_de_sdk_v1_9: ['/guides/caching', '/changelog', '/reference/errors']
+};
+
+function docsOf(source: DuxtResolvedSource): Doc[] {
+ const seeds = source.repo === 'cli' ? CLI_PAGES : SDK_PAGES;
+ const skip = new Set(MISSING[source.collection] ?? []);
+ const german = source.locale === 'de';
+
+ return seeds
+ .filter((seed) => !skip.has(seed.path))
+ .map((seed) => ({
+ path: `${source.prefix}${seed.path === '/' ? '' : seed.path}` || '/',
+ title: german ? seed.german : seed.title,
+ description: seed.description
+ ? 'One line, for the card and the OG image.'
+ : undefined,
+ icon: seed.icon,
+ id: `${source.collection}/${german ? 'de/' : ''}${seed.file}`,
+ lastUpdated:
+ source.history && seed.updated
+ ? `${seed.updated}T09:12:00Z`
+ : undefined,
+ contributors: Array.from(
+ { length: seed.contributors ?? 0 },
+ (_, index) => ({
+ name: `Contributor ${index + 1}`
+ })
+ )
+ }));
+}
+
+const GROUPS: DocGroup[] = RESOLVED.map((source) => ({
+ collection: source.collection,
+ docs: docsOf(source)
+}));
+
+const BY_COLLECTION = new Map(
+ GROUPS.map((group) => [group.collection, group.docs.map((doc) => doc.path)])
+);
+
+/**
+ * The same pages as the validator sees them.
+ *
+ * Two deliberate faults, because a Checks panel with nothing in it teaches
+ * nobody what a finding looks like: one link to a page that was renamed, and
+ * one page with no frontmatter.
+ */
+const RECORDS: PageRecord[] = GROUPS.flatMap((group) =>
+ group.docs.map((doc) => ({
+ collection: group.collection,
+ path: doc.path,
+ file: doc.id ?? doc.path,
+ title: doc.title,
+ description: doc.description,
+ anchors: new Set(['install', 'usage']),
+ links:
+ doc.path.endsWith('/getting-started') && group.collection === 'docs_sdk'
+ ? [{ href: '/guides/cacheing' }, { href: '/guides/deploying#install' }]
+ : []
+ }))
+);
+
+const SECTIONS: IndexedSection[] = [
+ {
+ collection: 'docs_sdk',
+ id: 'docs_sdk/1.getting-started/1.install.md#install',
+ title: 'Install',
+ titles: ['Getting started'],
+ content:
+ 'Add the package with your package manager of choice. The SDK ships types, so nothing else is needed to get autocompletion in an editor.'
+ },
+ {
+ collection: 'docs_sdk',
+ id: 'docs_sdk/2.guides/1.deploying.md#build-output',
+ title: 'Build output',
+ titles: ['Deploying'],
+ content:
+ 'The build writes a single server bundle and a public directory. Both are what a host needs; nothing else in the repository is read at runtime.'
+ },
+ {
+ collection: 'docs_sdk',
+ id: 'docs_sdk/2.guides/2.caching.md#invalidation',
+ title: 'Invalidation',
+ titles: ['Guides', 'Caching'],
+ content:
+ 'A cached response is keyed by the request path and the account tier, so a change of plan is visible without a purge.'
+ },
+ {
+ collection: 'docs_de_sdk',
+ id: 'docs_de_sdk/de/1.getting-started/1.install.md#installation',
+ title: 'Installation',
+ titles: ['Erste Schritte'],
+ content:
+ 'Das Paket wird mit dem Paketmanager der Wahl hinzugefügt. Typen liegen bei, mehr braucht ein Editor nicht.'
+ },
+ {
+ collection: 'docs_cli',
+ id: 'docs_cli/2.commands/1.run.md#flags',
+ title: 'Flags',
+ titles: ['Commands', 'acme run'],
+ content:
+ 'Every flag has a long form. The short forms exist for the three that are typed most often and are listed beside them.'
+ }
+];
+
+const LOCALE_KEYS = [
+ 'duxt.nav.open',
+ 'duxt.nav.close',
+ 'duxt.search.placeholder',
+ 'duxt.search.empty',
+ 'duxt.toc.title',
+ 'duxt.page.edit',
+ 'duxt.page.updated',
+ 'duxt.version.switch'
+];
+
+const CACHE = [
+ {
+ name: 'github.com-acme-sdk-main',
+ size: 4.6 * 1024 * 1024,
+ modified: new Date('2026-02-12T08:41:00Z')
+ },
+ {
+ name: 'github.com-acme-sdk-v1.9.4',
+ size: 3.9 * 1024 * 1024,
+ modified: new Date('2026-01-30T17:03:00Z')
+ },
+ {
+ name: 'github.com-acme-cli-main',
+ size: 820 * 1024,
+ modified: new Date('2026-02-11T12:20:00Z')
+ }
+];
+
+const REDIRECTS: RedirectRule[] = [
+ {
+ from: '/sdk/guides/deploy',
+ to: '/sdk/guides/deploying',
+ status: 301,
+ mine: true
+ },
+ {
+ from: '/de/sdk/guides/deploy',
+ to: '/de/sdk/guides/deploying',
+ status: 301,
+ mine: true
+ },
+ {
+ from: '/sdk/v1-9/guides/deploy',
+ to: '/sdk/v1-9/guides/deploying',
+ status: 301,
+ mine: true
+ },
+ { from: '/sitemap.xml', to: '/sitemap_index.xml', status: 307, mine: false }
+];
+
+/* -------------------------------------------------------------------------- */
+/* The panels */
+/* -------------------------------------------------------------------------- */
+
+/** The body of one panel, rendered from the fixture site. */
+export function previewBody(slug: string): string {
+ switch (slug) {
+ case 'paths':
+ return renderPaths({
+ // A page that v2 has and v1.9 does not: the fallback's own case, and
+ // the reason this panel exists.
+ input: '/de/sdk/v1-9/guides/caching',
+ locales: ['en', 'de'],
+ fallbackLocale: 'en',
+ sources: RESOLVED,
+ found: null,
+ byCollection: BY_COLLECTION
+ });
+ case 'pages':
+ return renderPages(GROUPS, ROOT);
+ case 'versions':
+ return renderVersions(RESOLVED, BY_COLLECTION);
+ case 'checks':
+ return renderChecks(RESOLVED, RECORDS, ROOT);
+ case 'search':
+ return renderSearch('install', SECTIONS);
+ case 'config':
+ return renderConfig(CONSUMER);
+ case 'i18n':
+ return renderI18n({
+ files: [
+ { dir: 'en', keys: LOCALE_KEYS },
+ { dir: 'de', keys: LOCALE_KEYS },
+ { dir: 'fr', keys: LOCALE_KEYS.slice(0, -2) },
+ { dir: 'pt-BR', keys: [...LOCALE_KEYS, 'duxt.page.translate'] }
+ ],
+ locales: ['en', 'de', 'fr', 'pt-BR'],
+ defaultLocale: 'en'
+ });
+ case 'cache':
+ return renderCache({ dataDir: `${ROOT}/.data/content`, entries: CACHE });
+ case 'redirects':
+ return renderRedirects(REDIRECTS);
+ default:
+ return renderSources(RESOLVED, `${ROOT}/app/app.config.ts`);
+ }
+}
+
+/** The file a preview is written to, and the name a docs page asks for. */
+export const previewFile = (slug: string) => `${slug || 'sources'}.html`;
+
+/** Every panel the documentation can embed. */
+export const PREVIEW_TABS = PANELS.map((panel) => ({
+ ...panel,
+ file: previewFile(panel.slug)
+}));
+
+/** One panel as a whole document, without the tab row — see `page`. */
+export function previewPage(slug: string): string {
+ return page(slug, previewBody(slug), true);
+}
diff --git a/server/devtools/render/content.ts b/server/devtools/render/content.ts
new file mode 100644
index 00000000..dd30298f
--- /dev/null
+++ b/server/devtools/render/content.ts
@@ -0,0 +1,233 @@
+import type { PageRecord } from '../../../validate-report';
+import { report } from '../../../validate-report';
+import {
+ code,
+ dim,
+ editor,
+ escape,
+ filter,
+ row,
+ stat,
+ stats,
+ table,
+ tag
+} from '../shell';
+
+/**
+ * The three panels that read the content itself, rather than the config.
+ *
+ * All three describe what the RUNNING site holds: the queries in
+ * `../content.ts` ask Content's own database, the same one the pages render
+ * from, and hand the result here. A panel that parsed its own copy of the
+ * Markdown would answer about a site that does not exist.
+ */
+
+export interface Doc {
+ path: string;
+ title?: string;
+ description?: string;
+ icon?: string;
+ id?: string;
+ lastUpdated?: string;
+ contributors?: { name: string }[];
+ body?: unknown;
+}
+
+export interface DocGroup {
+ collection: string;
+ docs: Doc[];
+}
+
+/** Every page, with what the frontmatter and the git history gave it. */
+export function renderPages(groups: DocGroup[], rootDir = ''): string {
+ const total = groups.reduce((sum, group) => sum + group.docs.length, 0);
+ const untitled = groups.reduce(
+ (sum, group) => sum + group.docs.filter((doc) => !doc.title).length,
+ 0
+ );
+ const dated = groups.reduce(
+ (sum, group) => sum + group.docs.filter((doc) => doc.lastUpdated).length,
+ 0
+ );
+
+ const summary = stats([
+ stat(total, 'pages'),
+ stat(groups.length, 'collections'),
+ stat(dated, 'with history', dated ? '' : 'warn'),
+ stat(untitled, 'without a title', untitled ? 'warn' : 'ok')
+ ]);
+
+ // One block per collection, the first opened: a site with five versions has
+ // five near-identical tables, and all five expanded is a page nobody reads.
+ const sections = groups.map(({ collection, docs }, index) => {
+ const rows = docs.map((doc) =>
+ row([
+ code(doc.path),
+ escape(doc.title) || tag('missing', 'warn'),
+ doc.description ? dim('yes') : tag('missing', 'warn'),
+ doc.icon ? code(doc.icon) : dim('—'),
+ doc.lastUpdated
+ ? escape(doc.lastUpdated.slice(0, 10))
+ : dim('no history'),
+ doc.contributors?.length
+ ? escape(String(doc.contributors.length))
+ : dim('—'),
+ editor(doc.id, doc.id?.split('/').slice(1).join('/'), rootDir)
+ ])
+ );
+
+ return `
+ ${escape(collection)} ${docs.length} pages
+ ${docs.length > 12 ? filter(`Filter ${collection}`) : ''}
+ ${table(
+ [
+ 'Path',
+ 'Title',
+ 'Description',
+ 'Icon',
+ 'Last updated',
+ 'Contributors',
+ 'File'
+ ],
+ rows,
+ 'Content produced no pages here. Every URL under this prefix is a 404 — check the source’s `path` and `refs`; the Checks panel reports it as an error.'
+ )}
+ `;
+ });
+
+ return `${summary}${sections.join('')}`;
+}
+
+/**
+ * The build validator, run against the served site.
+ *
+ * Identical checks to `pnpm build`, from `validate-report.ts` — the point is
+ * not a second opinion but a place where the findings stay put. A warning
+ * printed during a build has scrolled away by the time anyone reads it, and
+ * "last updated" on a broken link is exactly when you want the file name.
+ */
+export function renderChecks(
+ sources: DuxtResolvedSource[],
+ pages: PageRecord[],
+ rootDir = ''
+): string {
+ const { errors, warnings, notes } = report(sources, pages);
+
+ const list = (findings: string[], kind: string) =>
+ table(
+ ['', kind === 'error' ? 'Error' : 'Warning'],
+ findings.map((finding) =>
+ row([tag(kind, kind), fileLinked(finding, rootDir)])
+ )
+ );
+
+ const summary = stats([
+ stat(pages.length, 'pages checked'),
+ stat(sources.length, 'collections'),
+ stat(errors.length, 'errors', errors.length ? 'error' : 'ok'),
+ stat(warnings.length, 'warnings', warnings.length ? 'warn' : 'ok')
+ ]);
+
+ // Notes are not findings and must not read like them: a language's coverage
+ // is a state of the site, and putting it in the warnings table would make
+ // every translated site look as though something were wrong with it.
+ //
+ // A note indented by two spaces belongs UNDER the line above it — the report
+ // is one summary per language followed by the files behind the figure, and
+ // HTML collapses that indent to a single space if it is left as text.
+ const coverage = notes.length
+ ? `Translations ${notes
+ .map((note) =>
+ note.startsWith(' ')
+ ? `${fileLinked(note.trim(), rootDir)} `
+ : `${fileLinked(note, rootDir)} `
+ )
+ .join('')} `
+ : '';
+
+ return `${summary}${errors.length ? `Errors ${list(errors, 'error')}` : ''}${
+ warnings.length ? `Warnings ${list(warnings, 'warn')}` : ''
+ }${coverage}`;
+}
+
+/**
+ * Findings name their file in quotes; turn the first one into an editor link.
+ *
+ * The message stays exactly the string the build prints — the two must not
+ * drift — so the link is added by reading it rather than by threading a second
+ * field through the report.
+ */
+function fileLinked(finding: string, rootDir: string): string {
+ const match = /"([^"]+\.md)"/.exec(finding);
+ if (!match) return escape(finding);
+
+ const [quoted, file] = match;
+
+ return (
+ escape(finding.slice(0, match.index)) +
+ editor(file, quoted, rootDir) +
+ escape(finding.slice(match.index + quoted.length))
+ );
+}
+
+export interface IndexedSection {
+ collection: string;
+ id: string;
+ title?: string;
+ titles?: string[];
+ content?: string;
+}
+
+/**
+ * What the search index holds.
+ *
+ * The search had three separate bugs — a filter fighting a filter, results that
+ * were heading fragments, table cells run together without separators — and all
+ * three were invisible because nobody could see the sections the index is built
+ * from. This is that list, with the same query the client runs.
+ */
+export function renderSearch(term: string, sections: IndexedSection[]): string {
+ const form = `
+
+ Search
+ `;
+
+ const needle = term.trim().toLowerCase();
+
+ const matching = needle
+ ? sections.filter(
+ (section) =>
+ section.title?.toLowerCase().includes(needle) ||
+ section.content?.toLowerCase().includes(needle)
+ )
+ : sections;
+
+ // The id is the longest string in the table and the least worth reading in
+ // full, so it is the one that gets clipped — otherwise it takes the width the
+ // indexed text needs, and the column that answers the question is a sliver.
+ const rows = matching.slice(0, 200).map(
+ (section) => `
+ ${code(section.collection)}
+ ${escape(section.id)}
+ ${escape(section.title)}
+ ${escape((section.titles ?? []).join(' › ')) || dim('—')}
+ ${escape((section.content ?? '').slice(0, 180))}${(section.content ?? '').length > 180 ? dim(' …') : ''}
+ `
+ );
+
+ const summary = stats([
+ stat(sections.length, 'sections indexed'),
+ needle
+ ? stat(matching.length, 'matching', matching.length ? '' : 'warn')
+ : undefined,
+ matching.length > 200 ? stat(200, 'shown', 'warn') : undefined
+ ]);
+
+ return `${form}${summary}One row per heading — which is what the dialog groups back into pages. The text column is exactly what FTS5 matches against.
${table(
+ ['Collection', 'Id', 'Heading', 'Under', 'Indexed text'],
+ rows,
+ needle
+ ? `Nothing in the index matches “${escape(term)}”. FTS5 matches terms and prefixes, so a typo returns nothing.`
+ : 'The index is empty.'
+ )}`;
+}
diff --git a/server/devtools/render/sources.ts b/server/devtools/render/sources.ts
new file mode 100644
index 00000000..1658118c
--- /dev/null
+++ b/server/devtools/render/sources.ts
@@ -0,0 +1,367 @@
+import { reservedSegments, sourcesForRoute } from '../../../sources-resolve';
+import { splitLocalePath } from '../../../app/utils/locale-path';
+import { code, dim, escape, row, stat, stats, table, tag } from '../shell';
+
+/**
+ * The three panels that answer "what did my `sources` list turn into".
+ *
+ * They share one idea: the resolver is pure and already tested, so none of this
+ * reimplements it — the panels call the same functions the theme calls and
+ * print what came back. A panel that computed its own answer would be able to
+ * disagree with the site, which is the one thing a debugging view must not do.
+ *
+ * Data in, HTML out. The queries that produce that data live in
+ * `../sources.ts`, and keeping the two apart is what lets the documentation
+ * render these very panels from fixtures instead of shipping screenshots.
+ */
+
+export interface FoundPage {
+ path: string;
+ title?: string;
+ id?: string;
+}
+
+/** The sources table — what a consumer wrote, as the site resolved it. */
+export function renderSources(
+ sources: DuxtResolvedSource[],
+ appConfigFile?: string
+): string {
+ const reserved = reservedSegments(sources);
+
+ const rows = sources.map((source) =>
+ row([
+ code(source.prefix || '/'),
+ code(source.collection),
+ source.repositoryUrl
+ ? `${escape(source.repository)} `
+ : (escape(source.repository) ?? dim('this repository')),
+ code(source.path),
+ source.ref
+ ? `${escape(source.ref)} ${dim(`(${source.refKind})`)}`
+ : dim('checkout'),
+ `${escape(source.version ?? '—')}${source.isDefault ? ` ${tag('default')}` : ''}`,
+ source.status === 'current'
+ ? tag('current', 'ok')
+ : tag(source.status, source.status === 'eol' ? 'error' : 'warn'),
+ (reserved.get(source.collection)
+ ? [...reserved.get(source.collection)!]
+ : []
+ )
+ .map((segment) => code(segment))
+ .join(' ') || dim('—')
+ ])
+ );
+
+ const configured = appConfigFile
+ ? `Configured in ${escape(appConfigFile)}.
`
+ : '';
+
+ const repos = new Set(
+ sources.map((source) => source.repository ?? 'this repository')
+ );
+ const versioned = sources.filter((source) => source.version);
+ const notCurrent = sources.filter((source) => source.status !== 'current');
+
+ const summary = stats([
+ stat(sources.length, sources.length === 1 ? 'collection' : 'collections'),
+ stat(repos.size, repos.size === 1 ? 'repository' : 'repositories'),
+ stat(versioned.length, 'versions'),
+ // Not "the default version": with two repositories there are two defaults,
+ // and the figure worth seeing is how many versions carry a warning the
+ // reader will meet — deprecated, eol, or a branch still moving.
+ stat(notCurrent.length, 'not current', notCurrent.length ? 'warn' : 'ok')
+ ]);
+
+ return `${summary}${table(
+ [
+ 'Prefix',
+ 'Collection',
+ 'Repository',
+ 'Folder',
+ 'Ref',
+ 'Version',
+ 'Status',
+ 'Reserved segments'
+ ],
+ rows,
+ 'No sources resolved at all — `duxt.sources` is empty or unreadable.'
+ )}${configured}`;
+}
+
+export interface PathsData {
+ /** The URL as the reader pasted it, locale segment and all. */
+ input: string;
+ locales: string[];
+ /** Where the locale chain ends — vue-i18n's own fallback. */
+ fallbackLocale?: string | string[];
+ sources: DuxtResolvedSource[];
+ /** The page the claiming collection holds at that path, if it holds one. */
+ found: FoundPage | null;
+ /** Every page path the site serves, by collection. */
+ byCollection: Map;
+}
+
+/**
+ * The path debugger.
+ *
+ * Every version bug this repo had was a disagreement between what a URL looks
+ * like and which source claims it — `/workflows/v0.7.0` 404ing, and
+ * `/workflows/v0.7.0/v0.7.0` being generated. Both were arithmetic on prefixes
+ * that nothing printed. This prints it.
+ */
+export function renderPaths({
+ input,
+ locales,
+ fallbackLocale,
+ sources,
+ found,
+ byCollection
+}: PathsData): string {
+ const form = `
+
+ Resolve
+ `;
+
+ if (!input) {
+ return `${form}Paste a URL as the browser shows it — locale segment included. The steps below are the ones the theme walks on every request.
`;
+ }
+
+ const steps: string[] = [];
+
+ // 1. The locale segment, which is interface and not content — but which is
+ // kept, because it decides WHICH COLLECTION serves the path below.
+ const { locale, path: documentationPath } = splitLocalePath(input, locales);
+ steps.push(
+ documentationPath === input
+ ? `No locale segment. Documentation path is ${code(input)}.`
+ : `Locale segment stripped: ${code(input)} → ${code(documentationPath)}, language ${code(locale!)}. ${dim('The locale translates the interface, never the content tree — but it does pick the collection.')}`
+ );
+
+ // 2. Which source claims it: longest prefix, then the language. Both at once,
+ // because a translation carries the SAME prefix as its original — taking
+ // the prefix alone answers with whichever collection sorted first.
+ const chain = sourcesForRoute(
+ documentationPath,
+ locale,
+ sources,
+ fallbackLocale
+ );
+ const source = chain[0];
+
+ if (!source) {
+ steps.push(
+ `${tag('stops here', 'error')} No source claims this path, and none serves the root. Add a source without a prefix, or check the Sources panel.`
+ );
+
+ return `${form}${steps.map((step) => `${step} `).join('')} `;
+ }
+
+ steps.push(
+ `Longest matching prefix is ${code(source.prefix || '/')} → collection ${code(source.collection)}${source.version ? `, version ${code(source.version)}` : ''}${source.locale ? `, language ${code(source.locale)}` : ''}.`
+ );
+
+ // 2b. Where a page missing from that collection goes next. Nothing else
+ // shows the chain, and the fallback is the half of translation support
+ // that has no visible symptom until a page is silently English.
+ if (chain.length > 1) {
+ steps.push(
+ `Falls back to ${chain
+ .slice(1)
+ .map((other) => code(other.collection))
+ .join(
+ ' → '
+ )}. ${dim('A page is translated whole or not at all, so this is a chain of collections rather than a merge.')}`
+ );
+ }
+
+ // 3. Does that collection hold the page.
+ if (found) {
+ steps.push(
+ `${tag('found', 'ok')} ${escape(found.title ?? documentationPath)} — ${escape(found.id ?? '')}`
+ );
+ } else {
+ steps.push(
+ `${tag('stops here', 'error')} Collection ${code(source.collection)} has no page at ${code(documentationPath)}.`
+ );
+ }
+
+ const body = `${steps.map((step) => `${step} `).join('')} `;
+
+ // 4. Whether it exists elsewhere. A page missing from one version and present
+ // in another is the fallback's case; a page missing everywhere is a typo.
+ const rest = source.prefix
+ ? documentationPath.slice(source.prefix.length)
+ : documentationPath;
+
+ // Only versions of the SAME repository: `/duxt/guide` and `/workflows/guide`
+ // are two projects that happen to share a folder name, and putting them in
+ // one table invents a comparison nobody asked for.
+ const siblings = sources.filter(
+ (other) =>
+ (other.repo ?? other.repository) === (source.repo ?? source.repository)
+ );
+
+ const elsewhere = siblings.map((other) => {
+ const candidate = `${other.prefix}${rest}` || '/';
+ const has = byCollection.get(other.collection)?.includes(candidate);
+
+ return row([
+ code(other.prefix || '/'),
+ escape(other.version ?? '—'),
+ // A translation shares its original's prefix and version, so without the
+ // language the two are one row printed twice.
+ other.locale
+ ? `${escape(other.locale)}${other.isDefaultLocale ? ` ${tag('original')}` : ''}`
+ : dim('—'),
+ code(candidate),
+ has ? tag('present', 'ok') : tag('missing', 'muted')
+ ]);
+ });
+
+ const near = found
+ ? ''
+ : `Nearest paths in ${escape(source.collection)} ${table(
+ ['Path'],
+ nearest(
+ byCollection.get(source.collection) ?? [],
+ documentationPath
+ ).map((path) => row([code(path)]))
+ )}`;
+
+ return `${form}${body}The same page in other sources ${table(['Prefix', 'Version', 'Language', 'Would be', 'State'], elsewhere)}${near}`;
+}
+
+/** The handful of paths that share the longest leading run with the miss. */
+function nearest(paths: string[], target: string): string[] {
+ const score = (path: string) => {
+ let index = 0;
+ while (index < path.length && path[index] === target[index]) index += 1;
+ return index;
+ };
+
+ return [...paths].sort((a, b) => score(b) - score(a)).slice(0, 8);
+}
+
+/**
+ * Which version — and which language — carries which page.
+ *
+ * Nothing else shows this. The fallback's whole job is the gap between two
+ * columns of this table, and until now the only way to find one was to open a
+ * URL and see a 404. A translation falls back exactly the same way, so the
+ * missing `de` page is the same question one axis over.
+ */
+export function renderVersions(
+ sources: DuxtResolvedSource[],
+ byCollection: Map
+): string {
+ // Two groupings, one grid. Versions of different repositories share no paths
+ // and would invent gaps that are not gaps — and a locale is that same case
+ // one level down: `de` and `en` are two collections of the SAME version, so
+ // comparing them as versions drew a matrix of identical columns whose
+ // headings were all empty, because a translation carries neither a version
+ // nor a prefix of its own. Each axis therefore holds the other fixed.
+ const versions = groupBy(
+ sources,
+ (source) => `${repoKey(source)}\u0000${source.locale ?? ''}`
+ );
+ const locales = groupBy(
+ sources,
+ (source) => `${repoKey(source)}\u0000${source.version ?? ''}`
+ );
+
+ const sections = [
+ ...versions.map((group) =>
+ matrix(
+ group,
+ byCollection,
+ (source) =>
+ `${escape(source.version || source.ref || source.prefix || '—')}${source.isDefault ? ' *' : ''}`,
+ group[0]!.locale ? tag(group[0]!.locale, 'muted') : '',
+ '* the version served without a version segment.'
+ )
+ ),
+ ...locales.map((group) =>
+ matrix(
+ group,
+ byCollection,
+ (source) =>
+ `${escape(source.locale ?? '—')}${source.isDefaultLocale ? ' *' : ''}`,
+ group[0]!.version ? tag(group[0]!.version, 'muted') : '',
+ '* the language served from the source path itself, without a folder.'
+ )
+ )
+ ];
+
+ return (
+ sections.join('') ||
+ 'No source is published at more than one version or in more than one language, so there is no matrix to draw. Add a second entry to refs or to locales to see one.
'
+ );
+}
+
+const repoKey = (source: DuxtResolvedSource) =>
+ source.repo ?? source.repository ?? '';
+
+/** The groups worth a table: a single column compares nothing. */
+function groupBy(
+ sources: DuxtResolvedSource[],
+ key: (source: DuxtResolvedSource) => string
+): DuxtResolvedSource[][] {
+ const groups = new Map();
+
+ for (const source of sources) {
+ const list = groups.get(key(source)) ?? [];
+ list.push(source);
+ groups.set(key(source), list);
+ }
+
+ return [...groups.values()].filter((group) => group.length > 1);
+}
+
+/**
+ * One grid: every page of the group down the side, the group's members across.
+ *
+ * The column heading falls through by truthiness rather than `??`, because
+ * `prefix` is an EMPTY STRING for the source served without a segment — `??`
+ * keeps it, and the column then has no name at all. That is what the Versions
+ * tab showed as a row of bare asterisks.
+ */
+function matrix(
+ group: DuxtResolvedSource[],
+ byCollection: Map,
+ label: (source: DuxtResolvedSource) => string,
+ badge: string,
+ footnote: string
+): string {
+ const { repo, repository } = group[0]!;
+ const logical = new Set();
+
+ for (const source of group) {
+ for (const path of byCollection.get(source.collection) ?? []) {
+ logical.add(source.prefix ? path.slice(source.prefix.length) : path);
+ }
+ }
+
+ const rows = [...logical].sort().map((path) => {
+ const cells = group.map((source) => {
+ const has = byCollection
+ .get(source.collection)
+ ?.includes(`${source.prefix}${path}` || '/');
+
+ return has ? '✓ ' : '· ';
+ });
+
+ return `${code(path || '/')} ${cells.join('')} `;
+ });
+
+ // Through `table()` like every other panel. Built by hand it was the one
+ // table in the tab without the scroll container, the border and the column
+ // scopes — visibly a different component for no reason.
+ // The repository's real name before its URL slug: `acme/sdk` says which
+ // project this grid is about, `sdk` only says which segment it took.
+ return `${escape(repository || repo || 'This repository')}${badge ? ` ${badge}` : ''} ${table(
+ ['Page', ...group.map(label)],
+ rows,
+ 'This repository resolved to sources but to no pages at all.',
+ 'matrix'
+ )}${escape(footnote)}
`;
+}
diff --git a/server/devtools/render/system.ts b/server/devtools/render/system.ts
new file mode 100644
index 00000000..b3778590
--- /dev/null
+++ b/server/devtools/render/system.ts
@@ -0,0 +1,352 @@
+import { duxtDefaults, mergeDuxtConfig } from '../../../app/utils/duxt-config';
+import {
+ code,
+ dim,
+ escape,
+ filter,
+ json,
+ row,
+ stat,
+ stats,
+ table,
+ tag
+} from '../shell';
+
+/**
+ * The panels about the machinery around the content: the merged config, the
+ * message catalogues, Content's download cache and the redirects that shipped.
+ *
+ * Each answers a question whose answer exists only as a side effect somewhere —
+ * inside a defu call, across seven JSON files, in a directory nobody opens, in
+ * a route-rule table generated at build time. The reading of those side effects
+ * lives in `../system.ts`; this half turns what was read into a table.
+ */
+
+/* -------------------------------------------------------------------------- */
+/* Config */
+/* -------------------------------------------------------------------------- */
+
+/**
+ * The merged config, with the side of the merge each value came from.
+ *
+ * `mergeDuxtConfig` replaces arrays and merges objects, which is the subtlest
+ * rule in the layer: a consumer's `navigation` is the navigation, but its
+ * `footer.note` leaves `footer.columns` alone. "Why is duxt's entry still in my
+ * navbar" takes one look here instead of a session.
+ */
+export function renderConfig(consumer: Record): string {
+ const merged = mergeDuxtConfig(
+ consumer as unknown as Partial,
+ duxtDefaults
+ ) as unknown as Record;
+
+ const rows: string[] = [];
+ const counted: Origins = { consumer: 0, layer: 0, build: 0 };
+ collect(
+ merged,
+ consumer,
+ duxtDefaults as unknown as Record,
+ '',
+ rows,
+ counted
+ );
+
+ const summary = stats([
+ stat(rows.length, 'keys'),
+ stat(counted.consumer, 'from the site', 'ok'),
+ stat(counted.layer, 'from the layer'),
+ stat(counted.build, 'computed')
+ ]);
+
+ return `${summary}Arrays are replaced whole, objects merge key by key. A row marked ${tag(
+ 'build'
+ )} was computed by modules/config.ts and is written nowhere.
${filter(
+ 'Filter keys'
+ )}${table(['Key', 'From', 'Value'], rows)}`;
+}
+
+const BUILT = new Set(['resolvedSources']);
+
+/**
+ * Tallied while the rows are built, not counted back out of them.
+ *
+ * The first version of this figure filtered the rendered rows for the string
+ * `>consumer<`, which is a search of the config's own VALUES as much as of its
+ * origin badges — a label reading "consumer" would have counted itself.
+ */
+interface Origins {
+ consumer: number;
+ layer: number;
+ build: number;
+}
+
+/**
+ * A value the merge descends into: an object, and neither an array nor null.
+ *
+ * Checked on BOTH sides before recursing, not just for the merged value. The
+ * two sides of a key need not have the same shape — `aside.title` is a
+ * translation key in the layer's defaults and a per-locale object in a site
+ * that translates it — and the earlier version tested only whether the KEY was
+ * present, then walked into the string with `in`, which throws. A panel that
+ * crashes on a translated string is a panel nobody can open on the site that
+ * needs it most.
+ */
+const branches = (value: unknown): value is Record =>
+ Boolean(value) && typeof value === 'object' && !Array.isArray(value);
+
+function collect(
+ merged: Record,
+ consumer: Record | undefined,
+ defaults: Record | undefined,
+ prefix: string,
+ rows: string[],
+ counted: Origins
+): void {
+ for (const [key, value] of Object.entries(merged)) {
+ const path = prefix ? `${prefix}.${key}` : key;
+ const inConsumer = branches(consumer) ? key in consumer : false;
+
+ // Recurse only where both sides are objects: an array is a single value
+ // here, by the same rule the merge itself follows, and a key that is an
+ // object on one side and a string on the other is a REPLACED value — which
+ // is precisely what the reader opened this panel to see.
+ if (
+ branches(value) &&
+ branches(consumer?.[key]) &&
+ branches(defaults?.[key])
+ ) {
+ collect(
+ value as Record,
+ consumer?.[key] as Record,
+ defaults?.[key] as Record,
+ path,
+ rows,
+ counted
+ );
+ continue;
+ }
+
+ const kind = BUILT.has(key) ? 'build' : inConsumer ? 'consumer' : 'layer';
+ counted[kind] += 1;
+
+ const origin =
+ kind === 'build'
+ ? tag('build')
+ : kind === 'consumer'
+ ? tag('consumer', 'ok')
+ : tag('layer', 'muted');
+
+ rows.push(row([code(path), origin, json(value)]));
+ }
+}
+
+/* -------------------------------------------------------------------------- */
+/* i18n */
+/* -------------------------------------------------------------------------- */
+
+export interface LocaleFile {
+ /** The directory under `i18n/locales`, which is the locale's own name. */
+ dir: string;
+ /** Every message key it defines, flattened to `duxt.nav.open`. */
+ keys: string[];
+}
+
+export interface I18nData {
+ files: LocaleFile[];
+ /** The locales the site actually serves, which is a different list. */
+ locales: string[];
+ defaultLocale?: string;
+}
+
+/**
+ * Message keys per locale, against the base language.
+ *
+ * Seven locales and one JSON file per namespace make drift a matter of time,
+ * and i18n answers a missing key by printing the key — so a gap shows up as a
+ * label reading `duxt.nav.open` on a page nobody opened in that language.
+ */
+export function renderI18n({
+ files,
+ locales,
+ defaultLocale
+}: I18nData): string {
+ if (!files.length) return 'No locales found.
';
+
+ const base = files.find((file) => file.dir === 'en') ?? files[0]!;
+ const baseKeys = new Set(base.keys);
+
+ const compared = files.map((file) => {
+ const keys = new Set(file.keys);
+
+ return {
+ dir: file.dir,
+ keys,
+ missing: [...baseKeys].filter((key) => !keys.has(key)),
+ extra: [...keys].filter((key) => !baseKeys.has(key))
+ };
+ });
+
+ const rows = compared.map(({ dir, keys, missing, extra }) =>
+ // A locale file may deliberately hold only its overrides — pt-BR against
+ // pt-PT is the case in this repo — so "missing" is a fact, not a verdict.
+ row([
+ code(dir) + (dir === base.dir ? ` ${tag('base')}` : ''),
+ String(keys.size),
+ missing.length
+ ? `${tag(String(missing.length), 'warn')} ${dim(truncate(missing.join(', '), 120))}`
+ : tag('complete', 'ok'),
+ extra.length
+ ? `${tag(String(extra.length), 'warn')} ${dim(truncate(extra.join(', '), 120))}`
+ : dim('—')
+ ])
+ );
+
+ const served = locales.length
+ ? `Served locales: ${locales.map((locale) => code(locale)).join(' ')}${defaultLocale ? `, default ${code(defaultLocale)}` : ''}.
`
+ : '';
+
+ const gaps = compared.filter(
+ (entry) => entry.missing.length || entry.extra.length
+ ).length;
+
+ const summary = stats([
+ stat(files.length, 'locale files'),
+ stat(baseKeys.size, `keys in ${base.dir}`),
+ stat(gaps, 'with a gap', gaps ? 'warn' : 'ok')
+ ]);
+
+ return `${summary}${served}${table(
+ ['Directory', 'Keys', 'Missing vs base', 'Not in base'],
+ rows
+ )}`;
+}
+
+const truncate = (value: string, length: number) =>
+ value.length > length ? `${value.slice(0, length)}…` : value;
+
+/* -------------------------------------------------------------------------- */
+/* Cache */
+/* -------------------------------------------------------------------------- */
+
+export interface CacheEntry {
+ name: string;
+ /** Bytes on disk, summed over the directory. */
+ size: number;
+ modified: Date;
+}
+
+export interface CacheData {
+ dataDir: string;
+ /** `undefined` when the directory does not exist at all. */
+ entries?: CacheEntry[];
+ /** What the last drop did, if this render follows one. */
+ message?: string;
+}
+
+/**
+ * Content's download cache, and a way to drop one entry.
+ *
+ * A stale directory here cost real time once: a tag was fetched as a branch,
+ * failed, and the half-written `github.com---[` folder kept
+ * answering for the fixed config until it was deleted by hand. Nothing in the
+ * build mentions this directory, so nothing suggests the fix.
+ */
+export function renderCache({ dataDir, entries, message }: CacheData): string {
+ if (!entries) {
+ return ']Nothing downloaded yet — .data/content does not exist.
';
+ }
+
+ const rows = entries.map((entry) =>
+ row([
+ code(entry.name),
+ megabytes(entry.size),
+ entry.modified.toISOString().replace('T', ' ').slice(0, 16),
+ `
+
+ Drop
+ `
+ ])
+ );
+
+ const note = message ? `${escape(message)}
` : '';
+
+ const summary = stats([
+ stat(entries.length, 'cached sources'),
+ stat(
+ megabytes(entries.reduce((sum, entry) => sum + entry.size, 0)),
+ 'on disk'
+ )
+ ]);
+
+ return `${note}${summary}${escape(dataDir)}
${table(
+ ['Entry', 'Size', 'Modified', ''],
+ rows,
+ 'The cache directory exists but holds no source. Nothing remote has been downloaded yet.'
+ )}Dropping an entry makes the next build download it again. Content keys a directory by repository and ref, so an entry for a ref you have renamed is never reused and never cleaned up either.
`;
+}
+
+const megabytes = (bytes: number) =>
+ bytes > 1024 * 1024
+ ? `${(bytes / 1024 / 1024).toFixed(1)} MB`
+ : `${Math.max(1, Math.round(bytes / 1024))} kB`;
+
+/* -------------------------------------------------------------------------- */
+/* Redirects */
+/* -------------------------------------------------------------------------- */
+
+export interface RedirectRule {
+ from: string;
+ to: string;
+ status: number | string;
+ /** True for a rule `redirectFrom` generated, false for another module's. */
+ mine: boolean;
+}
+
+/**
+ * The route rules that ship, and which of them are the layer's.
+ *
+ * Read rather than recomputed on purpose: a rule that was generated and a rule
+ * that shipped are different claims, and only the second one redirects
+ * anybody. Reading them back means reading EVERY module's — `@nuxtjs/sitemap`
+ * puts `/sitemap.xml` in the table on any multi-locale site — so the rules the
+ * layer wrote are marked as such, from the list `modules/redirects.ts` records
+ * beside them. Nothing in a route rule says who wrote it.
+ */
+export function renderRedirects(rules?: RedirectRule[]): string {
+ if (!rules) {
+ return 'Nitro exposed no route rules to read.
';
+ }
+
+ // The layer's own first: they are what this tab is opened for, and on a site
+ // with one moved page they would otherwise sit under a module's boilerplate.
+ const sorted = [...rules].sort(
+ (a, b) => Number(b.mine) - Number(a.mine) || a.from.localeCompare(b.from)
+ );
+
+ const rows = sorted.map((rule) =>
+ row([
+ code(rule.from),
+ code(rule.to),
+ typeof rule.status === 'string'
+ ? dim(rule.status)
+ : escape(String(rule.status)),
+ rule.mine ? tag('redirectFrom', 'ok') : tag('another module', 'muted')
+ ])
+ );
+
+ const mine = sorted.filter((rule) => rule.mine).length;
+
+ return `${stats([
+ stat(rows.length, 'rules'),
+ stat(mine, 'from redirectFrom', mine ? 'ok' : ''),
+ stat(rows.length - mine, 'from elsewhere')
+ ])}${table(
+ ['From', 'To', 'Status', 'Written by'],
+ rows,
+ 'The server shipped no redirect at all — not the layer’s, not a module’s.'
+ )}${
+ mine
+ ? 'One rule per prefix a page is served under — the repository segment, the version segment and every locale — because an old URL was bookmarked under exactly one of them and nothing says which.'
+ : 'No page carries redirectFrom, so the layer generated nothing; every rule above belongs to another module.'
+ }
`;
+}
diff --git a/server/devtools/shell.ts b/server/devtools/shell.ts
index d987bbaf..1a48bb0c 100644
--- a/server/devtools/shell.ts
+++ b/server/devtools/shell.ts
@@ -1,6 +1,3 @@
-// @ts-expect-error virtual module, generated in modules/devtools.ts
-import { devtools } from '#duxt-devtools';
-
/**
* The frame every devtools panel is drawn in.
*
@@ -23,10 +20,10 @@ export interface DevtoolsContext {
appConfigFile?: string;
locales: string[];
defaultLocale?: string;
+ /** vue-i18n's own fallback, so the path debugger falls back where pages do. */
+ fallbackLocale?: string | string[];
}
-export const context = devtools as DevtoolsContext;
-
export const escape = (value: unknown) =>
String(value ?? '').replace(
/[&<>"]/g,
@@ -42,8 +39,10 @@ export const escape = (value: unknown) =>
export const code = (value: unknown) => `${escape(value)}`;
export const dim = (value: unknown) =>
`${escape(value)} `;
+// The kind is appended rather than interpolated: `tag('default')` is the
+// commonest call, and a template with a hole in it emits `class="tag "` for it.
export const tag = (value: unknown, kind = '') =>
- `${escape(value)} `;
+ `${escape(value)} `;
/**
* A link that opens the file in the editor.
@@ -51,11 +50,19 @@ export const tag = (value: unknown, kind = '') =>
* Nuxt Devtools serves this endpoint on the site's own origin, and the panel is
* an iframe on that origin, so a plain anchor is the whole integration. It only
* works while devtools is running, which is exactly when this page is reachable.
+ *
+ * The root directory is passed in rather than read from the context: this file
+ * holds the renderers, and a renderer that reaches for a build-time global
+ * cannot be handed fixture data — which is what the documentation previews are.
*/
-export function editor(file: string | undefined, label?: string): string {
+export function editor(
+ file: string | undefined,
+ label?: string,
+ rootDir = ''
+): string {
if (!file) return dim('—');
- const absolute = file.startsWith('/') ? file : `${context.rootDir}/${file}`;
+ const absolute = file.startsWith('/') ? file : `${rootDir}/${file}`;
return `${escape(label ?? file)} `;
}
@@ -97,9 +104,79 @@ export function stats(items: (string | undefined)[]): string {
export const stat = (value: unknown, label: string, kind = '') =>
`${escape(value)} ${escape(label)}
`;
-/** A text box that hides every row of a table not matching what is typed. */
+/**
+ * A text box that hides every row of a table not matching what is typed.
+ *
+ * Wrapped in a bar rather than left loose: between an open `summary` and its
+ * table, a bare input broke the one box a `details` is meant to be — the
+ * heading kept its squared-off bottom edge and the input floated in the gap.
+ * The bar is that middle band, and it carries the live match count, because a
+ * filter that hides rows without saying how many it kept makes the reader
+ * count them.
+ */
export const filter = (placeholder: string) =>
- ` `;
+ `
`;
+
+/**
+ * A value as JSON: short ones inline, structured ones as a disclosure.
+ *
+ * The Config panel's whole subject is what a key BECAME, and half those keys
+ * hold a translated object or a list of links. Rendered as one truncated line
+ * of `JSON.stringify` they were unreadable in the only case that matters —
+ * every locale of a label sat past the cut, so the row proved a value exists
+ * and nothing else. Short values still print inline, because a disclosure
+ * around `["pnpm","npm"]` hides nothing and costs a click.
+ *
+ * Kept collapsed, and kept in the DOM: the filter box matches on row text, so
+ * a folded object is still searchable by any string inside it.
+ */
+export function json(value: unknown, inline = 72): string {
+ if (!value || typeof value !== 'object') return code(String(value));
+
+ const flat = JSON.stringify(value);
+ const label = Array.isArray(value)
+ ? `${value.length} entries`
+ : `${Object.keys(value).length} keys`;
+
+ if (flat.length <= inline) return `${dim(label)} ${code(flat)}`;
+
+ return `${dim(label)} ${escape(truncate(flat, 96))} ${highlight(JSON.stringify(value, null, 2))} `;
+}
+
+const truncate = (value: string, length: number) =>
+ value.length > length ? `${value.slice(0, length)}…` : value;
+
+/** String, key, number, keyword — the four colours JSON has to say anything with. */
+const TOKEN =
+ /("(?:\\.|[^"\\])*")(\s*:)?|\b(?:true|false|null)\b|-?\d+(?:\.\d+)?(?:e[+-]?\d+)?/gi;
+
+/**
+ * Escaped as it is tokenised, never after.
+ *
+ * Highlighting the escaped string instead would mean matching `"` as a
+ * string delimiter — one entity in a value and the colours slide off by a
+ * character.
+ */
+function highlight(source: string): string {
+ let out = '';
+ let last = 0;
+
+ for (const match of source.matchAll(TOKEN)) {
+ const text = match[0];
+ out += escape(source.slice(last, match.index));
+ last = match.index + text.length;
+
+ if (match[1] && match[2]) {
+ out += `${escape(match[1])} ${escape(match[2])}`;
+ continue;
+ }
+
+ const kind = match[1] ? 'str' : /^[a-z]/.test(text) ? 'word' : 'num';
+ out += `${escape(text)} `;
+ }
+
+ return out + escape(source.slice(last));
+}
export const row = (cells: string[]) =>
`${cells.map((cell) => `${cell} `).join('')} `;
@@ -164,12 +241,69 @@ export const PANELS: Panel[] = [
{
slug: 'redirects',
title: 'Redirects',
- hint: 'The route rules `redirectFrom` generated, as they shipped.'
+ hint: 'Every redirect the server ships, with the rules `redirectFrom` generated marked as the layer\u2019s own.'
}
];
const href = (slug: string) => `/_duxt/devtools${slug ? `/${slug}` : ''}`;
+/**
+ * How a preview differs from the panel: where its tabs point, and that nothing
+ * else in it navigates.
+ *
+ * A preview is the same document rendered from fixtures and saved as a file, so
+ * its tab row has to link to sibling files rather than to a route that exists
+ * only in a dev server. Everything else in the page — an editor link, the drop
+ * button, the search form — would leave the page or POST somewhere, and a
+ * reader clicking one in the documentation is a reader chasing a broken link.
+ * They stay visible, because they are part of what the panel IS; they are made
+ * inert instead of removed.
+ */
+
+const INERT = `
+ // EVERY link and button, with no exception for the tab row — a preview has
+ // no tab row. Embedded in a documentation page, a reader who clicked one
+ // would land on another panel inside the frame while the prose around it went
+ // on explaining this one.
+ document.addEventListener('click', (event) => {
+ if (event.target.closest('a, button')) event.preventDefault();
+ });
+ document.addEventListener('submit', (event) => event.preventDefault());
+
+ // The page that framed this one owns the theme and the height: it knows
+ // which mode the reader picked, and only this document knows how tall it
+ // turned out. Two messages, one each way.
+ const theme = new URLSearchParams(location.search).get('theme');
+ if (theme) document.documentElement.dataset.theme = theme;
+
+ // Only from the frame that embedded this one, and only from its own origin.
+ // Both halves are needed: the origin alone would still accept a message from
+ // a sibling frame on the same site, and \`event.source\` alone would accept one
+ // from a parent on another origin. This document is served from \`public/\`, so
+ // the page framing it is always same-origin — a message from anywhere else is
+ // not a theme change, it is somebody else talking.
+ addEventListener('message', (event) => {
+ if (event.origin !== location.origin || event.source !== parent) return;
+
+ if (event.data && typeof event.data.duxtTheme === 'string') {
+ document.documentElement.dataset.theme = event.data.duxtTheme;
+ }
+ });
+
+ // Addressed rather than broadcast, for the same reason. \`'*'\` hands the
+ // height to whatever ends up framing this page, which on a consumer's site is
+ // not necessarily the page that asked for it.
+ const measure = () => parent.postMessage(
+ { duxtHeight: document.documentElement.scrollHeight },
+ location.origin
+ );
+
+ addEventListener('load', measure);
+ addEventListener('resize', measure);
+ document.addEventListener('toggle', measure, true);
+ document.addEventListener('input', () => setTimeout(measure, 0));
+`;
+
/**
* The palette, borrowed rather than invented.
*
@@ -179,6 +313,29 @@ const href = (slug: string) => `/_duxt/devtools${slug ? `/${slug}` : ''}`;
* Nuxt's own green, which is what makes the tab read as part of devtools
* instead of as a document someone embedded in it.
*/
+/**
+ * The dark half of the palette, written once and applied twice.
+ *
+ * Inside devtools `prefers-color-scheme` is the only signal a panel gets. A
+ * preview embedded in the documentation is a different case: it sits on a page
+ * whose theme is a class the reader toggles, so the parent tells it which one
+ * to wear, and an explicit choice has to beat the media query in BOTH
+ * directions — hence the `:not([data-theme='light'])` guard on the query.
+ */
+const DARK = `
+ --bg: #0f1115;
+ --fg: #e8e8ea;
+ --muted: #9099a8;
+ --faint: #5b6472;
+ --line: #23262d;
+ --raised: #171a20;
+ --accent: #00dc82;
+ --accent-soft: #10291f;
+ --warn: #f59e0b;
+ --error: #f87171;
+ --ok: #4ade80;
+`;
+
const STYLE = `
:root {
color-scheme: light dark;
@@ -196,20 +353,9 @@ const STYLE = `
--radius: 7px;
}
@media (prefers-color-scheme: dark) {
- :root {
- --bg: #0f1115;
- --fg: #e8e8ea;
- --muted: #9099a8;
- --faint: #5b6472;
- --line: #23262d;
- --raised: #171a20;
- --accent: #00dc82;
- --accent-soft: #10291f;
- --warn: #f59e0b;
- --error: #f87171;
- --ok: #4ade80;
- }
+ :root:not([data-theme='light']) { ${DARK} }
}
+ :root[data-theme='dark'] { ${DARK} }
* { box-sizing: border-box; }
body {
@@ -310,7 +456,17 @@ const STYLE = `
border: 1px solid var(--line); border-radius: var(--radius);
font: inherit; background: var(--bg); color: inherit;
}
- input.filter { margin-bottom: 12px; }
+ .filterbar { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
+ .filterbar .count {
+ color: var(--muted); font-size: 11.5px;
+ font-variant-numeric: tabular-nums; white-space: nowrap;
+ }
+ input.filter {
+ flex: 0 1 340px; min-width: 0; margin: 0; padding-left: 31px;
+ background: var(--bg) no-repeat 10px 50% / 14px url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%239099a8' stroke-width='2' stroke-linecap='round'%3E%3Ccircle cx='11' cy='11' r='7'/%3E%3Cpath d='m20 20-3.6-3.6'/%3E%3C/svg%3E");
+ }
+ input.filter:focus { border-color: var(--accent); }
+ input.filter::-webkit-search-cancel-button { cursor: pointer; }
input::placeholder { color: var(--faint); }
button {
padding: 6px 14px; border: 1px solid var(--line); border-radius: var(--radius);
@@ -349,6 +505,34 @@ const STYLE = `
details > summary:hover { border-color: var(--accent); }
details[open] > summary { border-radius: var(--radius) var(--radius) 0 0; margin-bottom: -1px; }
details[open] > summary + .scroll { border-radius: 0 0 var(--radius) var(--radius); }
+ details[open] > summary + .filterbar {
+ margin: 0; padding: 8px 11px;
+ background: var(--raised); border: 1px solid var(--line); border-top: 0;
+ }
+ details[open] > .filterbar + .scroll {
+ border-radius: 0 0 var(--radius) var(--radius); margin-top: -1px;
+ }
+
+ /* A disclosure inside a table cell is not the panel-sized one above: no bar,
+ no border, no weight — just a triangle in front of the preview. */
+ details.json { margin: 0; }
+ details.json > summary {
+ padding: 0; background: none; border: 0; border-radius: 0; font-weight: inherit;
+ list-style-position: outside; margin-left: 12px;
+ }
+ details.json > summary::marker { color: var(--faint); }
+ details.json > summary:hover { border: 0; }
+ details.json[open] > summary { margin-bottom: 6px; border-radius: 0; }
+ details.json[open] > summary .peek { display: none; }
+ pre.json {
+ margin: 0 0 2px; padding: 9px 11px; max-height: 320px; overflow: auto;
+ background: var(--bg); border: 1px solid var(--line); border-radius: var(--radius);
+ font: 11.5px/1.6 ui-monospace, 'SF Mono', Menlo, monospace;
+ white-space: pre; tab-size: 2;
+ }
+ .j-key { color: var(--accent); }
+ .j-num { color: var(--warn); }
+ .j-word { color: var(--muted); font-style: italic; }
.matrix th:first-child, .matrix td:first-child {
position: sticky; left: 0; background: var(--bg);
@@ -375,51 +559,53 @@ const SCRIPT = `
const term = input.value.trim().toLowerCase();
const scope = input.closest('details') ?? document;
+ const rows = scope.querySelectorAll('tbody tr');
+ let shown = 0;
- for (const row of scope.querySelectorAll('tbody tr')) {
+ for (const row of rows) {
row.hidden = Boolean(term) && !row.textContent.toLowerCase().includes(term);
+ if (!row.hidden) shown += 1;
+ }
+
+ const count = input.parentElement.querySelector('[data-filter-count]');
+ if (count) {
+ count.textContent = term
+ ? shown ? shown + ' of ' + rows.length : 'no matches'
+ : '';
}
});
`;
/** The full document: tab row, panel heading, body. */
-export function page(active: string, body: string): string {
+export function page(active: string, body: string, preview = false): string {
const panel = PANELS.find((entry) => entry.slug === active) ?? PANELS[0]!;
- const tabs = PANELS.map(
- (entry) =>
- `${escape(entry.title)} `
- ).join('');
+ /**
+ * NO TAB ROW IN A PREVIEW.
+ *
+ * The tab row is how the devtools panel is navigated, and it is exactly wrong
+ * inside a documentation page: the frame sits under prose describing ONE
+ * panel, and a reader who switched tabs in it would be reading about Sources
+ * while looking at Paths. The row is also the panel's own chrome — the
+ * documentation already carries a heading, a description and a sidebar entry
+ * saying which panel this is.
+ */
+ const tabs = preview
+ ? ''
+ : ``;
return `
duxt — ${escape(panel.title)}
-
+ ${tabs}
${escape(panel.title)}
${panel.hint.replace(/`([^`]+)`/g, (_, inner: string) => code(inner))}
${body}
-
+
`;
}
-
-/** The collections the site is serving, straight out of the running app config. */
-export function resolvedSources(): DuxtResolvedSource[] {
- const { duxt } = useAppConfig() as { duxt?: Partial };
-
- return (
- duxt?.resolvedSources ?? [
- {
- collection: 'docs',
- prefix: '',
- path: 'docs',
- isDefault: true,
- status: 'current',
- // A source read off disk is a full checkout, so its history is
- // readable without asking.
- history: true
- }
- ]
- );
-}
diff --git a/server/devtools/sources.ts b/server/devtools/sources.ts
index d69dcedb..44b0d530 100644
--- a/server/devtools/sources.ts
+++ b/server/devtools/sources.ts
@@ -1,36 +1,20 @@
import type { H3Event } from 'h3';
import { queryCollection } from '@nuxt/content/nitro';
-import { reservedSegments } from '../../sources-resolve';
-import { stripLocalePrefix } from '../../app/utils/locale-path';
-import { sourceForPath } from '../../app/utils/version-paths';
-import {
- code,
- context,
- dim,
- escape,
- resolvedSources,
- row,
- stat,
- stats,
- table,
- tag
-} from './shell';
+import { splitLocalePath } from '../../app/utils/locale-path';
+import { sourcesForRoute } from '../../sources-resolve';
+import { context, resolvedSources } from './context';
+import type { FoundPage } from './render/sources';
+import { renderPaths, renderSources, renderVersions } from './render/sources';
/**
- * The three panels that answer "what did my `sources` list turn into".
+ * The queries behind the three source panels.
*
- * They share one idea: the resolver is pure and already tested, so none of this
- * reimplements it — the panels call the same functions the theme calls and
- * print what came back. A panel that computed its own answer would be able to
+ * The resolver is pure and already tested, so none of this reimplements it: the
+ * panels call the same functions the theme calls, and hand what came back to
+ * `render/sources.ts`. A panel that computed its own answer would be able to
* disagree with the site, which is the one thing a debugging view must not do.
*/
-interface FoundPage {
- path: string;
- title?: string;
- id?: string;
-}
-
/** Content stores a page under its full path, prefix included. */
const collectionsOf = () =>
resolvedSources().map((source) => source.collection) as Parameters<
@@ -68,262 +52,71 @@ async function pathsByCollection(
/** The sources table — what a consumer wrote, as the site resolved it. */
export function sourcesPanel(): string {
- const sources = resolvedSources();
- const reserved = reservedSegments(sources);
-
- const rows = sources.map((source) =>
- row([
- code(source.prefix || '/'),
- code(source.collection),
- source.repositoryUrl
- ? `${escape(source.repository)} `
- : (escape(source.repository) ?? dim('this repository')),
- code(source.path),
- source.ref
- ? `${escape(source.ref)} ${dim(`(${source.refKind})`)}`
- : dim('checkout'),
- `${escape(source.version ?? '—')}${source.isDefault ? ` ${tag('default')}` : ''}`,
- source.status === 'current'
- ? tag('current', 'ok')
- : tag(source.status, source.status === 'eol' ? 'error' : 'warn'),
- (reserved.get(source.collection)
- ? [...reserved.get(source.collection)!]
- : []
- )
- .map((segment) => code(segment))
- .join(' ') || dim('—')
- ])
- );
-
- const configured = context.appConfigFile
- ? `Configured in ${escape(context.appConfigFile)}.
`
- : '';
-
- const repos = new Set(
- sources.map((source) => source.repository ?? 'this repository')
- );
- const versioned = sources.filter((source) => source.version);
- const notCurrent = sources.filter((source) => source.status !== 'current');
-
- const summary = stats([
- stat(sources.length, sources.length === 1 ? 'collection' : 'collections'),
- stat(repos.size, repos.size === 1 ? 'repository' : 'repositories'),
- stat(versioned.length, 'versions'),
- // Not "the default version": with two repositories there are two defaults,
- // and the figure worth seeing is how many versions carry a warning the
- // reader will meet — deprecated, eol, or a branch still moving.
- stat(notCurrent.length, 'not current', notCurrent.length ? 'warn' : 'ok')
- ]);
-
- return `${summary}${table(
- [
- 'Prefix',
- 'Collection',
- 'Repository',
- 'Folder',
- 'Ref',
- 'Version',
- 'Status',
- 'Reserved segments'
- ],
- rows,
- 'No sources resolved at all — `duxt.sources` is empty or unreadable.'
- )}${configured}`;
+ return renderSources(resolvedSources(), context.appConfigFile);
}
-/**
- * The path debugger.
- *
- * Every version bug this repo had was a disagreement between what a URL looks
- * like and which source claims it — `/workflows/v0.7.0` 404ing, and
- * `/workflows/v0.7.0/v0.7.0` being generated. Both were arithmetic on prefixes
- * that nothing printed. This prints it.
- */
+/** The path debugger: which source claims a URL, and what it finds there. */
export async function pathsPanel(
event: H3Event,
input: string
): Promise {
- const form = `
-
- Resolve
- `;
-
- if (!input) {
- return `${form}Paste a URL as the browser shows it — locale segment included. The steps below are the ones the theme walks on every request.
`;
- }
-
- const steps: string[] = [];
-
- // 1. The locale segment, which is interface and not content.
- const documentationPath = stripLocalePrefix(input, context.locales);
- steps.push(
- documentationPath === input
- ? `No locale segment. Documentation path is ${code(input)}.`
- : `Locale segment stripped: ${code(input)} → ${code(documentationPath)}. ${dim('The locale translates the interface, never the content tree.')}`
- );
-
- // 2. Which source claims it, by longest prefix.
const sources = resolvedSources();
- const source = sourceForPath(documentationPath, sources);
-
- if (!source) {
- steps.push(
- `${tag('stops here', 'error')} No source claims this path, and none serves the root. Add a source without a prefix, or check the Sources panel.`
- );
+ const locales = context.locales;
- return `${form}${steps.map((step) => `${step} `).join('')} `;
+ if (!input) {
+ return renderPaths({
+ input,
+ locales,
+ fallbackLocale: context.fallbackLocale,
+ sources,
+ found: null,
+ byCollection: new Map()
+ });
}
- steps.push(
- `Longest matching prefix is ${code(source.prefix || '/')} → collection ${code(source.collection)}${source.version ? `, version ${code(source.version)}` : ''}.`
- );
+ // The same two pure calls the renderer walks the reader through, repeated
+ // here only to know WHICH collection to query. Cheap, and it keeps the
+ // renderer free of the database.
+ //
+ // The locale is carried into the choice rather than thrown away with the
+ // segment: a translation lives under the SAME prefix as its original, so a
+ // lookup given only the stripped path answers with whichever collection
+ // sorted first — and told the reader about `docs` when they pasted a `/de/`
+ // URL.
+ const { locale, path: documentationPath } = splitLocalePath(input, locales);
+ const source = sourcesForRoute(
+ documentationPath,
+ locale,
+ sources,
+ context.fallbackLocale
+ )[0];
- // 3. Does that collection hold the page.
let found: FoundPage | null = null;
- try {
- found = (await queryCollection(
- event,
- source.collection as Parameters[1]
- )
- .path(documentationPath)
- .first()) as unknown as FoundPage | null;
- } catch {
- found = null;
- }
-
- if (found) {
- steps.push(
- `${tag('found', 'ok')} ${escape(found.title ?? documentationPath)} — ${escape(found.id ?? '')}`
- );
- } else {
- steps.push(
- `${tag('stops here', 'error')} Collection ${code(source.collection)} has no page at ${code(documentationPath)}.`
- );
+ if (source) {
+ try {
+ found = (await queryCollection(
+ event,
+ source.collection as Parameters[1]
+ )
+ .path(documentationPath)
+ .first()) as unknown as FoundPage | null;
+ } catch {
+ found = null;
+ }
}
- const body = `${steps.map((step) => `${step} `).join('')} `;
-
- // 4. Whether it exists elsewhere. A page missing from one version and present
- // in another is the fallback's case; a page missing everywhere is a typo.
- const rest = source.prefix
- ? documentationPath.slice(source.prefix.length)
- : documentationPath;
-
- const byCollection = await pathsByCollection(event);
-
- // Only versions of the SAME repository: `/duxt/guide` and `/workflows/guide`
- // are two projects that happen to share a folder name, and putting them in
- // one table invents a comparison nobody asked for.
- const siblings = sources.filter(
- (other) =>
- (other.repo ?? other.repository) === (source.repo ?? source.repository)
- );
-
- const elsewhere = siblings.map((other) => {
- const candidate = `${other.prefix}${rest}` || '/';
- const has = byCollection.get(other.collection)?.includes(candidate);
-
- return row([
- code(other.prefix || '/'),
- escape(other.version ?? '—'),
- code(candidate),
- has ? tag('present', 'ok') : tag('missing', 'muted')
- ]);
+ return renderPaths({
+ input,
+ locales,
+ fallbackLocale: context.fallbackLocale,
+ sources,
+ found,
+ byCollection: await pathsByCollection(event)
});
-
- const near = found
- ? ''
- : `Nearest paths in ${escape(source.collection)} ${table(
- ['Path'],
- nearest(
- byCollection.get(source.collection) ?? [],
- documentationPath
- ).map((path) => row([code(path)]))
- )}`;
-
- return `${form}${body}The same page in other sources ${table(['Prefix', 'Version', 'Would be', 'State'], elsewhere)}${near}`;
}
-/** The handful of paths that share the longest leading run with the miss. */
-function nearest(paths: string[], target: string): string[] {
- const score = (path: string) => {
- let index = 0;
- while (index < path.length && path[index] === target[index]) index += 1;
- return index;
- };
-
- return [...paths].sort((a, b) => score(b) - score(a)).slice(0, 8);
-}
-
-/**
- * Which version carries which page.
- *
- * Nothing else shows this. The fallback's whole job is the gap between two
- * columns of this table, and until now the only way to find one was to open a
- * URL and see a 404.
- */
+/** Which version — and which language — carries which page. */
export async function versionsPanel(event: H3Event): Promise {
- const sources = resolvedSources();
- const byCollection = await pathsByCollection(event);
-
- // One table per repository: versions of different repositories share no
- // paths, and putting them in one grid would invent gaps that are not gaps.
- const repos = new Map();
-
- for (const source of sources) {
- const key = source.repo ?? source.repository ?? '';
- const list = repos.get(key) ?? [];
- list.push(source);
- repos.set(key, list);
- }
-
- const sections = [...repos.entries()]
- .filter(([, group]) => group.length > 1)
- .map(([repo, group]) => {
- const logical = new Set();
-
- for (const source of group) {
- for (const path of byCollection.get(source.collection) ?? []) {
- logical.add(source.prefix ? path.slice(source.prefix.length) : path);
- }
- }
-
- const rows = [...logical].sort().map((path) => {
- const cells = group.map((source) => {
- const has = byCollection
- .get(source.collection)
- ?.includes(`${source.prefix}${path}` || '/');
-
- return has
- ? '✓ '
- : '· ';
- });
-
- return `${code(path || '/')} ${cells.join('')} `;
- });
-
- // Through `table()` like every other panel. Built by hand it was the one
- // table in the tab without the scroll container, the border and the
- // column scopes — visibly a different component for no reason.
- const headers = [
- 'Page',
- ...group.map(
- (source) =>
- `${escape(source.version ?? source.prefix ?? '—')}${source.isDefault ? ' *' : ''}`
- )
- ];
-
- return `${escape(repo || 'This repository')} ${table(
- headers,
- rows,
- 'This repository resolved to versions but to no pages at all.',
- 'matrix'
- )}* the version served without a version segment.
`;
- });
-
- return (
- sections.join('') ||
- 'No source is published at more than one version, so there is no matrix to draw. Add a second entry to refs to see one.
'
- );
+ return renderVersions(resolvedSources(), await pathsByCollection(event));
}
diff --git a/server/devtools/system.ts b/server/devtools/system.ts
index 391eff39..ac891676 100644
--- a/server/devtools/system.ts
+++ b/server/devtools/system.ts
@@ -1,161 +1,40 @@
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { join } from 'node:path';
-import { duxtDefaults, mergeDuxtConfig } from '../../app/utils/duxt-config';
+import { context } from './context';
+import type { CacheEntry, LocaleFile, RedirectRule } from './render/system';
import {
- code,
- context,
- dim,
- escape,
- filter,
- row,
- stat,
- stats,
- table,
- tag
-} from './shell';
+ renderCache,
+ renderConfig,
+ renderI18n,
+ renderRedirects
+} from './render/system';
/**
- * The panels about the machinery around the content: the merged config, the
- * message catalogues, Content's download cache and the redirects that shipped.
+ * What the panels about the machinery read: the merged config, the message
+ * catalogues, Content's download cache and the redirects that shipped.
*
* Each answers a question whose answer exists only as a side effect somewhere —
* inside a defu call, across seven JSON files, in a directory nobody opens, in
- * a route-rule table generated at build time.
+ * a route-rule table generated at build time. This half does the reading;
+ * `render/system.ts` turns it into tables.
*/
/* -------------------------------------------------------------------------- */
/* Config */
/* -------------------------------------------------------------------------- */
-/**
- * The merged config, with the side of the merge each value came from.
- *
- * `mergeDuxtConfig` replaces arrays and merges objects, which is the subtlest
- * rule in the layer: a consumer's `navigation` is the navigation, but its
- * `footer.note` leaves `footer.columns` alone. "Why is duxt's entry still in my
- * navbar" takes one look here instead of a session.
- */
+/** The merged config, with the side of the merge each value came from. */
export function configPanel(): string {
const { duxt } = useAppConfig() as { duxt?: Partial };
- const consumer = (duxt ?? {}) as unknown as Record;
- const merged = mergeDuxtConfig(consumer, duxtDefaults) as unknown as Record<
- string,
- unknown
- >;
-
- const rows: string[] = [];
- const counted: Origins = { consumer: 0, layer: 0, build: 0 };
- collect(
- merged,
- consumer,
- duxtDefaults as unknown as Record,
- '',
- rows,
- counted
- );
-
- const summary = stats([
- stat(rows.length, 'keys'),
- stat(counted.consumer, 'from the site', 'ok'),
- stat(counted.layer, 'from the layer'),
- stat(counted.build, 'computed')
- ]);
-
- return `${summary}Arrays are replaced whole, objects merge key by key. A row marked ${tag(
- 'build'
- )} was computed by modules/config.ts and is written nowhere.
${filter(
- 'Filter keys'
- )}${table(['Key', 'From', 'Value'], rows)}`;
-}
-
-const BUILT = new Set(['resolvedSources']);
-
-/**
- * Tallied while the rows are built, not counted back out of them.
- *
- * The first version of this figure filtered the rendered rows for the string
- * `>consumer<`, which is a search of the config's own VALUES as much as of its
- * origin badges — a label reading "consumer" would have counted itself.
- */
-interface Origins {
- consumer: number;
- layer: number;
- build: number;
-}
-
-function collect(
- merged: Record,
- consumer: Record | undefined,
- defaults: Record | undefined,
- prefix: string,
- rows: string[],
- counted: Origins
-): void {
- for (const [key, value] of Object.entries(merged)) {
- const path = prefix ? `${prefix}.${key}` : key;
- const inConsumer = consumer ? key in consumer : false;
- const inDefaults = defaults ? key in defaults : false;
-
- // Recurse only through plain objects: an array is a single value here, by
- // the same rule the merge itself follows.
- if (
- value &&
- typeof value === 'object' &&
- !Array.isArray(value) &&
- inDefaults &&
- inConsumer
- ) {
- collect(
- value as Record,
- consumer?.[key] as Record,
- defaults?.[key] as Record,
- path,
- rows,
- counted
- );
- continue;
- }
-
- const kind = BUILT.has(key) ? 'build' : inConsumer ? 'consumer' : 'layer';
- counted[kind] += 1;
-
- const origin =
- kind === 'build'
- ? tag('build')
- : kind === 'consumer'
- ? tag('consumer', 'ok')
- : tag('layer', 'muted');
-
- rows.push(row([code(path), origin, preview(value)]));
- }
-}
-
-function preview(value: unknown): string {
- if (Array.isArray(value)) {
- return `${dim(`${value.length} entries`)} ${code(truncate(JSON.stringify(value)))}`;
- }
- if (value && typeof value === 'object') {
- return code(truncate(JSON.stringify(value)));
- }
-
- return code(String(value));
+ return renderConfig((duxt ?? {}) as unknown as Record);
}
-const truncate = (value: string, length = 160) =>
- value.length > length ? `${value.slice(0, length)}…` : value;
-
/* -------------------------------------------------------------------------- */
/* i18n */
/* -------------------------------------------------------------------------- */
-/**
- * Message keys per locale, against the base language.
- *
- * Seven locales and one JSON file per namespace make drift a matter of time,
- * and i18n answers a missing key by printing the key — so a gap shows up as a
- * label reading `duxt.nav.open` on a page nobody opened in that language.
- */
+/** Every message key of every locale file the layer ships. */
export function i18nPanel(): string {
const root = join(context.layerDir, 'i18n/locales');
@@ -169,81 +48,41 @@ export function i18nPanel(): string {
return 'No i18n/locales directory in the layer.
';
}
- const keysOf = (dir: string) => {
- const keys = new Set();
- const namespaces = join(root, dir, 'duxt');
-
- let files: string[];
- try {
- files = readdirSync(namespaces).filter((file) => file.endsWith('.json'));
- } catch {
- return keys;
- }
-
- for (const file of files) {
- try {
- flatten(
- JSON.parse(readFileSync(join(namespaces, file), 'utf8')) as unknown,
- '',
- keys
- );
- } catch {
- keys.add(`${file}: unreadable`);
- }
- }
-
- return keys;
- };
-
- const base = dirs.includes('en') ? 'en' : dirs[0];
- if (!base) return 'No locales found.
';
+ const files: LocaleFile[] = dirs.map((dir) => ({
+ dir,
+ keys: [...keysOf(join(root, dir, 'duxt'))]
+ }));
- const baseKeys = keysOf(base);
-
- const compared = dirs.map((dir) => {
- const keys = dir === base ? baseKeys : keysOf(dir);
-
- return {
- dir,
- keys,
- missing: [...baseKeys].filter((key) => !keys.has(key)),
- extra: [...keys].filter((key) => !baseKeys.has(key))
- };
+ return renderI18n({
+ files,
+ locales: context.locales,
+ defaultLocale: context.defaultLocale
});
+}
- const rows = compared.map(({ dir, keys, missing, extra }) =>
- // A locale file may deliberately hold only its overrides — pt-BR against
- // pt-PT is the case in this repo — so "missing" is a fact, not a verdict.
- row([
- code(dir) + (dir === base ? ` ${tag('base')}` : ''),
- String(keys.size),
- missing.length
- ? `${tag(String(missing.length), 'warn')} ${dim(truncate(missing.join(', '), 120))}`
- : tag('complete', 'ok'),
- extra.length
- ? `${tag(String(extra.length), 'warn')} ${dim(truncate(extra.join(', '), 120))}`
- : dim('—')
- ])
- );
-
- const served = context.locales.length
- ? `Served locales: ${context.locales.map((locale) => code(locale)).join(' ')}${context.defaultLocale ? `, default ${code(context.defaultLocale)}` : ''}.
`
- : '';
+function keysOf(namespaces: string): Set {
+ const keys = new Set();
- const gaps = compared.filter(
- (entry) => entry.missing.length || entry.extra.length
- ).length;
+ let files: string[];
+ try {
+ files = readdirSync(namespaces).filter((file) => file.endsWith('.json'));
+ } catch {
+ return keys;
+ }
- const summary = stats([
- stat(dirs.length, 'locale files'),
- stat(baseKeys.size, `keys in ${base}`),
- stat(gaps, 'with a gap', gaps ? 'warn' : 'ok')
- ]);
+ for (const file of files) {
+ try {
+ flatten(
+ JSON.parse(readFileSync(join(namespaces, file), 'utf8')) as unknown,
+ '',
+ keys
+ );
+ } catch {
+ keys.add(`${file}: unreadable`);
+ }
+ }
- return `${summary}${served}${table(
- ['Directory', 'Keys', 'Missing vs base', 'Not in base'],
- rows
- )}`;
+ return keys;
}
function flatten(node: unknown, prefix: string, out: Set): void {
@@ -261,16 +100,9 @@ function flatten(node: unknown, prefix: string, out: Set): void {
/* Cache */
/* -------------------------------------------------------------------------- */
-/**
- * Content's download cache, and a way to drop one entry.
- *
- * A stale directory here cost real time once: a tag was fetched as a branch,
- * failed, and the half-written `github.com---[` folder kept
- * answering for the fixed config until it was deleted by hand. Nothing in the
- * build mentions this directory, so nothing suggests the fix.
- */
+/** Content's download cache, and a way to drop one entry. */
export function cachePanel(message?: string): string {
- let entries: { name: string; size: number; modified: Date }[];
+ let entries: CacheEntry[] | undefined;
try {
entries = readdirSync(context.dataDir, { withFileTypes: true })
@@ -285,36 +117,10 @@ export function cachePanel(message?: string): string {
})
.sort((a, b) => a.name.localeCompare(b.name));
} catch {
- return ']Nothing downloaded yet — .data/content does not exist.
';
+ entries = undefined;
}
- const rows = entries.map((entry) =>
- row([
- code(entry.name),
- megabytes(entry.size),
- entry.modified.toISOString().replace('T', ' ').slice(0, 16),
- `
-
- Drop
- `
- ])
- );
-
- const note = message ? `${escape(message)}
` : '';
-
- const summary = stats([
- stat(entries.length, 'cached sources'),
- stat(
- megabytes(entries.reduce((sum, entry) => sum + entry.size, 0)),
- 'on disk'
- )
- ]);
-
- return `${note}${summary}${escape(context.dataDir)}
${table(
- ['Entry', 'Size', 'Modified', ''],
- rows,
- 'The cache directory exists but holds no source. Nothing remote has been downloaded yet.'
- )}Dropping an entry makes the next build download it again. Content keys a directory by repository and ref, so an entry for a ref you have renamed is never reused and never cleaned up either.
`;
+ return renderCache({ dataDir: context.dataDir, entries, message });
}
function sizeOf(path: string): number {
@@ -328,51 +134,45 @@ function sizeOf(path: string): number {
return total;
}
-const megabytes = (bytes: number) =>
- bytes > 1024 * 1024
- ? `${(bytes / 1024 / 1024).toFixed(1)} MB`
- : `${Math.max(1, Math.round(bytes / 1024))} kB`;
-
/* -------------------------------------------------------------------------- */
/* Redirects */
/* -------------------------------------------------------------------------- */
/**
- * The route rules `redirectFrom` produced, read back out of the running server.
+ * Every redirect the server ships, and which of them the layer wrote.
*
* Read rather than recomputed on purpose: a rule that was generated and a rule
* that shipped are different claims, and only the second one redirects anybody.
+ * Reading them back means reading EVERY module's, so `modules/redirects.ts`
+ * records which paths are its own beside them — nothing in a route rule says
+ * who wrote it.
*/
export function redirectsPanel(): string {
- const rules = (
- useRuntimeConfig() as {
- nitro?: { routeRules?: Record };
- }
- ).nitro?.routeRules;
+ const config = useRuntimeConfig() as {
+ nitro?: { routeRules?: Record };
+ duxt?: { redirects?: string[] };
+ };
- if (!rules) {
- return 'Nitro exposed no route rules to read.
';
- }
+ const rules = config.nitro?.routeRules;
+ if (!rules) return renderRedirects();
+
+ const ours = new Set(config.duxt?.redirects ?? []);
- const rows = Object.entries(rules)
+ const redirects: RedirectRule[] = Object.entries(rules)
.filter(([, rule]) => rule?.redirect)
.map(([from, rule]) => {
const redirect = rule.redirect as
| string
| { to?: string; statusCode?: number };
- return row([
- code(from),
- code(typeof redirect === 'string' ? redirect : (redirect.to ?? '?')),
- typeof redirect === 'string'
- ? dim('302')
- : escape(String(redirect.statusCode ?? 302))
- ]);
+ return {
+ from,
+ to: typeof redirect === 'string' ? redirect : (redirect.to ?? '?'),
+ status:
+ typeof redirect === 'string' ? '302' : (redirect.statusCode ?? 302),
+ mine: ours.has(from)
+ };
});
- return `${stats([stat(rows.length, 'redirects')])}${table(
- ['From', 'To', 'Status'],
- rows,
- 'No page carries `redirectFrom`, so no rule was generated.'
- )}One rule per prefix a page is served under — the repository segment, the version segment and every locale — because an old URL was bookmarked under exactly one of them and nothing says which.
`;
+ return renderRedirects(redirects);
}
diff --git a/server/utils/duxt-server-text.ts b/server/utils/duxt-server-text.ts
index 6991a2a5..3c87ba0d 100644
--- a/server/utils/duxt-server-text.ts
+++ b/server/utils/duxt-server-text.ts
@@ -5,9 +5,9 @@ import { resolveDuxtTexts } from '../../app/utils/duxt-text';
* Resolve the config's text fields on the server, where there is no i18n.
*
* `llms.txt` and the feed are Nitro routes: no Vue app, no `useI18n`, and so no
- * translator. Before this, both printed the KEY — a feed whose description read
- * `duxt.defaults.landing.description` and an llms.txt that told a model the
- * same. Falling back to the layer's English messages is the honest answer: it
+ * translator. Before this, both printed the KEY — a feed whose description was
+ * the dotted path itself, and an llms.txt that told a model the same. Falling
+ * back to the layer's English messages is the honest answer: it
* is what `fallbackLocale` already says a missing translation resolves to, and
* these routes have no locale of their own to serve anyway.
*
diff --git a/sources-resolve.ts b/sources-resolve.ts
index 021aec81..15ef4bbd 100644
--- a/sources-resolve.ts
+++ b/sources-resolve.ts
@@ -15,6 +15,20 @@ export interface DuxtSource {
* build with "Could not find refs/heads/…".
*/
refs?: DuxtRef[];
+ /**
+ * Languages this source is available in, beyond the one written in `path`.
+ *
+ * A string is the folder inside `path`: `'de-DE'` reads `docs/de-DE/`. An
+ * object overrides where that language lives — its own folder, its own
+ * repository, its own ref — which is what lets a translation be maintained by
+ * other people, at another pace, in a repository of their own.
+ *
+ * The DEFAULT locale is the exception: it is the tree in `path` itself, with
+ * no folder, so listing it changes nothing. That is what keeps this key
+ * additive — a site that adds `locales` does not move the pages it already
+ * serves. Which one is the default comes from `defaultLocale`.
+ */
+ locales?: DuxtSourceLocale[];
/** Shown in the version switcher and used in the URL; defaults to the ref. */
label?: string;
/** Segment used in the URL for this repository; defaults to the repo name. */
@@ -82,8 +96,44 @@ interface DuxtRefOptions {
label?: string;
/** This one version's lifecycle, overriding the source's. */
status?: DuxtSourceStatus;
+ /**
+ * The languages THIS version is available in, overriding the source's.
+ *
+ * Resolved exactly as `status` is (`ref.locales ?? source.locales`), because
+ * a translation is usually kept for the current version and not for the two
+ * behind it — and a version whose translation nobody maintains is better
+ * declared untranslated than served stale.
+ */
+ locales?: DuxtSourceLocale[];
}
+/**
+ * One language of a source: the folder it lives in, or where else it lives.
+ *
+ * The string form is the whole of it for a repository that translates in
+ * place. The object form is what the big projects do instead — React keeps
+ * `de.react.dev` as its own repository, Vue an entire `vuejs-translations`
+ * org — because translators work at their own pace under their own review. A
+ * layer that already sources across repositories and refs can offer that as a
+ * source entry rather than as a second website.
+ */
+export type DuxtSourceLocale =
+ | string
+ | {
+ /** The locale code, matching one the site serves. */
+ locale: string;
+ /** Folder holding this language, relative to the repository root. */
+ path?: string;
+ /** A repository of its own — `owner/name` or a git URL. */
+ repo?: string;
+ /** A ref of its own, when the translation is versioned separately. */
+ ref?: DuxtRef;
+ };
+
+/** The code of a locale entry, whichever form it takes. */
+export const localeCode = (locale: DuxtSourceLocale): string =>
+ typeof locale === 'string' ? locale : locale.locale;
+
/**
* The tag shorthand: the newest one, resolved at build time.
*
@@ -113,6 +163,16 @@ export interface DuxtSourcesOptions {
showVersion?: boolean;
/** The ref served without a version prefix, by name. Defaults to the first. */
defaultRef?: string;
+ /**
+ * The locale whose pages are the tree in `path` itself, with no folder.
+ *
+ * Defaults to the first entry of the first `locales` list, so a site that
+ * never sets it still resolves the same way on both sides. It has to be
+ * read from the config rather than from `i18n.defaultLocale`: the
+ * collections are declared in `content.config.ts`, which has no access to
+ * the Nuxt config, and the two halves must land on identical names.
+ */
+ defaultLocale?: string;
}
/** One resolved source: which collection serves which URL prefix. */
@@ -141,6 +201,15 @@ export interface DuxtResolvedSource {
refKind?: 'branch' | 'tag';
/** Folder inside the repository holding the Markdown. */
path: string;
+ /**
+ * The locale this collection serves, when the site has translations.
+ *
+ * Absent on a site with none — which keeps every existing collection name,
+ * prefix and query exactly as it was.
+ */
+ locale?: string;
+ /** True for the locale served from `path` itself, without a folder. */
+ isDefaultLocale: boolean;
/** Where this version sits in its life; `current` unless stated. */
status: DuxtSourceStatus;
/** Whether the build may read this source's git history. */
@@ -178,7 +247,46 @@ export const slugify = (value: string) => {
* missing from the site, and nothing connecting the two. So the name is
* derived separately from the prefix: dashes and dots become underscores.
*/
-const identifier = (value: string) =>
+/** The name a partial in the default language resolves against. */
+export const PARTIALS_COLLECTION = 'duxt_partials';
+
+/**
+ * The partials collection one language reads.
+ *
+ * Named exactly as the page collections are: the default language keeps the
+ * bare name a single-language site already had, and every other language
+ * appends its code. So a site that declares no `locales` gets the one
+ * collection it always got, under the name it always had.
+ *
+ * Takes the RESOLVED SOURCE rather than a code, because "the default language"
+ * is a property of the manifest and not of the string `en`: a site whose
+ * default is `de` still calls that collection `duxt_partials`. A bare string is
+ * accepted for the build's own grouping, where the default is already
+ * `undefined`.
+ */
+export function partialsCollection(
+ locale?: Pick | string
+): string {
+ const code =
+ typeof locale === 'string'
+ ? locale
+ : locale && !locale.isDefaultLocale
+ ? locale.locale
+ : undefined;
+
+ return code
+ ? `${PARTIALS_COLLECTION}_${identifier(code)}`
+ : PARTIALS_COLLECTION;
+}
+
+/**
+ * A collection name Content and TypeScript both accept.
+ *
+ * Exported because the partials collections are named from the same pieces the
+ * page collections are, and a second spelling of this rule is a name that
+ * drifts the first time a locale carries a character neither expected.
+ */
+export const identifier = (value: string) =>
value.replace(/[^a-z0-9]+/gi, '_').replace(/^_|_$/g, '');
export const repoSlug = (source: DuxtSource) =>
@@ -210,6 +318,76 @@ export const repoUrl = (repo: string) => {
return repo.includes('://') ? repo : `https://github.com/${repo}`;
};
+/**
+ * The locales one source-and-ref combination expands into.
+ *
+ * `[undefined]` — one entry, no locale — is the shape every site had before
+ * this key existed, and the one every site without it still has. That is what
+ * keeps the whole feature additive: no `locales`, no second collection, no
+ * change to a single name.
+ */
+function localesOf(
+ source: DuxtSource,
+ ref: DuxtRef | undefined
+): (DuxtSourceLocale | undefined)[] {
+ const declared =
+ (ref && typeof ref === 'object' ? ref.locales : undefined) ??
+ source.locales;
+
+ return declared?.length ? declared : [undefined];
+}
+
+/** The first locale any source declares — the default when none is configured. */
+function firstLocale(sources: DuxtSource[]): string | undefined {
+ for (const source of sources) {
+ const declared =
+ source.locales ??
+ source.refs?.flatMap((ref) =>
+ typeof ref === 'object' && ref.locales ? ref.locales : []
+ );
+
+ if (declared?.length) return localeCode(declared[0]!);
+ }
+
+ return undefined;
+}
+
+/**
+ * Where one language of a source actually lives.
+ *
+ * The default locale is the tree in `path` itself; every other language is a
+ * folder inside it unless the entry says otherwise. An object may move the
+ * language wholesale — its own folder, repository and ref — which is the case
+ * a translation maintained by other people needs.
+ */
+function localeEntry(
+ source: DuxtSource,
+ locale: DuxtSourceLocale | undefined,
+ defaultLocale: string | undefined
+): { path: string; repo?: string; ref?: DuxtRef } {
+ const base = source.path ?? 'docs';
+ if (!locale) return { path: base, repo: source.repo };
+
+ const code = localeCode(locale);
+ const override = typeof locale === 'string' ? undefined : locale;
+
+ if (code === defaultLocale) {
+ return {
+ path: override?.path ?? base,
+ repo: override?.repo ?? source.repo,
+ ref: override?.ref
+ };
+ }
+
+ return {
+ // A folder INSIDE the source's own path, so one repository holds its
+ // translations beside the original — and an override escapes that.
+ path: override?.path ?? `${base}/${code}`,
+ repo: override?.repo ?? source.repo,
+ ref: override?.ref
+ };
+}
+
/**
* Resolve the list once: names, prefixes and labels.
*
@@ -224,16 +402,47 @@ export const repoUrl = (repo: string) => {
* or versions exist at all — which is also what keeps the scheme routable,
* since the shape is fixed before the first request.
*/
+/** One expanded combination: a source, at a ref, in a language. */
+export interface DuxtExpandedSource {
+ source: DuxtSource;
+ ref?: DuxtRef;
+ locale?: DuxtSourceLocale;
+ /** Where this combination's Markdown actually lives. */
+ effective: { path: string; repo?: string; ref?: DuxtRef };
+}
+
+/**
+ * The one expansion both halves of the layer read.
+ *
+ * `content.config.ts` declares the collections and the duxt module resolves
+ * the manifest, and the two MUST agree entry for entry — the manifest tells
+ * the theme which collection serves a route, and a name computed twice is a
+ * name that drifts once. Exported for exactly that reason.
+ */
+export function expandSources(
+ sources: DuxtSource[],
+ options: DuxtSourcesOptions = {}
+): DuxtExpandedSource[] {
+ const defaultLocale = options.defaultLocale ?? firstLocale(sources);
+
+ return sources.flatMap((source) =>
+ (source.refs?.length ? source.refs : [undefined]).flatMap((ref) =>
+ localesOf(source, ref).map((locale) => ({
+ source,
+ ref,
+ locale,
+ effective: localeEntry(source, locale, defaultLocale)
+ }))
+ )
+ );
+}
+
export function resolveSources(
sources: DuxtSource[],
options: DuxtSourcesOptions = {}
): DuxtResolvedSource[] {
- const expanded = sources.flatMap((source) =>
- (source.refs?.length ? source.refs : [undefined]).map((ref) => ({
- source,
- ref
- }))
- );
+ const defaultLocale = options.defaultLocale ?? firstLocale(sources);
+ const expanded = expandSources(sources, options);
const repos = new Set(sources.map((source) => source.repo ?? ''));
const refs = new Set(
@@ -250,10 +459,13 @@ export function resolveSources(
const resolved: DuxtResolvedSource[] = [];
const taken = new Map();
- for (const { source, ref } of expanded) {
- const name = ref ? refName(ref) : undefined;
+ for (const { source, ref, locale, effective: entry } of expanded) {
+ const effectiveRef = entry.ref ?? ref;
+ const name = effectiveRef ? refName(effectiveRef) : undefined;
const label =
(ref && typeof ref === 'object' ? ref.label : undefined) ?? source.label;
+ const code = locale ? localeCode(locale) : undefined;
+ const isDefaultLocale = !code || code === defaultLocale;
const version = name ? slugify(label ?? name) : undefined;
const isDefault = !name || name === defaultRef;
@@ -261,11 +473,22 @@ export function resolveSources(
if (withRepo) segments.push(repoSlug(source));
if (withVersion && version && !isDefault) segments.push(version);
+ // THE LOCALE IS NOT PART OF THE PREFIX. @nuxtjs/i18n already puts it in
+ // front of the path, so a collection carrying it too would spell it twice
+ // in one URL. Original and translation therefore live under IDENTICAL
+ // content paths in different collections — which leaves every path
+ // comparison in the theme untouched and makes the fallback a second query
+ // for the same path.
const prefix = segments.length ? `/${segments.join('/')}` : '';
const collection =
- ['docs', ...segments].map(identifier).join('_') || 'docs';
-
- const previous = taken.get(prefix);
+ ['docs', ...(isDefaultLocale ? [] : [code!]), ...segments]
+ .map(identifier)
+ .join('_') || 'docs';
+
+ // Claimed per locale: two languages serving one prefix is not a collision,
+ // it is the point.
+ const claim = `${code ?? ''}|${prefix}`;
+ const previous = taken.get(claim);
if (previous) {
// The one ambiguity the build-time decision leaves: a docs folder named
// like a repository or a version. Rejected rather than resolved silently.
@@ -276,7 +499,7 @@ export function resolveSources(
);
}
taken.set(
- prefix,
+ claim,
`${source.repo ?? 'this repository'}${name ? `@${name}` : ''}`
);
@@ -286,28 +509,89 @@ export function resolveSources(
repo: withRepo ? repoSlug(source) : undefined,
version,
isDefault,
- repository: source.repo ?? source.origin?.repo,
- repositoryUrl: source.repo
- ? repoUrl(source.repo)
+ repository: entry.repo ?? source.origin?.repo,
+ repositoryUrl: entry.repo
+ ? repoUrl(entry.repo)
: source.origin?.repo
? repoUrl(source.origin.repo)
: undefined,
ref: name ?? source.origin?.ref,
- refKind: ref ? (refIsTag(ref) ? 'tag' : 'branch') : undefined,
- path: source.path ?? 'docs',
+ refKind: effectiveRef
+ ? refIsTag(effectiveRef)
+ ? 'tag'
+ : 'branch'
+ : undefined,
+ path: entry.path,
+ locale: code,
+ isDefaultLocale,
status:
(ref && typeof ref === 'object' ? ref.status : undefined) ??
source.status ??
'current',
// A local source is a full checkout already; a remote one has to be
// unshallowed, which is why it has to be asked for.
- history: source.repo ? (source.history ?? false) : true
+ history: entry.repo ? (source.history ?? false) : true
});
}
return resolved;
}
+/**
+ * The locales a page may be served from, best first.
+ *
+ * A translated Markdown file cannot be merged the way a locale FILE is: a page
+ * is translated whole or not at all, so this is a chain of choices rather than
+ * a deep merge. It follows the rule the layer already applies twice — the
+ * `files` arrays in `nuxt.config.ts` and `resolveDuxtText` both let the
+ * language carry the content and the region carry only its deviations:
+ *
+ * 1. the locale itself — `de-DE` reads `de-DE`;
+ * 2. its base language — `de-DE` reads a `de` tree, which is what lets one
+ * `pt/` folder serve both `pt-PT` and `pt-BR`;
+ * 3. a sibling of the same language — `pt-BR` reads `pt-PT` before it gives
+ * up on Portuguese and falls back to English;
+ * 4. `fallbackLocale`, the value vue-i18n already carries for missing keys,
+ * so the interface and the pages agree on where they fall back to;
+ * 5. the default locale, which is the untranslated original.
+ *
+ * `undefined` closes every chain: it is the collection of a site that declares
+ * no locales at all, and the one a translation ultimately falls back to.
+ */
+export function localeChain(
+ locale: string | undefined,
+ available: (string | undefined)[],
+ fallbackLocale?: string | string[]
+): (string | undefined)[] {
+ const has = (code: string | undefined) => available.includes(code);
+ const chain: (string | undefined)[] = [];
+
+ const add = (code: string | undefined) => {
+ if (!chain.includes(code) && has(code)) chain.push(code);
+ };
+
+ if (locale) {
+ add(locale);
+
+ const base = locale.split('-')[0]!;
+ add(base);
+
+ // A sibling region of the same language, in the order the site declares
+ // them: `pt-BR` takes `pt-PT` over English, every time.
+ for (const code of available) {
+ if (code && code !== locale && code.split('-')[0] === base) add(code);
+ }
+ }
+
+ for (const code of [fallbackLocale ?? []].flat()) add(code);
+
+ // The original. Always last, and always reachable — a page that exists in no
+ // translation still has to render.
+ if (!chain.includes(undefined)) chain.push(undefined);
+
+ return chain;
+}
+
/**
* The resolved list, as data the app can read.
*
@@ -434,3 +718,89 @@ export function versionRelation(
// version leads the list — it is the newer of the two.
return order === 0 ? 'same' : order < 0 ? 'newer' : 'older';
}
+
+/**
+ * The collections that serve one route, best first.
+ *
+ * Two decisions in one, because neither is complete without the other: WHICH
+ * PREFIX claims the path, and WHICH LANGUAGE of that prefix the reader gets.
+ * The locale is deliberately not part of a prefix — `@nuxtjs/i18n` already puts
+ * it in front of the path — so original and translation are two collections
+ * behind one identical prefix, and a lookup handed only the path cannot tell
+ * them apart. Whichever sorted first won, which is the bug this exists to close.
+ *
+ * `path` is the DOCUMENTATION path, locale segment already stripped, and
+ * `locale` is what was stripped off it. The rest is `localeChain`, so the theme
+ * and every view that debugs it fall back through the same order.
+ *
+ * Never empty while `sources` is not: a route whose manifest has not arrived
+ * still has to query something, and an empty chain 404s a page that exists.
+ */
+export function sourcesForRoute(
+ path: string,
+ locale: string | undefined,
+ sources: DuxtResolvedSource[],
+ fallbackLocale?: string | string[]
+): DuxtResolvedSource[] {
+ if (!sources.length) return [];
+
+ // Read the prefix off the DEFAULT-language entries: every language of one
+ // source shares the prefix, so the pool only has to be free of duplicates.
+ const withDefaultLocale = sources.filter((source) => source.isDefaultLocale);
+ const pool = withDefaultLocale.length ? withDefaultLocale : sources;
+
+ const prefix =
+ (
+ [...pool]
+ .sort((a, b) => b.prefix.length - a.prefix.length)
+ .find(
+ (source) => !source.prefix || isInsidePrefix(path, source.prefix)
+ ) ??
+ pool.find((source) => !source.prefix) ??
+ // The landing page matches no prefix on a site whose every source has
+ // one. It still needs a real collection for the navigation the header
+ // draws, and a literal `docs` names one such a site does not have.
+ pool[0]
+ )?.prefix ?? '';
+
+ const candidates = sources.filter((source) => source.prefix === prefix);
+
+ // Deduplicated, because two links of the chain routinely land on ONE
+ // collection: `fallbackLocale` is usually the default locale, and the chain
+ // ends at the untranslated original regardless — so `en` and `undefined` both
+ // resolve to the same entry, and the panel printed "falls back to docs → docs".
+ const ordered = [
+ ...new Set(
+ localeChain(
+ locale,
+ candidates.map((source) => source.locale),
+ fallbackLocale
+ )
+ .map((code) =>
+ candidates.find((source) =>
+ code === undefined ? source.isDefaultLocale : source.locale === code
+ )
+ )
+ .filter(Boolean) as DuxtResolvedSource[]
+ )
+ ];
+
+ if (ordered.length) return ordered;
+ if (candidates.length) return candidates.slice(0, 1);
+
+ return sources.slice(0, 1);
+}
+
+/**
+ * Is `path` inside `prefix`? Segment-aware — `/workflows-old` is not inside
+ * `/workflows`, though it starts with it.
+ *
+ * The same rule as `isInside` in `app/utils/version-paths.ts`, and duplicated
+ * rather than imported: that file imports from THIS one, and a cycle between
+ * them is what `app.config.ts` reading this module cannot survive.
+ */
+function isInsidePrefix(path: string, prefix: string): boolean {
+ if (!prefix) return true;
+
+ return path === prefix || path.startsWith(`${prefix}/`);
+}
diff --git a/sources.ts b/sources.ts
index 31159723..2e7e6eeb 100644
--- a/sources.ts
+++ b/sources.ts
@@ -3,7 +3,13 @@ import { dirname, join } from 'node:path';
import { defineCollection, z } from '@nuxt/content';
import { defineSitemapSchema } from '@nuxtjs/sitemap/content';
import type { DuxtSource, DuxtSourcesOptions } from './sources-resolve';
-import { refName, repoUrl, resolveSources } from './sources-resolve';
+import {
+ expandSources,
+ refIsTag,
+ refName,
+ repoUrl,
+ resolveSources
+} from './sources-resolve';
import { resolveLatestRefs } from './sources-git';
export type {
DuxtResolvedSource,
@@ -11,6 +17,11 @@ export type {
DuxtSourcesOptions
} from './sources-resolve';
export { duxtSourceManifest } from './sources-resolve';
+// Named beside the resolver, not here: the app reads it to pick a partial's
+// language, and this file imports `@nuxt/content` — which the bundler refuses
+// to follow out of client code.
+import { PARTIALS_COLLECTION, partialsCollection } from './sources-resolve';
+export { PARTIALS_COLLECTION, partialsCollection };
/**
* Walk up to the repository root, so `docs/` resolves there and not in a
@@ -141,48 +152,104 @@ export function duxtSources(
const resolved = resolveSources(sources, options);
const collections: Record> = {};
- const expanded = sources.flatMap((source) =>
- (source.refs?.length ? source.refs : [undefined]).map((ref) => ({
- source,
- ref
- }))
- );
+ // The SAME expansion the manifest is built from, so entry `n` here and entry
+ // `n` there are the same source, ref and language. See `expandSources`.
+ const expanded = expandSources(sources, options);
resolved.forEach((entry, index) => {
- const { source, ref } = expanded[index]!;
+ const { source, ref, effective } = expanded[index]!;
+
+ // A locale entry may carry a ref of its own — a translation repository
+ // that tags on its own schedule.
+ const usedRef = effective.ref ?? ref;
+
+ /**
+ * The translations living INSIDE this source's own folder.
+ *
+ * The default locale reads `docs/` with `**\/*.md`, and that glob happily
+ * swallows `docs/de-DE/` — the original collection would carry every
+ * translated page a second time, under a path beginning with the locale,
+ * and the sidebar would show both. So each folder a sibling language
+ * occupies is excluded from the language that contains it.
+ */
+ const nested = expanded
+ .filter(
+ (other) =>
+ other.source === source &&
+ other.effective.repo === effective.repo &&
+ other.effective.path !== effective.path &&
+ other.effective.path.startsWith(`${effective.path}/`)
+ )
+ .map(
+ (other) => `${other.effective.path.slice(effective.path.length + 1)}/**`
+ );
collections[entry.collection] = defineCollection({
type: 'page',
schema: pageSchema,
- source: source.repo
+ source: effective.repo
? {
- exclude: excluded(),
+ exclude: [
+ ...excluded(),
+ ...nested.map((glob) => `${effective.path}/${glob}`)
+ ],
// A tag lives outside refs/heads, so it has to be passed as a tag —
// asking git for a branch by that name fails the build outright.
- repository: ref
- ? typeof ref === 'object' && 'tag' in ref
- ? { url: repoUrl(source.repo), tag: ref.tag }
- : { url: repoUrl(source.repo), branch: refName(ref) }
- : repoUrl(source.repo),
- include: `${source.path ?? 'docs'}/**/*.md`,
+ repository: usedRef
+ ? refIsTag(usedRef)
+ ? { url: repoUrl(effective.repo), tag: refName(usedRef) }
+ : { url: repoUrl(effective.repo), branch: refName(usedRef) }
+ : repoUrl(effective.repo),
+ include: `${effective.path}/**/*.md`,
prefix: entry.prefix
}
: {
- exclude: excluded(),
+ exclude: [...excluded(), ...nested],
include: '**/*.md',
- cwd: join(repositoryRoot(), source.path ?? 'docs'),
+ cwd: join(repositoryRoot(), effective.path),
prefix: entry.prefix
}
});
});
- collections[PARTIALS_COLLECTION] = definePartials(sources);
+ // One partials collection per LANGUAGE, named the way the page collections
+ // are — see `partialsCollection`.
+ for (const [locale, entries] of partialFolders(resolved, expanded)) {
+ collections[partialsCollection(locale)] = definePartials(entries);
+ }
return collections;
}
-/** The name every `:partial{name}` resolves against. */
-export const PARTIALS_COLLECTION = 'duxt_partials';
+/** One folder list per language: where that language's `_partials/` live. */
+function partialFolders(
+ resolved: DuxtResolvedSource[],
+ expanded: ReturnType
+): Map {
+ const byLocale = new Map<
+ string | undefined,
+ { repo?: string; path: string }[]
+ >();
+ const seen = new Set();
+
+ resolved.forEach((entry, index) => {
+ const { effective } = expanded[index]!;
+ const key = entry.isDefaultLocale ? undefined : entry.locale;
+
+ // One entry per REPOSITORY AND FOLDER, not per version: a partial is a
+ // block of prose, and reading three versions of it into one collection
+ // would give three blocks under one name.
+ const claim = `${key ?? ''}|${effective.repo ?? ''}:${effective.path}`;
+ if (seen.has(claim)) return;
+ seen.add(claim);
+
+ const list = byLocale.get(key) ?? [];
+ list.push({ repo: effective.repo, path: effective.path });
+ byLocale.set(key, list);
+ });
+
+ return byLocale;
+}
/**
* One collection over every source's `_partials/` folder.
@@ -198,33 +265,18 @@ export const PARTIALS_COLLECTION = 'duxt_partials';
* Two sources defining the same name is a collision the build reports rather
* than resolves; see `modules/validate.ts`.
*/
-function definePartials(sources: DuxtSource[]) {
- const seen = new Set();
-
- const entries = sources
- .map((source) => {
- const folder = source.path ?? 'docs';
-
- // One entry per REPOSITORY, not per version: a partial is a block of
- // prose, and reading three versions of it into one collection would give
- // three blocks under one name.
- const key = `${source.repo ?? ''}:${folder}`;
- if (seen.has(key)) return undefined;
- seen.add(key);
-
- return source.repo
- ? {
- repository: repoUrl(source.repo),
- include: `${folder}/_partials/**/*.md`
- }
- : {
- include: '_partials/**/*.md',
- cwd: join(repositoryRoot(), folder)
- };
- })
- .filter(Boolean) as NonNullable<
- Parameters[0]['source']
- >[];
+function definePartials(folders: { repo?: string; path: string }[]) {
+ const entries = folders.map((folder) =>
+ folder.repo
+ ? {
+ repository: repoUrl(folder.repo),
+ include: `${folder.path}/_partials/**/*.md`
+ }
+ : {
+ include: '_partials/**/*.md',
+ cwd: join(repositoryRoot(), folder.path)
+ }
+ ) as NonNullable[0]['source']>[];
// The SAME schema as the pages, not a smaller one. Content types a query by
// the fields every collection has in common, so a partials collection with
diff --git a/tests/content-cache-wal.test.ts b/tests/content-cache-wal.test.ts
new file mode 100644
index 00000000..2b4db355
--- /dev/null
+++ b/tests/content-cache-wal.test.ts
@@ -0,0 +1,48 @@
+import { existsSync, mkdtempSync, rmSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { afterEach, describe, expect, it } from 'vitest';
+import { enableWriteAheadLog } from '../content-cache';
+
+/**
+ * The one thing this function must never do: create the database.
+ *
+ * `new DatabaseSync` on a missing path writes an empty 4 kB file. Content then
+ * finds a database where it expected none, never creates its schema, and every
+ * query afterwards dies with `no such table: _development_cache` — which is
+ * exactly what CI hit, and what no local machine can reproduce once it has
+ * built a single time.
+ *
+ * Asserted on the FILESYSTEM rather than on the return value: the bug was never
+ * about what this reports, only about what it leaves behind.
+ */
+const roots: string[] = [];
+
+function nuxt() {
+ const rootDir = mkdtempSync(join(tmpdir(), 'duxt-wal-'));
+ roots.push(rootDir);
+ return { options: { rootDir, runtimeConfig: {} } } as never;
+}
+
+afterEach(() => {
+ for (const root of roots.splice(0))
+ rmSync(root, { recursive: true, force: true });
+});
+
+describe('enableWriteAheadLog', () => {
+ it('creates no database where there is none', () => {
+ const options = nuxt() as unknown as { options: { rootDir: string } };
+ const file = join(options.options.rootDir, '.data/content/contents.sqlite');
+
+ expect(enableWriteAheadLog(options as never)).toBe('skipped');
+ expect(existsSync(file), 'the cache file was created').toBe(false);
+ });
+
+ it('leaves the directory alone too', () => {
+ const options = nuxt() as unknown as { options: { rootDir: string } };
+
+ enableWriteAheadLog(options as never);
+
+ expect(existsSync(join(options.options.rootDir, '.data'))).toBe(false);
+ });
+});
diff --git a/tests/i18n-ownership.test.ts b/tests/i18n-ownership.test.ts
new file mode 100644
index 00000000..4e6e5a02
--- /dev/null
+++ b/tests/i18n-ownership.test.ts
@@ -0,0 +1,242 @@
+import { readFileSync, readdirSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import { describe, expect, it } from 'vitest';
+import { duxtDefaults } from '../app/utils/duxt-config';
+
+/**
+ * The layer's defaults name no project, and the locale files translate nothing
+ * but the interface.
+ *
+ * The rule this enforces is written into `app/utils/duxt-config.ts`:
+ * `duxt.defaults.*` translates the interface the layer draws, never content a
+ * site writes. It was broken once already, and quietly — the landing headline,
+ * six feature cards, a "Resources" dropdown of duxt's own tech stack, the site
+ * name and a `v0.0.0` badge all shipped as defaults, so a stranger extending
+ * the layer got a site advertising somebody else's project.
+ *
+ * NOTHING ELSE CAN SEE IT. `pnpm build:app` and `pnpm check:a11y` both run over
+ * `www/`, which overrides every leaking key — the only site in this repo that
+ * renders the defaults is the one that never renders them. Same argument
+ * `tests/contrast.test.ts` makes about jsdom, and the same answer: check the
+ * asset itself, here rather than in a gate.
+ *
+ * The regex assertions below are the cheap half and catch only the loud
+ * failures. `INVENTORY` is the half that enforces the rule, because ownership
+ * is not a vocabulary: "The framework underneath", "Extend, don't scaffold" and
+ * `'v0.0.0'` name no product and hold no URL, yet every one of them was a leak.
+ */
+
+const localesDir = fileURLToPath(new URL('../i18n/locales', import.meta.url));
+
+/** Every `/duxt/.json`, read rather than listed, so a new file is covered. */
+function localeFiles(): [string, unknown][] {
+ const files: [string, unknown][] = [];
+
+ for (const lang of readdirSync(localesDir)) {
+ const dir = `${localesDir}/${lang}/duxt`;
+
+ for (const file of readdirSync(dir)) {
+ if (!file.endsWith('.json')) continue;
+ files.push([
+ `${lang}/duxt/${file}`,
+ JSON.parse(readFileSync(`${dir}/${file}`, 'utf8'))
+ ]);
+ }
+ }
+
+ return files;
+}
+
+/** Leaf string VALUES with their dotted path. Never keys — every file wraps in `duxt`. */
+function strings(node: unknown, path = ''): [string, string][] {
+ if (typeof node === 'string') return [[path, node]];
+ if (!node || typeof node !== 'object') return [];
+
+ return Object.entries(node as Record).flatMap(
+ ([key, value]) => strings(value, path ? `${path}.${key}` : key)
+ );
+}
+
+const URL_LIKE = /https?:\/\/|\bwww\.|\.(?:com|dev|app|org|io|net)\b/i;
+const PRODUCTS = /\b(duxt|nuxt|shadcn|tailwind|mdc|kirch)\b/i;
+
+/**
+ * Product names the interface may say, each because the layer DRAWS the thing.
+ *
+ * A fourth entry needs a sentence here saying which control the layer renders
+ * that cannot be named any other way. If one cannot be written, the string is
+ * a leak.
+ */
+const ALLOW = new Set([
+ 'Markdown', // `page.copy.view` — the format the button hands you
+ 'ChatGPT', // `page.copy.chatgpt` — the button's destination
+ 'Claude' // `page.copy.claude` — the button's destination
+]);
+
+/** An internal key resolved at render time, not a rendered string. */
+const isKey = (value: string) => value.startsWith('duxt.defaults.');
+
+const named = (value: string) =>
+ PRODUCTS.test(value) && ![...ALLOW].some((word) => value.includes(word));
+
+describe('the layer ships no site content', () => {
+ const defaults = strings(duxtDefaults);
+
+ it.each(defaults)('duxtDefaults.%s holds no URL', (_path, value) => {
+ expect(URL_LIKE.test(value), `"${value}" carries a URL`).toBe(false);
+ });
+
+ it.each(defaults)('duxtDefaults.%s names no product', (_path, value) => {
+ expect(isKey(value) || !named(value), `"${value}" names a product`).toBe(
+ true
+ );
+ });
+
+ const values = localeFiles().flatMap(([file, json]) =>
+ strings(json).map(([path, value]) => [`${file} → ${path}`, value] as const)
+ );
+
+ it.each(values)('%s holds no URL', (_where, value) => {
+ expect(URL_LIKE.test(value), `"${value}" carries a URL`).toBe(false);
+ });
+
+ it.each(values)('%s names no product', (_where, value) => {
+ expect(named(value), `"${value}" names a product`).toBe(false);
+ });
+});
+
+/**
+ * Every shipped key is reachable, and every default points at one that exists.
+ *
+ * The BACKWARD direction runs over every key, and is the one that finds things:
+ * a key nothing points at is a string five languages carry and no site can
+ * reach. `duxt.nav.anchor` was exactly that — the heading anchors became their
+ * own links, the `aria-label` went with them, and the translations stayed.
+ *
+ * The FORWARD direction is asserted only for `duxt.defaults.*`, deliberately.
+ * A regex over source cannot tell an i18n key from a config path: `duxt.footer`
+ * `.copyright` is a field and `duxt.footer.poweredBy` is a key, and they are the
+ * same shape. Under `duxt.defaults.*` there is no ambiguity — those literals are
+ * VALUES in `duxtDefaults`, so every one of them is a key by construction.
+ * Missing keys elsewhere are vue-i18n's job, which prints them at runtime.
+ *
+ * The source of truth is the SOURCE, read here rather than restated.
+ */
+const sources = ['app', 'server', 'modules'].flatMap((dir) =>
+ walk(fileURLToPath(new URL(`../${dir}`, import.meta.url)))
+);
+
+/**
+ * `server/devtools/preview.ts` is excluded: it is a FIXTURE, and the keys in it
+ * belong to a site that does not exist — `duxt.page.translate`, `duxt.nav.close`
+ * and `duxt.page.updated` are invented so the i18n panel has a consumer's
+ * translations to show beside the layer's.
+ */
+function walk(dir: string): string[] {
+ return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
+ const full = `${dir}/${entry.name}`;
+ if (entry.isDirectory()) return walk(full);
+ if (entry.name === 'preview.ts') return [];
+
+ return /\.(vue|ts)$/.test(entry.name) ? [readFileSync(full, 'utf8')] : [];
+ });
+}
+
+/**
+ * Keys built at runtime, which no literal search can see.
+ *
+ * Each is a template whose tail is a value — `duxt.version.${kind}` in
+ * `DuxtVersionBanner.vue`, the two `status`/`titles` maps beside it. Listed as
+ * prefixes and nowhere else: a fourth entry means somebody added a computed key,
+ * and it should cost a line here saying so.
+ */
+const COMPUTED = [
+ 'duxt.version.',
+ 'duxt.version.status.',
+ 'duxt.version.titles.'
+];
+
+describe('every key is reachable', () => {
+ const used = new Set([
+ ...sources.flatMap((file) => file.match(/duxt\.[a-zA-Z][a-zA-Z.]*/g) ?? []),
+ ...strings(duxtDefaults)
+ .map(([, value]) => value)
+ .filter(isKey)
+ ]);
+
+ const shipped = localeFiles()
+ .filter(([file]) => file.startsWith('en/'))
+ .flatMap(([, json]) => strings(json).map(([path]) => path));
+
+ it('ships nothing nothing points at', () => {
+ expect(
+ shipped.filter(
+ (key) => !used.has(key) && !COMPUTED.some((p) => key.startsWith(p))
+ )
+ ).toEqual([]);
+ });
+
+ it('defaults point at nothing the English locale lacks', () => {
+ const named = [
+ ...strings(duxtDefaults)
+ .map(([, value]) => value)
+ .filter(isKey),
+ // `index.vue` calls these three through `$t()` with no config field behind
+ // them, so they are named rather than derived.
+ 'duxt.defaults.landing.preview',
+ 'duxt.defaults.landing.previewOpen',
+ 'duxt.defaults.landing.featuresTitle'
+ ];
+
+ expect(named.filter((key) => !shipped.includes(key))).toEqual([]);
+ });
+});
+
+/**
+ * The inventory — the only assertion that enforces OWNERSHIP rather than
+ * vocabulary, and the only one that would have caught `version: 'v0.0.0'`.
+ *
+ * Adding a key costs an edit here plus a sentence saying why the LAYER draws
+ * that string. That cost is the mechanism: it is what makes somebody stop and
+ * ask whether the string belongs to the layer or to the site.
+ *
+ * This is not a shrinking set — `title` was ADDED by the migration that emptied
+ * the rest, because a name the layer cannot know still has to render as a word.
+ */
+const INVENTORY = [
+ 'title', // A neutral stand-in; an unset title prints `undefined` in six places
+ 'navigation.docs', // The one navbar entry that names the interface, not a tree
+ 'landing.actions.docs', // "Read the docs" is true of every site on this layer
+ 'landing.preview', // The embedded window's accessible name
+ 'landing.previewOpen', // The button on it
+ 'landing.featuresTitle', // The heading over a grid the SITE fills
+ 'aside.title' // "Community" heads a block that draws nothing until filled
+];
+
+describe('the layer draws exactly this much text', () => {
+ it('ships the agreed defaults and nothing more', () => {
+ const [, en] =
+ localeFiles().find(([file]) => file === 'en/duxt/defaults.json') ?? [];
+
+ const shipped = strings(en)
+ .map(([path]) => path.replace(/^duxt\.defaults\./, ''))
+ .sort();
+
+ expect(shipped).toEqual([...INVENTORY].sort());
+ });
+
+ it('defaults no config field the inventory does not cover', () => {
+ expect(Object.keys(duxtDefaults).sort()).toEqual(
+ [
+ 'aside',
+ 'breadcrumb',
+ 'landing',
+ 'links',
+ 'navigation',
+ 'packageManagers',
+ 'sections',
+ 'title'
+ ].sort()
+ );
+ });
+});
diff --git a/tests/locale-path.test.ts b/tests/locale-path.test.ts
index 9743e41d..33d72680 100644
--- a/tests/locale-path.test.ts
+++ b/tests/locale-path.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
-import { stripLocalePrefix } from '../app/utils/locale-path';
+import { splitLocalePath, stripLocalePrefix } from '../app/utils/locale-path';
const codes = ['en-GB', 'en-US', 'de-DE', 'es-ES', 'fr-FR', 'pt-PT', 'pt-BR'];
@@ -37,3 +37,31 @@ describe('stripLocalePrefix', () => {
expect(stripLocalePrefix('', codes)).toBe('');
});
});
+
+describe('splitLocalePath', () => {
+ const codes = ['en-GB', 'de-DE', 'pt-BR'];
+
+ it('hands back the language it stripped', () => {
+ expect(splitLocalePath('/de-DE/guide/deploying', codes)).toEqual({
+ locale: 'de-DE',
+ path: '/guide/deploying'
+ });
+ });
+
+ it('reports no language where there was no segment', () => {
+ expect(splitLocalePath('/guide/deploying', codes)).toEqual({
+ path: '/guide/deploying'
+ });
+ });
+
+ it('leaves a folder named like nothing on the list alone', () => {
+ expect(splitLocalePath('/de/guide', codes)).toEqual({ path: '/de/guide' });
+ });
+
+ it('keeps a bare locale root routable', () => {
+ expect(splitLocalePath('/de-DE', codes)).toEqual({
+ locale: 'de-DE',
+ path: '/'
+ });
+ });
+});
diff --git a/tests/navigation-tree.test.ts b/tests/navigation-tree.test.ts
index 2d6bf0d2..12d3ce50 100644
--- a/tests/navigation-tree.test.ts
+++ b/tests/navigation-tree.test.ts
@@ -2,6 +2,7 @@ import type { ContentNavigationItem } from '@nuxt/content';
import { describe, expect, it } from 'vitest';
import {
findByPath,
+ overlayTranslations,
sectionItems,
trailBelowPrefix
} from '../app/utils/navigation-tree';
@@ -159,3 +160,57 @@ describe('trailBelowPrefix', () => {
expect(trailBelowPrefix(branchTree, '/missing', '/workflows')).toEqual([]);
});
});
+
+describe('overlayTranslations', () => {
+ const tree = [
+ {
+ title: 'Get started',
+ path: '/getting-started',
+ children: [
+ { title: 'Introduction', path: '/getting-started' },
+ { title: 'Installation', path: '/getting-started/installation' }
+ ]
+ },
+ { title: 'Concepts', path: '/concepts' }
+ ];
+
+ it('keeps every page of the original, translated or not', () => {
+ const out = overlayTranslations(tree, new Map());
+
+ // The whole point: a translation with two pages must not shrink the
+ // sidebar to two entries — the rest is served by the fallback and has to
+ // stay reachable.
+ expect(out).toHaveLength(2);
+ expect(out[0]!.children).toHaveLength(2);
+ });
+
+ it('takes the translated title where there is one', () => {
+ const out = overlayTranslations(
+ tree,
+ new Map([['/getting-started', { title: 'Loslegen' }]])
+ );
+
+ expect(out[0]!.title).toBe('Loslegen');
+ // A page with no translation keeps the original wording rather than a gap.
+ expect(out[1]!.title).toBe('Concepts');
+ });
+
+ it('translates nested entries too', () => {
+ const out = overlayTranslations(
+ tree,
+ new Map([['/getting-started/installation', { title: 'Installation DE' }]])
+ );
+
+ expect(out[0]!.children![1]!.title).toBe('Installation DE');
+ });
+
+ it('leaves a title alone when the translation has none', () => {
+ const out = overlayTranslations(
+ tree,
+ new Map([['/concepts', { description: 'Nur die Beschreibung' }]])
+ );
+
+ expect(out[1]!.title).toBe('Concepts');
+ expect(out[1]!.description).toBe('Nur die Beschreibung');
+ });
+});
diff --git a/tests/page-icon.test.ts b/tests/page-icon.test.ts
new file mode 100644
index 00000000..af54d846
--- /dev/null
+++ b/tests/page-icon.test.ts
@@ -0,0 +1,75 @@
+import { describe, expect, it } from 'vitest';
+import { resolvePageIcon } from '../app/utils/navigation-tree';
+
+/**
+ * Three levels of precedence, and the one that matters is the middle one.
+ *
+ * A page states its icon in frontmatter and most do. Some cannot — an ADR's
+ * frontmatter is fixed at `title`, `description`, `status` and `date` — so a
+ * section standing in for its pages is what keeps a decision log from rendering
+ * eight rows with nothing beside them.
+ */
+const SECTIONS = [
+ { to: '/guides', pageIcon: 'lucide:book' },
+ { to: '/adr', pageIcon: 'lucide:gavel' },
+ // Deliberately without one: a section that sets no `pageIcon` falls through
+ // to the site's, it does not block it.
+ { to: '/concepts' },
+ // Nested, and listed BEFORE its parent to prove order does not decide it.
+ { to: '/guides/deep', pageIcon: 'lucide:layers' }
+];
+
+describe('resolvePageIcon', () => {
+ it("prefers the page's own icon over everything", () => {
+ expect(
+ resolvePageIcon(
+ { icon: 'lucide:star', path: '/adr/0001-a' },
+ SECTIONS,
+ 'lucide:file'
+ )
+ ).toBe('lucide:star');
+ });
+
+ it("falls back to the section's pageIcon", () => {
+ expect(
+ resolvePageIcon({ path: '/adr/0001-a' }, SECTIONS, 'lucide:file')
+ ).toBe('lucide:gavel');
+ });
+
+ it('falls back to the site-wide icon where the section sets none', () => {
+ expect(
+ resolvePageIcon({ path: '/concepts/layers' }, SECTIONS, 'lucide:file')
+ ).toBe('lucide:file');
+ });
+
+ it('answers undefined when nothing sets one', () => {
+ expect(
+ resolvePageIcon({ path: '/concepts/layers' }, SECTIONS, undefined)
+ ).toBeUndefined();
+ });
+
+ it('lets a nested section win over the one above it', () => {
+ expect(
+ resolvePageIcon({ path: '/guides/deep/page' }, SECTIONS, undefined)
+ ).toBe('lucide:layers');
+ });
+
+ it('matches no section for a path outside all of them', () => {
+ expect(resolvePageIcon({ path: '/credits' }, SECTIONS, 'lucide:file')).toBe(
+ 'lucide:file'
+ );
+ });
+
+ // An empty string is a value a config can hold and an `` cannot draw.
+ it('treats an empty icon as absent', () => {
+ expect(
+ resolvePageIcon({ icon: '', path: '/adr/0001-a' }, SECTIONS, undefined)
+ ).toBe('lucide:gavel');
+ });
+
+ it('survives a site with no sections at all', () => {
+ expect(
+ resolvePageIcon({ path: '/adr/0001-a' }, undefined, 'lucide:file')
+ ).toBe('lucide:file');
+ });
+});
diff --git a/tests/sources-locales.test.ts b/tests/sources-locales.test.ts
new file mode 100644
index 00000000..6d23b0a4
--- /dev/null
+++ b/tests/sources-locales.test.ts
@@ -0,0 +1,254 @@
+import { describe, expect, it } from 'vitest';
+import {
+ localeChain,
+ partialsCollection,
+ resolveSources,
+ sourcesForRoute
+} from '../sources-resolve';
+
+describe('translated sources', () => {
+ it('changes nothing for a site that declares no locales', () => {
+ const resolved = resolveSources([{ path: 'docs' }]);
+
+ // The whole feature is additive or it is a breaking change: the collection
+ // a plain site gets must still be called `docs`, at the root, with no
+ // locale attached to it.
+ expect(resolved).toHaveLength(1);
+ expect(resolved[0]).toMatchObject({
+ collection: 'docs',
+ prefix: '',
+ locale: undefined,
+ isDefaultLocale: true,
+ path: 'docs'
+ });
+ });
+
+ it('reads the default locale from the source folder itself', () => {
+ const resolved = resolveSources([
+ { path: 'docs', locales: ['en-GB', 'de-DE'] }
+ ]);
+
+ const [original, german] = resolved;
+
+ // No `en/` folder, and the name a single-language site already had.
+ expect(original).toMatchObject({
+ collection: 'docs',
+ path: 'docs',
+ locale: 'en-GB',
+ isDefaultLocale: true
+ });
+
+ expect(german).toMatchObject({
+ collection: 'docs_de_DE',
+ path: 'docs/de-DE',
+ locale: 'de-DE',
+ isDefaultLocale: false
+ });
+ });
+
+ it('keeps the locale out of the URL prefix', () => {
+ const resolved = resolveSources([
+ { path: 'docs', locales: ['en-GB', 'de-DE'] }
+ ]);
+
+ // i18n puts the locale in front of the path itself. A collection carrying
+ // it too would spell it twice in one URL — and identical content paths are
+ // what make the fallback a second query for the same path.
+ expect(resolved.every((source) => source.prefix === '')).toBe(true);
+ });
+
+ it('lets a ref override the languages its version is kept in', () => {
+ const resolved = resolveSources(
+ [
+ {
+ path: 'docs',
+ locales: ['en-GB', 'de-DE'],
+ refs: [{ tag: 'v2' }, { tag: 'v1', locales: ['en-GB'] }]
+ }
+ ],
+ { defaultLocale: 'en-GB', defaultRef: 'v2' }
+ );
+
+ // v2 in both languages, v1 only in the original: the common case, because
+ // nobody keeps the translation of a version they no longer develop.
+ expect(
+ resolved.map((source) => [source.collection, source.locale])
+ ).toEqual([
+ ['docs', 'en-GB'],
+ ['docs_de_DE', 'de-DE'],
+ ['docs_v1', 'en-GB']
+ ]);
+ });
+
+ it('takes a translation from a repository of its own', () => {
+ const [, german] = resolveSources([
+ {
+ path: 'docs',
+ locales: [
+ 'en-GB',
+ { locale: 'de-DE', repo: 'acme/docs-de', path: 'docs' }
+ ]
+ }
+ ]);
+
+ // What React and Vue do outside their tooling — a translation repository
+ // with its own maintainers — as one source entry.
+ expect(german).toMatchObject({
+ locale: 'de-DE',
+ path: 'docs',
+ repository: 'acme/docs-de'
+ });
+ });
+
+ it('does not treat two languages on one prefix as a collision', () => {
+ // Same prefix, twice, is the point rather than the error the resolver
+ // throws for two sources claiming one URL.
+ expect(() =>
+ resolveSources([{ path: 'docs', locales: ['en-GB', 'de-DE'] }])
+ ).not.toThrow();
+ });
+});
+
+describe('localeChain', () => {
+ const available = ['en-GB', 'de-DE', 'pt-PT', 'pt-BR'];
+
+ it('reads its own language first', () => {
+ expect(localeChain('de-DE', available)[0]).toBe('de-DE');
+ });
+
+ it('falls back to a sibling region before leaving the language', () => {
+ // pt-BR reads European Portuguese before it gives up and shows English —
+ // the rule the locale FILES already follow in nuxt.config.
+ expect(localeChain('pt-BR', ['pt-PT', undefined], 'en-GB')).toEqual([
+ 'pt-PT',
+ undefined
+ ]);
+ });
+
+ it('reads a base-language folder for a regional locale', () => {
+ // One `pt/` folder serves both Portuguese locales.
+ expect(localeChain('pt-BR', ['pt', undefined])).toEqual(['pt', undefined]);
+ });
+
+ it('uses vue-i18n’s own fallbackLocale before the original', () => {
+ expect(localeChain('fr-FR', ['en-GB', undefined], 'en-GB')).toEqual([
+ 'en-GB',
+ undefined
+ ]);
+ });
+
+ it('always ends at the untranslated original', () => {
+ // A page that exists in no translation still has to render.
+ for (const locale of [undefined, 'de-DE', 'zz-ZZ']) {
+ expect(localeChain(locale, available.concat(undefined)).at(-1)).toBe(
+ undefined
+ );
+ }
+ });
+
+ it('never repeats a locale', () => {
+ const chain = localeChain('pt-PT', ['pt-PT', undefined], 'pt-PT');
+ expect(chain).toEqual(['pt-PT', undefined]);
+ });
+});
+
+describe('sourcesForRoute', () => {
+ const resolved = resolveSources(
+ [
+ {
+ path: 'docs',
+ repo: 'acme/sdk',
+ refs: [
+ { branch: 'main', label: 'v2' },
+ { tag: 'v1.9.4', label: 'v1.9' }
+ ],
+ locales: ['en', 'de']
+ },
+ { path: 'docs', repo: 'acme/cli' }
+ ],
+ { defaultLocale: 'en' }
+ );
+
+ const collections = (path: string, locale?: string) =>
+ sourcesForRoute(path, locale, resolved, 'en').map(
+ (source) => source.collection
+ );
+
+ it('picks the language, not whichever collection sorted first', () => {
+ // The bug this closes: original and translation share a prefix, so a
+ // lookup given only the path answered with the original every time.
+ expect(collections('/sdk/guides/caching', 'de')[0]).toBe('docs_de_sdk');
+ expect(collections('/sdk/guides/caching', 'en')[0]).toBe('docs_sdk');
+ });
+
+ it('still takes the longest prefix', () => {
+ expect(collections('/sdk/v1.9/guides/caching', 'de')[0]).toBe(
+ 'docs_de_sdk_v1_9'
+ );
+ });
+
+ it('falls back to the untranslated original, once', () => {
+ // `fallbackLocale` and the end of the chain are the same collection here,
+ // and naming it twice made the path debugger print "docs → docs".
+ expect(collections('/sdk/guides/caching', 'de')).toEqual([
+ 'docs_de_sdk',
+ 'docs_sdk'
+ ]);
+ });
+
+ it('serves a language the site does not carry from the original', () => {
+ expect(collections('/sdk/guides/caching', 'fr')).toEqual(['docs_sdk']);
+ });
+
+ it('is segment-aware, like every other prefix comparison', () => {
+ // `/cli` must not claim `/climate` — the collection would exist and the
+ // page would simply never be found in it.
+ expect(collections('/sdk-old/guide')[0]).not.toBe('docs_cli');
+ });
+
+ it('never answers with nothing', () => {
+ // An empty chain means no query at all, which 404s a page that exists.
+ expect(collections('/nowhere/at/all').length).toBeGreaterThan(0);
+ expect(sourcesForRoute('/x', 'de', [])).toEqual([]);
+ });
+});
+
+describe('partialsCollection', () => {
+ it('keeps the bare name for the default language', () => {
+ // Public surface: a single-language site's partials collection must go on
+ // being called what it has always been called.
+ expect(partialsCollection()).toBe('duxt_partials');
+ expect(partialsCollection({ locale: 'de', isDefaultLocale: true })).toBe(
+ 'duxt_partials'
+ );
+ });
+
+ it('appends the code for a translation', () => {
+ expect(partialsCollection({ locale: 'de', isDefaultLocale: false })).toBe(
+ 'duxt_partials_de'
+ );
+ expect(
+ partialsCollection({ locale: 'pt-BR', isDefaultLocale: false })
+ ).toBe('duxt_partials_pt_BR');
+ });
+
+ it('names one collection per language, not per version', () => {
+ // A partial is a block of prose: three versions of one repository share it,
+ // and reading them into one collection would give three blocks per name.
+ const resolved = resolveSources(
+ [
+ {
+ path: 'docs',
+ repo: 'acme/sdk',
+ refs: [{ branch: 'main' }, { tag: 'v1.9.4' }],
+ locales: ['en', 'de']
+ }
+ ],
+ { defaultLocale: 'en' }
+ );
+
+ expect(new Set(resolved.map((entry) => partialsCollection(entry)))).toEqual(
+ new Set(['duxt_partials', 'duxt_partials_de'])
+ );
+ });
+});
diff --git a/tests/validate.test.ts b/tests/validate.test.ts
index b11260d2..76eb7306 100644
--- a/tests/validate.test.ts
+++ b/tests/validate.test.ts
@@ -85,3 +85,142 @@ describe('report', () => {
expect(warnings[0]).toMatch(/missing/);
});
});
+
+describe('links on a translated site', () => {
+ const sources = [
+ { collection: 'docs', prefix: '', isDefaultLocale: true, locale: 'en' },
+ { collection: 'docs_de', prefix: '', isDefaultLocale: false, locale: 'de' }
+ ];
+
+ it('checks an anchor against the linking page own language', () => {
+ // Anchors come from heading TEXT, so a German page has German anchors.
+ // Resolving by path alone picks whichever language was parsed last and
+ // reports every correct link on one of the two.
+ const { warnings } = report(sources, [
+ page({
+ collection: 'docs',
+ path: '/concepts/localisation',
+ file: 'docs/localisation.md',
+ anchors: new Set(['paths-and-links'])
+ }),
+ page({
+ collection: 'docs_de',
+ path: '/concepts/localisation',
+ file: 'docs/de/localisation.md',
+ anchors: new Set(['pfade-und-links'])
+ }),
+ page({
+ collection: 'docs_de',
+ path: '/guide',
+ file: 'docs/de/guide.md',
+ links: [{ href: '/concepts/localisation#pfade-und-links' }]
+ })
+ ]);
+
+ expect(warnings).toEqual([]);
+ });
+
+ it('still reports an anchor no language has', () => {
+ const { warnings } = report(sources, [
+ page({
+ collection: 'docs',
+ path: '/concepts/localisation',
+ file: 'docs/localisation.md',
+ anchors: new Set(['paths-and-links'])
+ }),
+ page({
+ collection: 'docs_de',
+ path: '/concepts/localisation',
+ file: 'docs/de/localisation.md',
+ anchors: new Set(['pfade-und-links'])
+ }),
+ page({
+ collection: 'docs_de',
+ path: '/guide',
+ file: 'docs/de/guide.md',
+ links: [{ href: '/concepts/localisation#gibt-es-nicht' }]
+ })
+ ]);
+
+ expect(warnings).toHaveLength(1);
+ expect(warnings[0]).toMatch(/gibt-es-nicht/);
+ });
+
+ it('falls back to the original for a page a language does not carry', () => {
+ // What the site itself does — so a link to an untranslated page is not a
+ // broken link, and must not be reported as one.
+ const { warnings } = report(sources, [
+ page({
+ collection: 'docs',
+ path: '/only-english',
+ file: 'docs/only-english.md',
+ anchors: new Set(['setup'])
+ }),
+ page({
+ collection: 'docs_de',
+ path: '/guide',
+ file: 'docs/de/guide.md',
+ links: [{ href: '/only-english#setup' }]
+ })
+ ]);
+
+ expect(warnings).toEqual([]);
+ });
+});
+
+describe('the translation report', () => {
+ const sources = [
+ { collection: 'docs', prefix: '', isDefaultLocale: true, locale: 'en' },
+ { collection: 'docs_de', prefix: '', isDefaultLocale: false, locale: 'de' }
+ ];
+
+ it('says nothing at all on a site with one language', () => {
+ const { notes } = report([{ collection: 'docs', prefix: '' }], [page({})]);
+
+ expect(notes).toEqual([]);
+ });
+
+ it('counts what a language carries and names what it does not', () => {
+ const { notes } = report(sources, [
+ page({ collection: 'docs', path: '/a', file: 'docs/a.md' }),
+ page({ collection: 'docs', path: '/b', file: 'docs/b.md' }),
+ page({ collection: 'docs_de', path: '/a', file: 'docs/de/a.md' })
+ ]);
+
+ expect(notes[0]).toBe('de: 1/2 pages');
+ expect(notes[1]).toMatch(/docs\/b\.md.*no de translation/);
+ });
+
+ it('reports a translation the original has moved past', () => {
+ // The OpenCode case: the files are all there, and none of them has moved
+ // since the workflow was switched off.
+ const { notes } = report(sources, [
+ page({
+ collection: 'docs',
+ path: '/a',
+ file: 'docs/a.md',
+ lastUpdated: '2026-09-01T00:00:00Z'
+ }),
+ page({
+ collection: 'docs_de',
+ path: '/a',
+ file: 'docs/de/a.md',
+ lastUpdated: '2026-06-01T00:00:00Z'
+ })
+ ]);
+
+ expect(notes[0]).toBe('de: 1/1 pages, 1 behind the original');
+ expect(notes[1]).toMatch(/docs\/de\/a\.md.*has not moved/);
+ });
+
+ it('says nothing about staleness with no history to read', () => {
+ // A source without `history: true` has no dates at all, and guessing from
+ // their absence would report every page of every language as stale.
+ const { notes } = report(sources, [
+ page({ collection: 'docs', path: '/a', file: 'docs/a.md' }),
+ page({ collection: 'docs_de', path: '/a', file: 'docs/de/a.md' })
+ ]);
+
+ expect(notes).toEqual(['de: 1/1 pages']);
+ });
+});
diff --git a/validate-report.ts b/validate-report.ts
index b96e8102..1b3be308 100644
--- a/validate-report.ts
+++ b/validate-report.ts
@@ -18,6 +18,22 @@ export interface PageRecord {
description?: string;
anchors: Set;
links: { href: string }[];
+ /**
+ * When the file last changed, as `git-meta` read it off the history.
+ *
+ * Absent for a source that did not ask for its history, and for one whose
+ * clone could not be deepened — which is why the staleness half of the
+ * translation report is skipped rather than guessed when it is missing.
+ */
+ lastUpdated?: string;
+}
+
+/** The manifest, as much of it as the checks read. */
+export interface SourceRecord {
+ collection: string;
+ prefix: string;
+ locale?: string;
+ isDefaultLocale?: boolean;
}
/** Collect anchor ids and internal links out of a parsed MDC body. */
@@ -49,9 +65,9 @@ export function walk(
}
export function report(
- sources: { collection: string; prefix: string }[],
+ sources: SourceRecord[],
pages: PageRecord[]
-): { errors: string[]; warnings: string[] } {
+): { errors: string[]; warnings: string[]; notes: string[] } {
const errors: string[] = [];
const warnings: string[] = [];
@@ -117,10 +133,42 @@ export function report(
// path written in a page is relative to that page's OWN source, so it is
// tried under the source's prefix first and bare second. Checking only the
// bare form reports every correct link on a prefixed site.
- const known = new Map(pages.map((page) => [page.path, page]));
+ //
+ // PER LANGUAGE, and that is not a refinement. Every language of one source
+ // serves IDENTICAL content paths — the locale lives in front of the URL,
+ // not in the content tree — so a map keyed by path alone holds one page per
+ // path and whichever language was parsed last wins. Anchors are derived
+ // from heading TEXT, so that map would check a German link against English
+ // headings and report every correct anchor on a translated site. A link is
+ // therefore resolved in the linking page's OWN collection first, and in the
+ // untranslated original second — which is where the site itself falls back
+ // when a language does not carry the page.
+ const known = new Map();
+ for (const page of pages) {
+ const list = known.get(page.path) ?? [];
+ list.push(page);
+ known.set(page.path, list);
+ }
+
const prefixOf = new Map(
sources.map((source) => [source.collection, source.prefix])
);
+ const originals = new Set(
+ sources
+ .filter((source) => !source.locale || source.isDefaultLocale)
+ .map((source) => source.collection)
+ );
+
+ const resolve = (path: string, from: PageRecord) => {
+ const candidates = known.get(stripTrailingSlash(path));
+ if (!candidates) return undefined;
+
+ return (
+ candidates.find((page) => page.collection === from.collection) ??
+ candidates.find((page) => originals.has(page.collection)) ??
+ candidates[0]
+ );
+ };
for (const page of pages) {
const prefix = prefixOf.get(page.collection) ?? '';
@@ -131,8 +179,7 @@ export function report(
const [target, anchor] = href.split('#');
const destination = target
- ? (known.get(stripTrailingSlash(`${prefix}${target}`)) ??
- known.get(stripTrailingSlash(target)))
+ ? (resolve(`${prefix}${target}`, page) ?? resolve(target, page))
: page;
if (target && !destination) {
@@ -150,8 +197,99 @@ export function report(
}
}
- return { errors, warnings };
+ return { errors, warnings, notes: translationNotes(sources, byCollection) };
}
const stripTrailingSlash = (path: string) =>
path.length > 1 ? path.replace(/\/+$/, '') : path;
+
+/**
+ * What each language carries, and what has stood still.
+ *
+ * Notes rather than warnings: an untranslated page is not a defect, it is a
+ * state — and one nothing else in this layer ever says out loud. The layer
+ * knows, per page, which languages carry it and when each file last changed;
+ * without a report that knowledge sits in the manifest and reaches nobody.
+ *
+ * OpenCode is the cautionary case, and the reason this is a build report rather
+ * than a dashboard: seventeen languages, an agent that kept them in sync, the
+ * workflow switched off, and nothing anywhere saying the translations had
+ * stopped moving. A line per language in every build is what makes that
+ * visible on the day it happens instead of a year later.
+ *
+ * Two findings, and the second is skipped rather than guessed when the data is
+ * missing: a page the original has and a translation does not, and a
+ * translation whose file has not moved since the original's did. `lastUpdated`
+ * comes from `git-meta`, which a source without `history: true` never gets.
+ */
+function translationNotes(
+ sources: SourceRecord[],
+ byCollection: Map
+): string[] {
+ const notes: string[] = [];
+
+ // Grouped by PREFIX, because that is what a translation shares with its
+ // original — the locale is never part of a content path.
+ const byPrefix = new Map();
+ for (const source of sources) {
+ const list = byPrefix.get(source.prefix) ?? [];
+ list.push(source);
+ byPrefix.set(source.prefix, list);
+ }
+
+ for (const [prefix, group] of byPrefix) {
+ const original = group.find(
+ (source) => !source.locale || source.isDefaultLocale
+ );
+ const translations = group.filter((source) => source !== original);
+
+ if (!original || !translations.length) continue;
+
+ const base = byCollection.get(original.collection) ?? [];
+ const dates = new Map(
+ base.map((page) => [page.path, page.lastUpdated] as const)
+ );
+
+ for (const translation of translations) {
+ const pages = byCollection.get(translation.collection) ?? [];
+ const have = new Map(pages.map((page) => [page.path, page] as const));
+
+ const missing = base.filter((page) => !have.has(page.path));
+
+ const stale = pages.filter((page) => {
+ const source = dates.get(page.path);
+ return (
+ source &&
+ page.lastUpdated &&
+ Date.parse(page.lastUpdated) < Date.parse(source)
+ );
+ });
+
+ const where = prefix ? ` under "${prefix}"` : '';
+
+ notes.push(
+ `${translation.locale}${where}: ${have.size}/${base.length} pages` +
+ (stale.length ? `, ${stale.length} behind the original` : '')
+ );
+
+ // Named, not just counted — a figure says a translation has drifted and
+ // a file name says where to start. Capped, because a language nobody has
+ // begun would otherwise print the whole tree.
+ for (const page of [...missing, ...stale].slice(0, NAMED)) {
+ notes.push(
+ missing.includes(page)
+ ? ` "${page.file}" has no ${translation.locale} translation.`
+ : ` "${page.file}" has not moved since the original changed.`
+ );
+ }
+
+ const rest = missing.length + stale.length - NAMED;
+ if (rest > 0) notes.push(` … and ${rest} more.`);
+ }
+ }
+
+ return notes;
+}
+
+/** How many files a language names before the report starts counting instead. */
+const NAMED = 10;
diff --git a/www/.env.example b/www/.env.example
new file mode 100644
index 00000000..0b9b910b
--- /dev/null
+++ b/www/.env.example
@@ -0,0 +1,8 @@
+# ── Nuxt public site URL ─────────────────────────────────────────────────────
+NUXT_PUBLIC_I18N_BASE_URL=http://localhost:3000
+
+# ── Open Graph images (npx nuxt-og-image generate-secret) ────────────────────
+NUXT_OG_IMAGE_SECRET=
+
+# ── Dev over a public tunnel (any value switches HMR to wss:443) ─────────────
+DUXT_TUNNEL=
diff --git a/www/app/app.config.ts b/www/app/app.config.ts
index 643458a4..330f674f 100644
--- a/www/app/app.config.ts
+++ b/www/app/app.config.ts
@@ -6,6 +6,30 @@
// to the template, so the layer ships the row empty and kirchDev fills it here.
export default defineAppConfig({
duxt: {
+ /**
+ * The site's own name. The layer ships no `title` of its own: a name is the
+ * one thing a documentation theme cannot guess, and "duxt" in every
+ * downstream header was exactly that mistake.
+ *
+ * A plain literal, not a record: the word is the same in every language —
+ * the rule that also keeps `'GitHub'` and `'shadcn-vue'` below unwrapped.
+ */
+ title: 'duxt',
+
+ /**
+ * This site's own wordmark, set exactly the way a consumer sets theirs.
+ *
+ * The layer ships no `logo` at all, so `DuxtBrand` falls back to a generic
+ * icon beside `title` for anyone who does not set one. Serving duxt's mark
+ * from the layer would put it in every downstream header, which is the one
+ * thing a documentation theme must not do.
+ */
+ logo: {
+ src: '/wordmark.svg',
+ srcDark: '/wordmark-dark.svg',
+ alt: 'duxt'
+ },
+
/**
* One source, and therefore no prefixes: with a single repository and a
* single ref the resolver serves `/getting-started` rather than
@@ -19,13 +43,130 @@ export default defineAppConfig({
// what `repo` would do, and it would clone the checkout we stand in.
{
path: 'docs',
- origin: { repo: 'kirchDev/duxt', ref: 'main' }
+ origin: { repo: 'kirchDev/duxt', ref: 'main' },
+
+ // FOUR languages over one tree, named by LANGUAGE rather than locale.
+ // `docs/pt/` serves both `pt-PT` and `pt-BR`, and `en-US` reads the
+ // original through `fallbackLocale` — the same rule the locale FILES in
+ // nuxt.config already follow, so seven locales need four folders.
+ //
+ // `en-GB` is the tree in `docs/` itself and takes no folder of its own,
+ // which keeps every URL this site already serves where it is.
+ locales: ['en-GB', 'de', 'es', 'fr', 'pt']
}
],
+ sourceOptions: { defaultLocale: 'en-GB' },
// The feed, pointed at a section that has dated entries. Off by default in
// the layer; this site turns it on so the route is exercised.
feed: { path: '/adr', title: 'duxt — decisions' },
+ // Written out in full, both entries: `mergeDuxtConfig` REPLACES an array
+ // rather than merging into it, so naming `navigation` at all means naming
+ // every entry. The layer ships only the generic Docs entry; the Resources
+ // dropdown below and the Credits link after it are duxt's own — five links
+ // to duxt's tech stack and the page that thanks them, which belong to this
+ // site and not to a stranger's header.
+ //
+ // The Docs entry carries no `to` on purpose. `DuxtHeader.linkTarget`
+ // resolves it to the first section, and `entryActive` lights it wherever
+ // ANY section is — giving it an explicit `to` would narrow the highlight
+ // back to one section.
+ navigation: [
+ { label: 'Docs', icon: 'lucide:book-open-text' },
+ {
+ label: {
+ 'en-GB': 'Resources',
+ 'de-DE': 'Ressourcen',
+ 'es-ES': 'Recursos',
+ 'fr-FR': 'Ressources',
+ 'pt-PT': 'Recursos'
+ },
+ icon: 'lucide:library',
+ children: [
+ {
+ label: 'Nuxt',
+ to: 'https://nuxt.com',
+ icon: 'lucide:box',
+ description: {
+ 'en-GB': 'The framework underneath',
+ 'de-DE': 'Das Framework darunter',
+ 'es-ES': 'El framework de base',
+ 'fr-FR': 'Le framework sous-jacent',
+ 'pt-PT': 'A framework subjacente'
+ },
+ external: true
+ },
+ {
+ label: 'Nuxt Content',
+ to: 'https://content.nuxt.com',
+ icon: 'lucide:file-text',
+ description: {
+ 'en-GB': 'Sourcing, parsing and querying',
+ 'de-DE': 'Beschaffen, parsen und abfragen',
+ 'es-ES': 'Obtención, análisis y consulta',
+ 'fr-FR': 'Récupération, analyse et requêtes',
+ 'pt-PT': 'Obtenção, análise e consulta'
+ },
+ external: true
+ },
+ {
+ label: 'shadcn-vue',
+ to: 'https://www.shadcn-vue.com',
+ icon: 'lucide:palette',
+ description: {
+ 'en-GB': 'The component base',
+ 'de-DE': 'Die Komponentenbasis',
+ 'es-ES': 'La base de componentes',
+ 'fr-FR': 'La base de composants',
+ 'pt-PT': 'A base de componentes'
+ },
+ external: true
+ },
+ {
+ label: 'Tailwind CSS',
+ to: 'https://tailwindcss.com',
+ icon: 'lucide:paintbrush',
+ description: {
+ 'en-GB': 'The styling system',
+ 'de-DE': 'Das Styling-System',
+ 'es-ES': 'El sistema de estilos',
+ 'fr-FR': 'Le système de styles',
+ 'pt-PT': 'O sistema de estilos'
+ },
+ external: true
+ },
+ {
+ label: 'MDC syntax',
+ to: 'https://content.nuxt.com/docs/files/markdown',
+ icon: 'lucide:code',
+ description: {
+ 'en-GB': 'Components inside Markdown',
+ 'de-DE': 'Komponenten in Markdown',
+ 'es-ES': 'Componentes dentro de Markdown',
+ 'fr-FR': 'Des composants dans le Markdown',
+ 'pt-PT': 'Componentes dentro do Markdown'
+ },
+ external: true
+ }
+ ]
+ },
+ // A navbar entry of its own rather than an item inside the dropdown: a
+ // link buried in a menu is a link nobody opens the menu for, and this one
+ // is a page of the site while the five above leave it. It sits after
+ // Resources because it is the smaller thing.
+ {
+ label: {
+ 'en-GB': 'Credits',
+ 'de-DE': 'Credits',
+ 'es-ES': 'Créditos',
+ 'fr-FR': 'Crédits',
+ 'pt-PT': 'Créditos'
+ },
+ to: '/credits',
+ icon: 'lucide:heart'
+ }
+ ],
+
// The source carries a slug, so every path has a repository segment and the
// navigation the layer ships — which assumes a single unprefixed source —
// no longer matches. A consumer with prefixes has to name its own.
@@ -86,14 +227,37 @@ export default defineAppConfig({
},
{
label: {
- 'en-GB': 'Credits',
- 'de-DE': 'Credits',
- 'es-ES': 'Créditos',
- 'fr-FR': 'Crédits',
- 'pt-PT': 'Créditos'
+ 'en-GB': 'Conventions',
+ 'de-DE': 'Konventionen',
+ 'es-ES': 'Convenciones',
+ 'fr-FR': 'Conventions',
+ 'pt-PT': 'Convenções'
},
- to: '/credits',
- icon: 'lucide:heart'
+ to: '/conventions',
+ icon: 'lucide:ruler'
+ },
+ {
+ // Last, and deliberately so: the decision log is an appendix rather
+ // than reading order — which is what `99.` encodes in the folder name.
+ // It earns a row anyway, because a section the row omits falls back to
+ // the branch it is in, and a reader who lands there has no way back up.
+ label: {
+ 'en-GB': 'Architecture decisions',
+ 'de-DE': 'Architekturentscheidungen',
+ 'es-ES': 'Decisiones de arquitectura',
+ 'fr-FR': "Décisions d'architecture",
+ 'pt-PT': 'Decisões de arquitetura'
+ },
+ to: '/adr',
+ icon: 'lucide:gavel',
+ // A document, not a second gavel. An ADR's frontmatter is fixed at
+ // `title`, `description`, `status` and `date`, so the records carry no
+ // icon of their own and the log rendered as eight bare rows beside
+ // sections that all have a column of them. The section is the decision
+ // log — the gavel above says so; each row in it is one written record,
+ // and repeating the section's own symbol on every child would say
+ // nothing the heading has not already said.
+ pageIcon: 'lucide:file-text'
}
],
@@ -168,13 +332,16 @@ export default defineAppConfig({
icon: 'lucide:message-circle',
external: true
},
- // An internal link, deliberately beside three external ones: `to` is
- // run through `useDuxtLink`, so it picks up the locale prefix while
- // the absolute URLs above pass through untouched. `external` only
- // decides the new tab and the arrow, never the routing.
+ // duxt's published documentation, by its own domain rather than by a
+ // path on whatever site is rendering this. That is what makes the entry
+ // mean anything in the row it sits in: the three links above take the
+ // reader to duxt's repository, its tracker and its community, and a
+ // relative `/getting-started` took them back to the page they were
+ // already on.
//
- // TODO: point at duxt's own published documentation once it is
- // deployed. It links to this same site today, which is circular.
+ // Absolute, so `useDuxtLink` passes it through untouched instead of
+ // prefixing a locale onto it. `external` only decides the new tab and
+ // the arrow, never the routing.
{
label: {
'en-GB': 'Documentation',
@@ -183,17 +350,17 @@ export default defineAppConfig({
'fr-FR': 'Documentation',
'pt-PT': 'Documentação'
},
- to: '/getting-started',
- icon: 'lucide:book-open-text'
+ to: 'https://duxt.app/',
+ icon: 'lucide:book-open-text',
+ external: true
}
]
},
landing: {
- // `{version}` reads `duxt.version`, so the number is never typed twice:
- // release-please bumps `package.json`, and a copy written out here would
- // be wrong from the next release onwards. The pill links to the release
- // it names.
+ // `{version}` reads this site's `version`, which `nuxt.config.ts` reads
+ // out of duxt's own `package.json` — see the note there. Nothing to bump
+ // in either file. The pill links to the release it names.
badge: {
label: {
'en-GB': '{version} released',
@@ -203,11 +370,52 @@ export default defineAppConfig({
'pt-PT': '{version} publicada'
},
icon: 'lucide:rocket',
- variant: 'success',
+ // The accent, not the success colour: this pill announces a release,
+ // it does not report that something went well. Green reads as a status
+ // and pulls harder than the headline underneath it.
+ variant: 'default',
to: 'https://github.com/kirchDev/duxt/releases/latest',
external: true
},
+ /**
+ * The hero copy, written out here rather than inherited.
+ *
+ * The layer used to ship both as `duxt.defaults.landing.*` keys, which
+ * made duxt's own marketing the default headline of every site that
+ * extended it. `duxt.defaults.*` translates the interface the layer
+ * draws, never content a site writes.
+ *
+ * The record form rather than a locale file, for the reason the sections
+ * above give — and for one more: `llms.txt`, `llms-full.txt` and the feed
+ * are Nitro routes with no i18n, and `resolveServerTexts` reads only the
+ * LAYER's English messages. A key in a `www/i18n/` file would be printed
+ * to a model verbatim; a record resolves to its `en-GB` entry there.
+ */
+ headline: {
+ 'en-GB': 'Documentation for Nuxt, versioned and multi-repo',
+ 'de-DE':
+ 'Dokumentation für Nuxt, versioniert und über mehrere Repositories',
+ 'es-ES': 'Documentación para Nuxt, versionada y multirrepositorio',
+ 'fr-FR': 'Documentation pour Nuxt, versionnée et multidépôt',
+ 'pt-PT': 'Documentação para Nuxt, com versões e vários repositórios'
+ },
+
+ // Also the ` `, the llms.txt blurb and the RSS
+ // channel description — one sentence, four readers.
+ description: {
+ 'en-GB':
+ 'Extend one layer and your docs/ folder becomes a site. Point it at other repositories, or at tags of the same one, and those become versions.',
+ 'de-DE':
+ 'Einen Layer erweitern, und dein docs/-Ordner wird zur Website. Zeig damit auf andere Repositories oder auf Tags desselben, und daraus werden Versionen.',
+ 'es-ES':
+ 'Extiende una capa y tu carpeta docs/ se convierte en un sitio. Apúntala a otros repositorios, o a etiquetas del mismo, y estos se convierten en versiones.',
+ 'fr-FR':
+ "Étendez une couche et votre dossier docs/ devient un site. Pointez-la vers d'autres dépôts, ou vers des tags du même, et ceux-ci deviennent des versions.",
+ 'pt-PT':
+ 'Estenda uma camada e a sua pasta docs/ torna-se um site. Aponte-a para outros repositórios, ou para tags do mesmo, e estes tornam-se versões.'
+ },
+
// The window under the hero: this site's own getting-started page,
// embedded and operable. `to` stays inside the site — see DuxtPreview.
preview: { to: '/getting-started' },
diff --git a/www/app/assets/css/brand.css b/www/app/assets/css/brand.css
new file mode 100644
index 00000000..dca1d17e
--- /dev/null
+++ b/www/app/assets/css/brand.css
@@ -0,0 +1,42 @@
+/*
+ * This site's accent colour, and nothing else.
+ *
+ * The layer ships the neutral shadcn palette on purpose — a site extending duxt
+ * gets grey and tints it itself. This file is that tinting, done the way
+ * `duxt.css` says a consumer should do it: redefine the token, never fork the
+ * file. Loaded after the layer's stylesheet through `css` in nuxt.config, so
+ * equal specificity resolves in this file's favour on source order alone.
+ *
+ * The two values are the wordmark's, not a second opinion about them: the
+ * bracket colour from `.github/assets/wordmark-light.svg` and its lifted twin
+ * from the dark one. Logo and interface therefore cannot drift apart.
+ *
+ * A PLAIN `:root` is enough, and that is the layer's doing rather than luck:
+ * `duxt.css` puts its palette in an anonymous cascade layer, and unlayered CSS
+ * beats layered CSS whatever the source order. So this file wins even though
+ * the layer's stylesheet is linked after it — which it is, twice over.
+ *
+ * MEASURED, because nothing here checks it. `tests/contrast.test.ts` reads the
+ * LAYER's palette, and `check:a11y` runs axe over jsdom, which has no computed
+ * colour and reports `color-contrast` as skipped — so these two tokens sit
+ * outside both gates:
+ *
+ * light #4F46E5 white on it 6.28:1 · as text on the page 6.28:1
+ * dark #7D7BF5 dark text on it 5.14:1 · as text on the page 5.68:1
+ *
+ * All four clear AA for body text. Anyone changing a value here re-measures it.
+ */
+:root {
+ --primary: oklch(0.511 0.23 277);
+ --primary-foreground: oklch(1 0 0);
+ --ring: oklch(0.511 0.23 277);
+}
+
+.dark {
+ /* Lifted rather than reused: the same indigo that reads on white is too dark
+ to carry a label on a near-black surface, and shadcn's dark mode expects a
+ LIGHT `--primary` with dark text on it. */
+ --primary: oklch(0.644 0.178 281);
+ --primary-foreground: oklch(0.205 0 0);
+ --ring: oklch(0.644 0.178 281);
+}
diff --git a/www/nuxt.config.ts b/www/nuxt.config.ts
index f38d8ca7..757b4714 100644
--- a/www/nuxt.config.ts
+++ b/www/nuxt.config.ts
@@ -1,5 +1,37 @@
+import { readFileSync } from 'node:fs';
import { defineNuxtConfig } from 'nuxt/config';
+/**
+ * The version this site documents, read rather than typed.
+ *
+ * duxt's own `package.json`, one directory up — the file release-please bumps.
+ * A number written into `app.config.ts` would be a second copy of it, wrong
+ * from the first release onwards, which is the mistake the layer default
+ * `version: 'v0.0.0'` made for every site that extended it.
+ *
+ * It goes through `appConfig` here rather than `app.config.ts` because that
+ * file is a RUNTIME module — it is compiled for the browser and cannot read a
+ * file. `nuxt.config.ts` is build code, so it can. The generated template
+ * merges `appConfig` BEHIND `app.config.ts`, so this stands only as long as no
+ * human writes a `version` there, which is the right way round.
+ *
+ * The `v` is added here: `package.json` holds `0.0.0`, and every place duxt
+ * shows a version prefixes it — `DuxtFooter` does the same.
+ */
+const version = (() => {
+ try {
+ const pkg = JSON.parse(
+ readFileSync(new URL('../package.json', import.meta.url), 'utf8')
+ ) as { version?: string };
+
+ return pkg.version ? `v${pkg.version}` : undefined;
+ } catch {
+ // Absent rather than guessed: the badge and the hero pill both draw
+ // nothing without it, which beats a number nobody can trace.
+ return undefined;
+ }
+})();
+
// Consumes the layer exactly as a downstream repo does. Modules, the Content
// driver and the theme all arrive with the extend.
export default defineNuxtConfig({
@@ -8,6 +40,29 @@ export default defineNuxtConfig({
extends: ['@kirchdev/duxt'],
compatibilityDate: '2026-09-02',
+ appConfig: { duxt: { version } },
+
+ // Appended to the layer's own entry, never replacing it: Nuxt concatenates
+ // `css` with the extending app's last, which is exactly the order these
+ // overrides need — same specificity, later wins.
+ css: ['~/assets/css/brand.css'],
+
+ // This site's own branding, not the layer's. The layer stays unbranded on
+ // purpose — a consumer extending it wants their own mark in the tab, so the
+ // icons live here in the consuming site rather than in the published package.
+ //
+ // The SVG carries the whole job: one file, sharp at every size, and its own
+ // `prefers-color-scheme` rule inside so the mark lightens against a dark tab
+ // strip. The PNG exists only because iOS ignores SVG icons.
+ app: {
+ head: {
+ link: [
+ { rel: 'icon', type: 'image/svg+xml', href: '/favicon.svg' },
+ { rel: 'apple-touch-icon', href: '/apple-touch-icon.png' }
+ ]
+ }
+ },
+
// Dev over a public tunnel: the HMR client otherwise dials ws://localhost,
// which a phone on the other side of the tunnel cannot reach — the page
// loads and then never updates. Only when the variable is set, so a normal
diff --git a/www/public/apple-touch-icon.png b/www/public/apple-touch-icon.png
new file mode 100644
index 00000000..f0ddf1ee
Binary files /dev/null and b/www/public/apple-touch-icon.png differ
diff --git a/www/public/favicon.svg b/www/public/favicon.svg
new file mode 100644
index 00000000..d8d704df
--- /dev/null
+++ b/www/public/favicon.svg
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
diff --git a/www/public/wordmark-dark.svg b/www/public/wordmark-dark.svg
new file mode 100644
index 00000000..d77cc4aa
--- /dev/null
+++ b/www/public/wordmark-dark.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/www/public/wordmark.svg b/www/public/wordmark.svg
new file mode 100644
index 00000000..827832d8
--- /dev/null
+++ b/www/public/wordmark.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+