From 3487d48734f61c57a8fafc8f2433bf26a8e1246a Mon Sep 17 00:00:00 2001 From: Titus Kirch Date: Tue, 15 Sep 2026 01:31:30 +0200 Subject: [PATCH 01/15] feat(sources): recognise component-prefixed release tags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A release-please monorepo tags every published package on its own — `duxt@v0.4.0` beside `duxt-typesense@v0.1.0` — and until now `latest`, release discovery and the version switcher accepted only `v?X.Y.Z`, silently skipping anything else. After the first component tag a site's `latest` would have stayed on the last plain tag without any build failing. - `parseVersionTag` reads both shapes; `compareVersionTags`, `versionRelation`, release discovery and the switcher's order all go through it, so they cannot disagree about which tags are versions. - A tag's label and URL segment show the version alone (`v0.4.0`); the ref keeps the full tag, which is what Content downloads. - `tagComponent` restricts `latest` and `releases` to one package's tags. Plain tags still count under it — the history from before a repository adopted component tags. Unset, every tag is a candidate, exactly as before. - www's own duxt source names `tagComponent: 'duxt'`, so its `latest` follows the layer's releases once they are tagged `duxt@v…`. Refs #86 --- app/types/duxt.d.ts | 7 ++ app/utils/version-choices.ts | 10 +-- docs/4.reference/2.sources.md | 28 ++++++++ docs/de/4.reference/2.sources.md | 27 ++++++++ docs/es/4.reference/2.sources.md | 27 ++++++++ docs/fr/4.reference/2.sources.md | 28 ++++++++ docs/pt/4.reference/2.sources.md | 27 ++++++++ sources-git.ts | 77 +++++++++++++--------- sources-resolve.ts | 110 ++++++++++++++++++++++++++----- tests/sources-git.test.ts | 85 ++++++++++++++++++++++++ tests/sources-versions.test.ts | 45 +++++++++++++ tests/version-choices.test.ts | 26 ++++++++ www/app/app.config.ts | 5 ++ 13 files changed, 450 insertions(+), 52 deletions(-) diff --git a/app/types/duxt.d.ts b/app/types/duxt.d.ts index a810b222..6ef8498d 100644 --- a/app/types/duxt.d.ts +++ b/app/types/duxt.d.ts @@ -271,6 +271,13 @@ declare global { status?: DuxtSourceStatusInput; /** Lifecycle defaults by ref kind; an explicit ref status wins. */ statusDefaults?: DuxtSourceStatusDefaultsInput; + /** + * The one package whose tags this source publishes, in a repository that + * tags `@vX.Y.Z` — `'duxt'` reads `duxt@v0.4.0` and ignores + * `duxt-typesense@v0.1.0`. Applies to `latest` and `releases`; plain + * `vX.Y.Z` tags still count. + */ + tagComponent?: string; /** * The repository a source read off disk lives in, for links back to it. * Not `repo`, which is what makes Content download a source. diff --git a/app/utils/version-choices.ts b/app/utils/version-choices.ts index 344512c1..088b56a6 100644 --- a/app/utils/version-choices.ts +++ b/app/utils/version-choices.ts @@ -11,7 +11,7 @@ * rest of it is: every version bug this layer has had came from prefix work * done inline in a component, where it could only be checked by clicking. */ -import { compareVersionTags } from '../../sources-resolve'; +import { compareVersionTags, parseVersionTag } from '../../sources-resolve'; /** * What a reader needs to see first: work that may change, the edition they @@ -33,11 +33,11 @@ function compareEditions(a: DuxtResolvedSource, b: DuxtResolvedSource): number { const aVersion = a.version!; const bVersion = b.version!; - const semver = /^v?\d+\.\d+\.\d+(?:-.+)?$/; - // The shared comparator knows full tags. Demo editions such as `v3.x` are - // deliberately not tags, but still sort newest-first inside their group. - return semver.test(aVersion) && semver.test(bVersion) + // The shared comparator knows full tags, component-prefixed ones included. + // Demo editions such as `v3.x` are deliberately not tags, but still sort + // newest-first inside their group. + return parseVersionTag(aVersion) && parseVersionTag(bVersion) ? compareVersionTags(aVersion, bVersion) : bVersion.localeCompare(aVersion); } diff --git a/docs/4.reference/2.sources.md b/docs/4.reference/2.sources.md index f428780b..9bfa4723 100644 --- a/docs/4.reference/2.sources.md +++ b/docs/4.reference/2.sources.md @@ -24,6 +24,7 @@ generated section's file-based `versions`. | `repo` | `string` | `owner/name` or a git URL. **Omitted means the local checkout** | | `refs` | ref list | Refs to publish as versions; requires an explicit `repo` | | `releases` | `{ select, prereleases? }` | Discover release tags to publish as versions — see below | +| `tagComponent` | `string` | Publish only one package's `@vX.Y.Z` tags — see [Component tags](#component-tags) | | `version` | `string` | The version this source **is**, where no ref names one | | `label` | text | Shown in the switcher and used in the URL; defaults to the ref | | `name` | text | What to **call** this source where the site names it. Display only — see below | @@ -171,6 +172,33 @@ remote, or a repository URL the layer refuses to run — the error says that instead, so an outage is never reported as a configuration mistake. The same distinction applies to `latest`. +### Component tags + +A monorepo that publishes several packages on their own tags each one by +**component** — release-please writes `duxt@v0.4.0` beside +`duxt-typesense@v0.1.0`. Both shapes are versions: `latest`, `releases` and the +switcher's order read `@vX.Y.Z` exactly as they read `vX.Y.Z`, and the +label and the URL segment show the version alone — `v0.4.0`, never +`duxt@v0.4.0`. The ref keeps the full tag, because that is the name git knows. + +Where the repository tags more than one package, "the newest tag" means nothing +until it says whose. `tagComponent` names the package: + +```ts +{ + repo: 'kirchDev/duxt', + path: 'docs', + tagComponent: 'duxt', + refs: [{ tag: 'latest', default: true }] // duxt@v…, never duxt-typesense@v… +} +``` + +**Plain `vX.Y.Z` tags still count** under a component: they are the package's +releases from before the repository adopted component tags, and dropping them +would unpublish every one of them. Another component's tags never do. Without +`tagComponent`, every tag that reads as a version is a candidate, prefixed or +not. + ## Locales Omitted, the source has one language and one collection. Listed, each entry diff --git a/docs/de/4.reference/2.sources.md b/docs/de/4.reference/2.sources.md index ae3e9bcf..c9073288 100644 --- a/docs/de/4.reference/2.sources.md +++ b/docs/de/4.reference/2.sources.md @@ -24,6 +24,7 @@ oder dateibasierte `versions` eines generierten Abschnitts. | `repo` | `string` | `owner/name` oder eine git-URL. **Weggelassen heißt lokaler Checkout** | | `refs` | Ref-Liste | Refs als Versionen; erfordern ein explizites `repo` | | `releases` | `{ select, prereleases? }` | Release-Tags als Versionen entdecken — siehe unten | +| `tagComponent` | `string` | Nur die `@vX.Y.Z`-Tags eines Pakets veröffentlichen — siehe [Komponenten-Tags](#komponenten-tags) | | `version` | `string` | Die Version, die diese Quelle selbst ist | | `label` | Text | Im Umschalter gezeigt und in der URL genutzt; standardmäßig der Ref | | `name` | Text | Wie diese Quelle **heißt**, wo die Seite sie benennt. Nur Anzeige — siehe unten | @@ -163,6 +164,32 @@ Eine Auswahl ohne passenden Tag stoppt den Build und nennt Quelle und Auswahl. Ein nicht lesbares Repository wird getrennt als Git-Fehler gemeldet. Dieselbe Unterscheidung gilt für `latest`. +### Komponenten-Tags + +Ein Monorepo, das mehrere Pakete getrennt veröffentlicht, taggt jedes nach +seiner **Komponente** — release-please schreibt `duxt@v0.4.0` neben +`duxt-typesense@v0.1.0`. Beide Formen sind Versionen: `latest`, `releases` und +die Reihenfolge im Umschalter lesen `@vX.Y.Z` genau wie `vX.Y.Z`, und +Label und URL-Segment zeigen nur die Version — `v0.4.0`, nie `duxt@v0.4.0`. Der +Ref behält den vollständigen Tag, denn unter diesem Namen kennt git ihn. + +Taggt das Repository mehr als ein Paket, bedeutet „der neueste Tag“ nichts, +solange nicht gesagt ist, wessen. `tagComponent` nennt das Paket: + +```ts +{ + repo: 'kirchDev/duxt', + path: 'docs', + tagComponent: 'duxt', + refs: [{ tag: 'latest', default: true }] // duxt@v…, nie duxt-typesense@v… +} +``` + +**Einfache `vX.Y.Z`-Tags zählen weiterhin**: Sie sind die Releases des Pakets +aus der Zeit vor den Komponenten-Tags, und sie wegzulassen würde jedes davon +depublizieren. Tags einer anderen Komponente zählen nie. Ohne `tagComponent` ist +jeder Tag, der sich als Version liest, ein Kandidat — mit oder ohne Präfix. + ## Locales Weggelassen hat die Quelle eine Sprache und eine Collection. Aufgelistet wird diff --git a/docs/es/4.reference/2.sources.md b/docs/es/4.reference/2.sources.md index 061278fd..bdd5bf64 100644 --- a/docs/es/4.reference/2.sources.md +++ b/docs/es/4.reference/2.sources.md @@ -24,6 +24,7 @@ las `versions` basadas en archivos de una sección generada. | `repo` | `string` | `owner/name` o una URL de git. **Omitido significa el checkout local** | | `refs` | lista de refs | Refs a publicar como versiones; requieren un `repo` explícito | | `releases` | `{ select, prereleases? }` | Descubrir tags de versión para publicarlos — véase abajo | +| `tagComponent` | `string` | Publicar solo los tags `@vX.Y.Z` de un paquete — véase [Tags por componente](#tags-por-componente) | | `version` | `string` | La versión que esta fuente representa por sí misma | | `label` | texto | Mostrado en el selector y usado en la URL; por defecto, la ref | | `name` | texto | Cómo **se llama** esta fuente donde el sitio la nombra. Solo visualización — véase abajo | @@ -160,6 +161,32 @@ Una selección sin tag coincidente detiene la build e identifica la fuente y la selección. Un repositorio ilegible se informa por separado como error de Git. La misma distinción se aplica a `latest`. +### Tags por componente + +Un monorepo que publica varios paquetes por separado etiqueta cada uno por su +**componente** — release-please escribe `duxt@v0.4.0` junto a +`duxt-typesense@v0.1.0`. Ambas formas son versiones: `latest`, `releases` y el +orden del selector leen `@vX.Y.Z` igual que `vX.Y.Z`, y la etiqueta y el +segmento de URL muestran solo la versión — `v0.4.0`, nunca `duxt@v0.4.0`. La ref +conserva el tag completo, porque es el nombre que git conoce. + +Si el repositorio etiqueta más de un paquete, «el tag más reciente» no significa +nada hasta decir de cuál. `tagComponent` nombra el paquete: + +```ts +{ + repo: 'kirchDev/duxt', + path: 'docs', + tagComponent: 'duxt', + refs: [{ tag: 'latest', default: true }] // duxt@v…, nunca duxt-typesense@v… +} +``` + +**Los tags `vX.Y.Z` simples siguen contando**: son las versiones del paquete +anteriores a los tags por componente, y descartarlos despublicaría cada una. +Los tags de otro componente nunca cuentan. Sin `tagComponent`, todo tag que se +lea como versión es candidato, con prefijo o sin él. + ## Locales Omitido, la fuente tiene un idioma y una colección. Listado, cada entrada se diff --git a/docs/fr/4.reference/2.sources.md b/docs/fr/4.reference/2.sources.md index 6f1eef7f..544456ba 100644 --- a/docs/fr/4.reference/2.sources.md +++ b/docs/fr/4.reference/2.sources.md @@ -25,6 +25,7 @@ section générée. | `repo` | `string` | `owner/name` ou une URL git. **Omis signifie le checkout local** | | `refs` | liste de refs | Refs à publier comme versions ; exigent un `repo` explicite | | `releases` | `{ select, prereleases? }` | Découvrir les tags de version à publier — voir plus bas | +| `tagComponent` | `string` | Ne publier que les tags `@vX.Y.Z` d’un paquet — voir [Tags par composant](#tags-par-composant) | | `version` | `string` | La version que cette source représente elle-même | | `label` | texte | Affiché dans le sélecteur et utilisé dans l’URL ; par défaut la ref | | `name` | texte | Comment cette source **s’appelle** là où le site la nomme. Affichage seul — voir plus bas | @@ -162,6 +163,33 @@ Une sélection sans tag correspondant arrête le build en nommant la source et l sélection. Un dépôt illisible est signalé séparément comme une erreur Git. La même distinction vaut pour `latest`. +### Tags par composant + +Un monorepo qui publie plusieurs paquets séparément étiquette chacun par son +**composant** — release-please écrit `duxt@v0.4.0` à côté de +`duxt-typesense@v0.1.0`. Les deux formes sont des versions : `latest`, +`releases` et l’ordre du sélecteur lisent `@vX.Y.Z` exactement comme +`vX.Y.Z`, et le libellé comme le segment d’URL n’affichent que la version — +`v0.4.0`, jamais `duxt@v0.4.0`. La ref garde le tag complet, car c’est le nom que +git connaît. + +Quand le dépôt étiquette plus d’un paquet, « le tag le plus récent » ne veut rien +dire tant qu’il ne précise pas lequel. `tagComponent` nomme le paquet : + +```ts +{ + repo: 'kirchDev/duxt', + path: 'docs', + tagComponent: 'duxt', + refs: [{ tag: 'latest', default: true }] // duxt@v…, jamais duxt-typesense@v… +} +``` + +**Les tags `vX.Y.Z` simples comptent toujours** : ce sont les versions du paquet +antérieures aux tags par composant, et les écarter les dépublierait toutes. Les +tags d’un autre composant ne comptent jamais. Sans `tagComponent`, tout tag qui +se lit comme une version est candidat, préfixé ou non. + ## Locales Omis, la source a une langue et une collection. Listées, chaque entrée devient diff --git a/docs/pt/4.reference/2.sources.md b/docs/pt/4.reference/2.sources.md index 80de6426..06614c32 100644 --- a/docs/pt/4.reference/2.sources.md +++ b/docs/pt/4.reference/2.sources.md @@ -24,6 +24,7 @@ Para versões locais, use `version` em fontes com pastas distintas ou as | `repo` | `string` | `owner/name` ou um URL git. **Omitido significa o checkout local** | | `refs` | lista de refs | Refs a publicar como versões; exigem um `repo` explícito | | `releases` | `{ select, prereleases? }` | Descobrir tags de versão para publicação — ver abaixo | +| `tagComponent` | `string` | Publicar apenas as tags `@vX.Y.Z` de um pacote — ver [Tags por componente](#tags-por-componente) | | `version` | `string` | A versão que esta fonte representa por si própria | | `label` | texto | Mostrado no seletor e usado no URL; por omissão, a ref | | `name` | texto | Como esta fonte **se chama** onde o site a nomeia. Apenas visualização — ver abaixo | @@ -157,6 +158,32 @@ Uma seleção sem tag correspondente para a build e identifica a fonte e a seleção. Um repositório ilegível é comunicado separadamente como erro de Git. A mesma distinção aplica-se a `latest`. +### Tags por componente + +Um monorepo que publica vários pacotes separadamente marca cada um pelo seu +**componente** — o release-please escreve `duxt@v0.4.0` ao lado de +`duxt-typesense@v0.1.0`. Ambas as formas são versões: `latest`, `releases` e a +ordem do seletor leem `@vX.Y.Z` tal como `vX.Y.Z`, e a etiqueta e o +segmento de URL mostram apenas a versão — `v0.4.0`, nunca `duxt@v0.4.0`. A ref +guarda a tag completa, porque é o nome que o git conhece. + +Quando o repositório marca mais do que um pacote, “a tag mais recente” não quer +dizer nada até dizer de qual. `tagComponent` nomeia o pacote: + +```ts +{ + repo: 'kirchDev/duxt', + path: 'docs', + tagComponent: 'duxt', + refs: [{ tag: 'latest', default: true }] // duxt@v…, nunca duxt-typesense@v… +} +``` + +**As tags `vX.Y.Z` simples continuam a contar**: são os lançamentos do pacote +anteriores às tags por componente, e descartá-las despublicaria cada um. As +tags de outro componente nunca contam. Sem `tagComponent`, qualquer tag que se +leia como versão é candidata, com ou sem prefixo. + ## Locales Omitido, a fonte tem um idioma e uma coleção. Listado, cada entrada torna-se uma diff --git a/sources-git.ts b/sources-git.ts index d80d5725..2fd1db0a 100644 --- a/sources-git.ts +++ b/sources-git.ts @@ -2,16 +2,19 @@ import { execFileSync } from 'node:child_process'; import type { DuxtRef, DuxtSource, - DuxtSourceReleases + DuxtSourceReleases, + DuxtVersionTag } from './sources-resolve'; import { compareVersionTags, expandSources, isLatestRef, newestTag, + parseVersionTag, refIsTag, refName, - repoUrl + repoUrl, + tagBelongsTo } from './sources-resolve'; /** @@ -153,32 +156,34 @@ function tagsFor(source: DuxtSource, what: string): string[] { return found.tags; } -interface ParsedTag { - major: number; - minor: number; - prerelease: boolean; +/** + * The version tags a source may publish from: every tag that reads as a + * version and belongs to the source's `tagComponent`, when it names one. + */ +function versionTags( + tags: string[], + component: string | undefined +): { tag: string; version: DuxtVersionTag }[] { + return tags + .map((tag) => ({ tag, version: parseVersionTag(tag) })) + .filter( + (entry): entry is { tag: string; version: DuxtVersionTag } => + Boolean(entry.version) && tagBelongsTo(entry.version!, component) + ); } -function parseTag(tag: string): ParsedTag | undefined { - const match = /^v?(\d+)\.(\d+)\.\d+(?:-(.+))?$/.exec(tag.trim()); - if (!match) return undefined; - - return { - major: Number(match[1]), - minor: Number(match[2]), - prerelease: Boolean(match[3]) - }; -} +/** How a source's tag restriction reads in an error, or nothing without one. */ +const componentClause = (source: DuxtSource) => + source.tagComponent ? ` for the component "${source.tagComponent}"` : ''; /** Tags selected by a source's explicit release policy, newest first. */ -function releaseTags(tags: string[], releases: DuxtSourceReleases): string[] { - const parsed = tags - .map((tag) => ({ tag, version: parseTag(tag) })) - .filter( - (entry): entry is { tag: string; version: ParsedTag } => - Boolean(entry.version) && - (releases.prereleases || !entry.version!.prerelease) - ) +function releaseTags( + tags: string[], + releases: DuxtSourceReleases, + component: string | undefined +): string[] { + const parsed = versionTags(tags, component) + .filter((entry) => releases.prereleases || !entry.version.pre) .sort((left, right) => compareVersionTags(left.tag, right.tag)); if (releases.select === 'all') return parsed.map((entry) => entry.tag); @@ -186,10 +191,9 @@ function releaseTags(tags: string[], releases: DuxtSourceReleases): string[] { const selected = new Set(); const result: string[] = []; for (const entry of parsed) { + const [major, minor] = entry.version.numbers; const line = - releases.select === 'major' - ? String(entry.version.major) - : `${entry.version.major}.${entry.version.minor}`; + releases.select === 'major' ? String(major) : `${major}.${minor}`; if (selected.has(line)) continue; selected.add(line); result.push(entry.tag); @@ -206,12 +210,14 @@ function resolveReleaseRefs(source: DuxtSource): DuxtSource { const tags = releaseTags( tagsFor(source, `releases selection "${source.releases.select}"`), - source.releases + source.releases, + source.tagComponent ); if (!tags.length) { throw new Error( `duxt: releases selection "${source.releases.select}" on ` + - `${source.repo ?? 'this repository'} found no SemVer tags to publish.` + `${source.repo ?? 'this repository'} found no SemVer tags to publish` + + `${componentClause(source)}.` ); } @@ -248,12 +254,21 @@ export function resolveLatestRefs(sources: DuxtSource[]): DuxtSource[] { return discovered.map((source) => { if (!source.refs?.some(isLatestRef)) return source; - const newest = newestTag(tagsFor(source, "refs: ['latest']")); + const tags = tagsFor(source, "refs: ['latest']"); + + // Restricted only when a component is named: an unrestricted source keeps + // choosing among every tag, exactly as before component tags existed. + const newest = newestTag( + source.tagComponent + ? versionTags(tags, source.tagComponent).map((entry) => entry.tag) + : tags + ); if (!newest) { throw new Error( `duxt: refs: ['latest'] on ${source.repo ?? 'this repository'} found ` + - 'no tags to choose from. Name a tag explicitly, or drop the entry.' + `no tags to choose from${componentClause(source)}. Name a tag ` + + 'explicitly, or drop the entry.' ); } diff --git a/sources-resolve.ts b/sources-resolve.ts index 59f9c574..7edb011b 100644 --- a/sources-resolve.ts +++ b/sources-resolve.ts @@ -49,6 +49,19 @@ export interface DuxtSource { * label, lifecycle, default and locales. */ releases?: DuxtSourceReleases; + /** + * The one package whose tags this source publishes from. + * + * A release-please monorepo tags every published package on its own — + * `duxt@v0.4.0` beside `duxt-typesense@v0.1.0` — so "the newest tag" means + * nothing until it says whose. Naming a component restricts `latest` and + * `releases` to that package's `@…` tags. Plain `vX.Y.Z` tags + * still count: they are the history from before the repository adopted + * component tags, and dropping them would unpublish every earlier release. + * + * Unset, every tag that reads as a version is a candidate, prefixed or not. + */ + tagComponent?: string; /** * Languages this source is available in, beyond the one written in `path`. * @@ -655,7 +668,15 @@ export function resolveSources( (ref && typeof ref === 'object' ? ref.label : undefined) ?? source.label; const code = locale ? localeCode(locale) : defaultLocale; const isDefaultLocale = !code || code === defaultLocale; - const version = name ? slugify(label ?? name) : undefined; + // A component-prefixed tag is shown and addressed by its version: + // `duxt@v0.4.0` reads `v0.4.0` in the switcher and the URL, while `ref` + // below keeps the full tag git knows. A branch's name is never touched — + // `release@1.0.0` as a BRANCH is a name, not a release. + const shown = + effectiveRef && refIsTag(effectiveRef) && name + ? versionTagName(name) + : name; + const version = name ? slugify(label ?? shown!) : undefined; const isDefault = !name || (ref && typeof ref === 'object' && Boolean(ref.default)) || @@ -816,6 +837,73 @@ export function duxtSourceManifest( return resolveSources(sources, options); } +/** A version tag, taken apart. */ +export interface DuxtVersionTag { + /** + * The package a monorepo release tool named in front of the version — + * `duxt` in `duxt@v0.4.0`. Absent on a plain `v0.4.0`. + */ + component?: string; + /** The version itself, as written: `v0.4.0`, `1.2.0-rc.1`. */ + version: string; + numbers: [number, number, number]; + pre?: string; +} + +/** + * Read a tag as a version, or answer `undefined` for anything that is not one. + * + * TWO SHAPES, because release tooling writes two. A single-package repository + * tags `v1.2.3`; a release-please monorepo tags `@v1.2.3`, one + * component per published package. Everything that orders or selects releases + * reads a tag through this one function, so `latest`, release discovery and + * the switcher's order cannot disagree about which tags are versions — the + * drift that would let a site's `latest` stay on the last plain tag, silently, + * after the first component tag was cut. + * + * The component is everything before the LAST `@`, so a scoped package name + * (`@acme/sdk@v1.0.0`) keeps its own. + */ +export function parseVersionTag(value: string): DuxtVersionTag | undefined { + const match = /^(?:(.+)@)?(v?(\d+)\.(\d+)\.(\d+)(?:-(.+))?)$/.exec( + value.trim() + ); + if (!match) return undefined; + + return { + component: match[1], + version: match[2]!, + numbers: [Number(match[3]), Number(match[4]), Number(match[5])], + pre: match[6] + }; +} + +/** + * The version a tag names, without the component in front of it. + * + * What a reader sees and what a URL carries: `duxt@v0.4.0` is the name git + * knows and Content downloads, `v0.4.0` is the release. Anything that is not a + * version tag comes back unchanged. + */ +export const versionTagName = (tag: string): string => + parseVersionTag(tag)?.version ?? tag; + +/** + * Does a tag belong to `component`? + * + * A plain tag always does. In a repository that adopted component tags it is + * the release history from before the switch — release-please's own migration + * leaves exactly that — and without it a source restricted to one component + * would lose every release it already published. Another component's tag + * never does. + */ +export function tagBelongsTo( + tag: DuxtVersionTag, + component: string | undefined +): boolean { + return !component || !tag.component || tag.component === component; +} + /** * Order two version-ish tag names the way a release list is ordered. * @@ -825,18 +913,8 @@ export function duxtSourceManifest( * and anything that is not a version at all sorts last so it can never win. */ export function compareVersionTags(a: string, b: string): number { - const parse = (value: string) => { - const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-(.+))?$/.exec(value.trim()); - if (!match) return undefined; - - return { - numbers: [Number(match[1]), Number(match[2]), Number(match[3])], - pre: match[4] - }; - }; - - const left = parse(a); - const right = parse(b); + const left = parseVersionTag(a); + const right = parseVersionTag(b); if (!left || !right) return left ? -1 : right ? 1 : a.localeCompare(b); @@ -917,9 +995,9 @@ export function versionRelation( if (!version || !preferred) return 'unknown'; if (version === preferred) return 'same'; - const isVersion = (value: string) => - /^v?\d+\.\d+\.\d+(?:-.+)?$/.test(value.trim()); - if (!isVersion(version) || !isVersion(preferred)) return 'unknown'; + if (!parseVersionTag(version) || !parseVersionTag(preferred)) { + return 'unknown'; + } const order = compareVersionTags(version, preferred); diff --git a/tests/sources-git.test.ts b/tests/sources-git.test.ts index 57d63f2c..5400b883 100644 --- a/tests/sources-git.test.ts +++ b/tests/sources-git.test.ts @@ -14,6 +14,20 @@ vi.mock('node:child_process', () => ({ }); } + // A release-please monorepo that adopted component tags after a history + // of plain ones, and tags a second package beside the first. + if (url.endsWith('/monorepo')) { + return [ + 'deadbeef\trefs/tags/v0.3.4', + 'deadbeef\trefs/tags/v0.4.0', + 'deadbeef\trefs/tags/duxt@v0.5.0', + 'deadbeef\trefs/tags/duxt@v0.5.1', + 'deadbeef\trefs/tags/duxt@v0.5.1-rc.1', + 'deadbeef\trefs/tags/duxt-typesense@v0.9.0', + 'deadbeef\trefs/tags/nightly' + ].join('\n'); + } + return url.endsWith('/legacy') ? 'deadbeef\trefs/tags/v0.2.0\n' : url.endsWith('/empty') @@ -196,6 +210,77 @@ describe('resolveLatestRefs', () => { ).not.toThrow(/no SemVer tags/i); }); + it('resolves latest across every component when a source names none', () => { + const [source] = resolveLatestRefs([ + { repo: 'acme/monorepo', refs: [{ tag: 'latest', default: true }] } + ]); + + expect(source!.refs).toEqual([ + expect.objectContaining({ tag: 'duxt-typesense@v0.9.0' }) + ]); + }); + + it("resolves latest within one component's tags", () => { + const [source] = resolveLatestRefs([ + { + repo: 'acme/monorepo', + tagComponent: 'duxt', + refs: [{ tag: 'latest', default: true }, { tag: 'v0.3.4' }] + } + ]); + + expect(source!.refs).toEqual([ + expect.objectContaining({ tag: 'duxt@v0.5.1', default: true }), + { tag: 'v0.3.4' } + ]); + expect(resolveSources([source!])[0]).toMatchObject({ + ref: 'duxt@v0.5.1', + version: 'v0.5.1' + }); + }); + + it("discovers one component's releases, plain history included", () => { + const [source] = resolveLatestRefs([ + { + repo: 'acme/monorepo', + tagComponent: 'duxt', + releases: { select: 'minor' } + } + ]); + + // The plain tags are the package's releases from before the repository + // adopted component tags; another component's tags are never its own. + expect(source!.refs).toEqual([ + { tag: 'duxt@v0.5.1' }, + { tag: 'v0.4.0' }, + { tag: 'v0.3.4' } + ]); + }); + + it('keeps a plain-tag repository unchanged under a component', () => { + const [source] = resolveLatestRefs([ + { + repo: 'acme/legacy', + tagComponent: 'duxt', + refs: [{ tag: 'latest', default: true }] + } + ]); + + expect(source!.refs).toEqual([expect.objectContaining({ tag: 'v0.2.0' })]); + }); + + it('names the component when none of its tags exist', () => { + expect(() => + resolveLatestRefs([ + { + repo: 'acme/empty', + tagComponent: 'duxt', + refs: [{ tag: 'latest', default: true }] + } + ]) + ).toThrow(/acme\/empty.*"duxt"/is); + }); + it('reports a refused repository URL as the refusal it is', () => { expect(() => resolveLatestRefs([ diff --git a/tests/sources-versions.test.ts b/tests/sources-versions.test.ts index 819f2f37..c00d4fe9 100644 --- a/tests/sources-versions.test.ts +++ b/tests/sources-versions.test.ts @@ -29,6 +29,51 @@ describe('compareVersionTags', () => { it('is a comparator, so a sort of equals is stable', () => { expect(compareVersionTags('v1.0.0', 'v1.0.0')).toBe(0); }); + + it('reads a component-prefixed tag by its version, not its name', () => { + // release-please's monorepo tags: `@vX.Y.Z`. The prefix names + // the package, so `duxt@v0.10.0` is newer than `duxt@v0.9.0` and than a + // plain `v0.4.0` cut before the repository adopted component tags. + expect( + newestTag(['v0.4.0', 'duxt@v0.9.0', 'duxt@v0.10.0', 'nightly']) + ).toBe('duxt@v0.10.0'); + expect(compareVersionTags('duxt@v1.0.0', 'v1.0.0')).toBe(0); + }); +}); + +describe('component-prefixed tags', () => { + it('shows the version, never the component, in the label and the URL', () => { + const resolved = resolveSources([ + { + repo: 'acme/monorepo', + path: 'docs', + refs: ['main', { tag: 'duxt@v0.4.0' }] + } + ]); + + // The ref stays the tag git knows — it is what Content downloads. + expect(resolved[1]).toMatchObject({ + ref: 'duxt@v0.4.0', + version: 'v0.4.0', + prefix: '/v0.4.0' + }); + }); + + it('compares a prefixed tag with a plain one', () => { + expect(versionRelation('duxt@v1.0.0', 'v0.9.0')).toBe('newer'); + }); + + it("still lets a ref's own label win", () => { + const resolved = resolveSources([ + { + repo: 'acme/monorepo', + path: 'docs', + refs: ['main', { tag: 'duxt@v2.0.0', label: 'v2' }] + } + ]); + + expect(resolved[1]).toMatchObject({ version: 'v2', prefix: '/v2' }); + }); }); describe('resolved source metadata', () => { diff --git a/tests/version-choices.test.ts b/tests/version-choices.test.ts index 0bfba8cf..fd9cdf6b 100644 --- a/tests/version-choices.test.ts +++ b/tests/version-choices.test.ts @@ -164,6 +164,32 @@ describe('versionChoices', () => { it('survives having no current source', () => { expect(versionChoices([v2, v1], undefined, undefined)).toEqual([]); }); + + it('labels and orders component-prefixed tags by their version', () => { + const sources = resolveSources([ + { + repo: 'acme/monorepo', + path: 'docs', + refs: [ + { tag: 'duxt@v0.10.0', default: true }, + { tag: 'duxt@v0.9.0' }, + { tag: 'v0.4.0' } + ] + } + ]); + const current = sources.find((entry) => entry.isDefault)!; + + expect( + versionChoices(sources, current, undefined).map((choice) => [ + choice.label, + choice.to + ]) + ).toEqual([ + ['v0.10.0', '/'], + ['v0.9.0', '/v0.9.0'], + ['v0.4.0', '/v0.4.0'] + ]); + }); }); describe('a site that publishes a reference beside its documentation', () => { diff --git a/www/app/app.config.ts b/www/app/app.config.ts index 3348ef72..b18eea91 100644 --- a/www/app/app.config.ts +++ b/www/app/app.config.ts @@ -43,6 +43,11 @@ const DUXT_DOCS = { branch: 'upcoming', tag: 'deprecated' }, + // This repository tags each published package on its own — `duxt@v0.5.0` + // beside a provider's `duxt-typesense@v0.1.0` — so `latest` has to say + // whose newest tag it means. The plain `v0.1.0`…`v0.4.0` tags cut before + // the monorepo still count as the layer's releases. + tagComponent: 'duxt', refs: [ { branch: 'main' }, { tag: 'latest', default: true }, From 790a5462c8e4ec6ec7a15dc91a735452f4bf0f38 Mon Sep 17 00:00:00 2001 From: Titus Kirch Date: Tue, 15 Sep 2026 02:29:43 +0200 Subject: [PATCH 02/15] build: restructure the repository into a Turborepo monorepo The root was the layer, so thirty build-time modules sat flat beside the meta configuration and a second published package had nowhere to go. - The layer moves to packages/duxt and its build-time modules into topic folders under build/ (sources, sections, bruno, openapi, search, content, og-image, git, config, cli). The files allowlist shrinks to directories; the exports map keeps every subpath name it had. - The site moves to apps/www together with the scripts and tests that read its build. The root keeps workspace and meta configuration, docs/ and the layer's CHANGELOG.md. - Root scripts delegate package tasks to `turbo run`. The cache is local only and covers only tasks whose declared inputs determine the result; typecheck, build and the checks over a build stay uncached. - The layer's dependencies are hoisted to the workspace root, because the site builds the layer's code and resolved them there while the root was the layer (typecheck and the Workers build both depend on it). - release-please runs per package with `@vX.Y.Z` tags, keeps the layer's changelog at /CHANGELOG.md so every earlier edition keeps its release pages, and bridges from v0.4.0 with last-release-sha. - The npm publish moves into release-please.yml as own jobs, decided by tag and registry, because the central bodies publish the repository root. - CLAUDE.md, AGENTS.md, README, CONTRIBUTING and the branding page describe the new layout. Refs #86 --- .github/workflows/ci.yml | 2 +- .github/workflows/deploy.yml | 22 +- .github/workflows/prerender-bench.yml | 20 +- .github/workflows/release-please.yml | 196 ++- .gitignore | 12 +- .oxfmtrc.json | 3 +- .release-please-manifest.json | 2 +- .vscode/settings.json | 2 +- AGENTS.md | 72 +- CLAUDE.md | 72 +- CONTRIBUTING.md | 38 +- README.md | 6 +- {www => apps/www}/.env.example | 0 {www => apps/www}/app/app.config.ts | 30 +- {www => apps/www}/app/assets/css/brand.css | 0 {www => apps/www}/demo/CHANGELOG.md | 0 {www => apps/www}/demo/collection/1.ping.bru | 0 .../www}/demo/collection/2.signed.bru | 0 {www => apps/www}/demo/collection/bruno.json | 0 .../www}/demo/collection/collection.bru | 0 .../demo/collection/consignments/1.create.bru | 0 .../demo/collection/consignments/2.list.bru | 0 .../demo/collection/consignments/3.get.bru | 0 .../demo/collection/consignments/folder.bru | 0 .../demo/collection/environments/local.bru | 0 .../demo/collection/tracking/1.events.bru | 0 .../www}/demo/collection/tracking/folder.bru | 0 {www => apps/www}/demo/docs/1.versions.md | 0 {www => apps/www}/demo/docs/2.source.md | 0 {www => apps/www}/demo/docs/3.images.md | 0 {www => apps/www}/demo/docs/index.md | 0 {www => apps/www}/demo/main.yaml | 0 {www => apps/www}/demo/v1.yaml | 0 {www => apps/www}/demo/v2.yaml | 0 {www => apps/www}/demo/v3.yaml | 2 +- {www => apps/www}/modules/search-records.ts | 0 {www => apps/www}/nuxt.config.ts | 19 +- {www => apps/www}/package.json | 18 +- {www => apps/www}/public/apple-touch-icon.png | Bin {www => apps/www}/public/demo/badge.png | Bin {www => apps/www}/public/demo/ornament.png | Bin {www => apps/www}/public/demo/pipeline.svg | 0 .../www}/public/demo/screenshot-dark.png | Bin {www => apps/www}/public/demo/screenshot.png | Bin {www => apps/www}/public/demo/spinner.gif | Bin {www => apps/www}/public/favicon.svg | 0 {www => apps/www}/public/wordmark-dark.svg | 0 {www => apps/www}/public/wordmark.svg | 0 {scripts => apps/www/scripts}/browser.ts | 0 {scripts => apps/www/scripts}/built-server.ts | 3 +- {scripts => apps/www/scripts}/check-a11y.ts | 0 .../www/scripts}/check-adapters.ts | 2 +- {scripts => apps/www/scripts}/check-images.ts | 2 +- .../www/scripts}/check-keyboard.ts | 0 .../www/scripts}/check-overflow.ts | 0 {scripts => apps/www/scripts}/check-routes.ts | 10 +- {scripts => apps/www/scripts}/check-seo.ts | 0 .../www/scripts}/deploy-summary.ts | 0 .../www/scripts}/nuxt-process-guard.ts | 0 .../www/scripts}/prerender-bench.ts | 4 +- .../www/scripts}/search-index-bench.ts | 4 +- {www => apps/www}/server/routes/demo/echo.ts | 4 +- .../www}/server/routes/demo/echo/[...path].ts | 0 .../www/tests}/check-images.test.ts | 0 .../www/tests}/check-routes.test.ts | 0 {tests => apps/www/tests}/check-seo.test.ts | 7 +- .../www/tests}/deploy-summary.test.ts | 0 .../www/tests}/deploy-workflow.test.ts | 4 +- .../www/tests}/docs-locales.test.ts | 3 +- .../tests}/fixtures/nuxt-ownership/process.ts | 0 .../www/tests}/fixtures/seo-server.mjs | 0 .../www/tests}/git-dir-guard.test.ts | 2 +- .../www/tests}/nuxt-process-guard.test.ts | 16 +- .../www/tests}/prerender-bench.test.ts | 5 +- .../www/tests}/search-index-bench.test.ts | 8 +- {tests => apps/www/tests}/seo-fixture.test.ts | 0 {www => apps/www}/tsconfig.json | 0 apps/www/vitest.config.ts | 22 + {www => apps/www}/wrangler.jsonc | 6 +- docs/6.conventions/1.branding.md | 6 +- docs/de/6.conventions/1.branding.md | 6 +- docs/es/6.conventions/1.branding.md | 6 +- docs/fr/6.conventions/1.branding.md | 6 +- docs/pt/6.conventions/1.branding.md | 6 +- package.json | 171 +-- {app => packages/duxt/app}/app.config.ts | 0 {app => packages/duxt/app}/app.vue | 0 .../duxt/app}/assets/css/duxt.css | 0 .../duxt/app}/assets/css/typeset.css | 0 .../app}/components/DuxtAnnouncements.vue | 0 .../duxt/app}/components/DuxtBrand.vue | 2 +- .../duxt/app}/components/DuxtBreadcrumb.vue | 0 .../duxt/app}/components/DuxtBrunoEntries.vue | 2 +- .../duxt/app}/components/DuxtCodeBlock.vue | 0 .../duxt/app}/components/DuxtCodeToolbar.vue | 0 .../duxt/app}/components/DuxtCopyButton.vue | 0 .../duxt/app}/components/DuxtCopyPage.vue | 2 +- .../duxt/app}/components/DuxtFooter.vue | 0 .../duxt/app}/components/DuxtHeader.vue | 0 .../duxt/app}/components/DuxtJsonEditor.vue | 0 .../duxt/app}/components/DuxtLandingProse.vue | 0 .../app}/components/DuxtLandingShowcase.vue | 2 +- .../duxt/app}/components/DuxtLinkLabel.vue | 0 .../duxt/app}/components/DuxtLiveWindow.vue | 0 .../duxt/app}/components/DuxtLocale.vue | 0 .../duxt/app}/components/DuxtMetaList.vue | 0 .../duxt/app}/components/DuxtNavigation.vue | 0 .../app}/components/DuxtNavigationLink.vue | 0 .../app}/components/DuxtOpenApiCallbacks.vue | 2 +- .../app}/components/DuxtOpenApiClient.vue | 2 +- .../duxt/app}/components/DuxtOpenApiMedia.vue | 2 +- .../app}/components/DuxtOpenApiMethod.vue | 0 .../app}/components/DuxtOpenApiParameters.vue | 2 +- .../app}/components/DuxtOpenApiResponses.vue | 2 +- .../app}/components/DuxtOpenApiSchema.vue | 2 +- .../app}/components/DuxtOpenApiSecurity.vue | 2 +- .../duxt/app}/components/DuxtPageFeedback.vue | 0 .../duxt/app}/components/DuxtPageInfo.vue | 0 .../duxt/app}/components/DuxtPageNav.vue | 0 .../app}/components/DuxtPreviewSkeleton.vue | 0 .../duxt/app}/components/DuxtProgress.vue | 0 .../duxt/app}/components/DuxtSearch.vue | 0 .../duxt/app}/components/DuxtSearchInput.vue | 0 .../duxt/app}/components/DuxtSections.vue | 0 .../duxt/app}/components/DuxtSegmented.vue | 0 .../duxt/app}/components/DuxtShortcuts.vue | 0 .../app}/components/DuxtShortcutsTrigger.vue | 0 .../duxt/app}/components/DuxtSkipLink.vue | 0 .../duxt/app}/components/DuxtToc.vue | 0 .../app}/components/DuxtTranslationBanner.vue | 0 .../duxt/app}/components/DuxtVersion.vue | 0 .../app}/components/DuxtVersionBanner.vue | 0 .../app}/components/OgImage/Duxt.satori.vue | 0 .../app}/components/content/Accordion.vue | 0 .../app}/components/content/AccordionItem.vue | 0 .../app}/components/content/BrunoOverview.vue | 5 +- .../app}/components/content/BrunoRequest.vue | 4 +- .../app}/components/content/BrunoRequests.vue | 0 .../duxt/app}/components/content/Callout.vue | 0 .../components/content/ChangelogGroup.vue | 0 .../components/content/ChangelogReleases.vue | 0 .../app}/components/content/CodeGroup.vue | 0 .../app}/components/content/DevtoolsPanel.vue | 0 .../duxt/app}/components/content/Field.vue | 0 .../app}/components/content/FieldGroup.vue | 0 .../duxt/app}/components/content/FileTree.vue | 0 .../duxt/app}/components/content/Mermaid.vue | 0 .../components/content/OpenApiOperation.vue | 2 +- .../components/content/OpenApiOperations.vue | 2 +- .../components/content/OpenApiOverview.vue | 2 +- .../components/content/PackageManagers.vue | 0 .../app}/components/content/PageCards.vue | 0 .../duxt/app}/components/content/Partial.vue | 2 +- .../duxt/app}/components/content/Preview.vue | 0 .../duxt/app}/components/content/ProseA.vue | 0 .../duxt/app}/components/content/ProseH2.vue | 0 .../duxt/app}/components/content/ProseH3.vue | 0 .../duxt/app}/components/content/ProseH4.vue | 0 .../duxt/app}/components/content/ProseImg.vue | 2 +- .../duxt/app}/components/content/ProsePre.vue | 0 .../app}/components/content/ProseTable.vue | 0 .../duxt/app}/components/content/Since.vue | 0 .../duxt/app}/components/content/Steps.vue | 0 .../duxt/app}/components/content/Tab.vue | 0 .../duxt/app}/components/content/TabGroup.vue | 0 .../duxt/app}/components/ui/alert/Alert.vue | 0 .../components/ui/alert/AlertDescription.vue | 0 .../app}/components/ui/alert/AlertTitle.vue | 0 .../duxt/app}/components/ui/alert/index.ts | 0 .../duxt/app}/components/ui/badge/Badge.vue | 0 .../duxt/app}/components/ui/badge/index.ts | 0 .../components/ui/breadcrumb/Breadcrumb.vue | 0 .../ui/breadcrumb/BreadcrumbEllipsis.vue | 0 .../ui/breadcrumb/BreadcrumbItem.vue | 0 .../ui/breadcrumb/BreadcrumbLink.vue | 0 .../ui/breadcrumb/BreadcrumbList.vue | 0 .../ui/breadcrumb/BreadcrumbPage.vue | 0 .../ui/breadcrumb/BreadcrumbSeparator.vue | 0 .../app}/components/ui/breadcrumb/index.ts | 0 .../duxt/app}/components/ui/button/Button.vue | 0 .../duxt/app}/components/ui/button/index.ts | 0 .../duxt/app}/components/ui/card/Card.vue | 0 .../app}/components/ui/card/CardAction.vue | 0 .../app}/components/ui/card/CardContent.vue | 0 .../components/ui/card/CardDescription.vue | 0 .../app}/components/ui/card/CardFooter.vue | 0 .../app}/components/ui/card/CardHeader.vue | 0 .../app}/components/ui/card/CardTitle.vue | 0 .../duxt/app}/components/ui/card/index.ts | 0 .../components/ui/collapsible/Collapsible.vue | 0 .../ui/collapsible/CollapsibleContent.vue | 0 .../ui/collapsible/CollapsibleTrigger.vue | 0 .../app}/components/ui/collapsible/index.ts | 0 .../app}/components/ui/command/Command.vue | 0 .../components/ui/command/CommandDialog.vue | 0 .../components/ui/command/CommandEmpty.vue | 0 .../components/ui/command/CommandGroup.vue | 0 .../components/ui/command/CommandInput.vue | 0 .../components/ui/command/CommandItem.vue | 0 .../components/ui/command/CommandList.vue | 0 .../ui/command/CommandSeparator.vue | 0 .../components/ui/command/CommandShortcut.vue | 0 .../duxt/app}/components/ui/command/index.ts | 0 .../duxt/app}/components/ui/dialog/Dialog.vue | 0 .../app}/components/ui/dialog/DialogClose.vue | 0 .../components/ui/dialog/DialogContent.vue | 0 .../ui/dialog/DialogDescription.vue | 0 .../components/ui/dialog/DialogFooter.vue | 0 .../components/ui/dialog/DialogHeader.vue | 0 .../components/ui/dialog/DialogOverlay.vue | 0 .../ui/dialog/DialogScrollContent.vue | 0 .../app}/components/ui/dialog/DialogTitle.vue | 0 .../components/ui/dialog/DialogTrigger.vue | 0 .../duxt/app}/components/ui/dialog/index.ts | 0 .../ui/dropdown-menu/DropdownMenu.vue | 0 .../DropdownMenuCheckboxItem.vue | 0 .../ui/dropdown-menu/DropdownMenuContent.vue | 0 .../ui/dropdown-menu/DropdownMenuGroup.vue | 0 .../ui/dropdown-menu/DropdownMenuItem.vue | 0 .../ui/dropdown-menu/DropdownMenuLabel.vue | 0 .../dropdown-menu/DropdownMenuRadioGroup.vue | 0 .../dropdown-menu/DropdownMenuRadioItem.vue | 0 .../dropdown-menu/DropdownMenuSeparator.vue | 0 .../ui/dropdown-menu/DropdownMenuShortcut.vue | 0 .../ui/dropdown-menu/DropdownMenuSub.vue | 0 .../dropdown-menu/DropdownMenuSubContent.vue | 0 .../dropdown-menu/DropdownMenuSubTrigger.vue | 0 .../ui/dropdown-menu/DropdownMenuTrigger.vue | 0 .../app}/components/ui/dropdown-menu/index.ts | 0 .../duxt/app}/components/ui/input/Input.vue | 0 .../duxt/app}/components/ui/input/index.ts | 0 .../ui/navigation-menu/NavigationMenu.vue | 0 .../navigation-menu/NavigationMenuContent.vue | 0 .../NavigationMenuIndicator.vue | 0 .../ui/navigation-menu/NavigationMenuItem.vue | 0 .../ui/navigation-menu/NavigationMenuLink.vue | 0 .../ui/navigation-menu/NavigationMenuList.vue | 0 .../navigation-menu/NavigationMenuTrigger.vue | 0 .../NavigationMenuViewport.vue | 0 .../components/ui/navigation-menu/index.ts | 0 .../components/ui/scroll-area/ScrollArea.vue | 0 .../components/ui/scroll-area/ScrollBar.vue | 0 .../app}/components/ui/scroll-area/index.ts | 0 .../duxt/app}/components/ui/select/Select.vue | 0 .../components/ui/select/SelectContent.vue | 0 .../app}/components/ui/select/SelectGroup.vue | 0 .../app}/components/ui/select/SelectItem.vue | 0 .../components/ui/select/SelectItemText.vue | 0 .../app}/components/ui/select/SelectLabel.vue | 0 .../ui/select/SelectScrollDownButton.vue | 0 .../ui/select/SelectScrollUpButton.vue | 0 .../components/ui/select/SelectSeparator.vue | 0 .../components/ui/select/SelectTrigger.vue | 0 .../app}/components/ui/select/SelectValue.vue | 0 .../duxt/app}/components/ui/select/index.ts | 0 .../components/ui/separator/Separator.vue | 0 .../app}/components/ui/separator/index.ts | 0 .../duxt/app}/components/ui/sheet/Sheet.vue | 0 .../app}/components/ui/sheet/SheetClose.vue | 0 .../app}/components/ui/sheet/SheetContent.vue | 0 .../components/ui/sheet/SheetDescription.vue | 0 .../app}/components/ui/sheet/SheetFooter.vue | 0 .../app}/components/ui/sheet/SheetHeader.vue | 0 .../app}/components/ui/sheet/SheetOverlay.vue | 0 .../app}/components/ui/sheet/SheetTitle.vue | 0 .../app}/components/ui/sheet/SheetTrigger.vue | 0 .../duxt/app}/components/ui/sheet/index.ts | 0 .../app}/components/ui/sidebar/Sidebar.vue | 0 .../components/ui/sidebar/SidebarContent.vue | 0 .../components/ui/sidebar/SidebarFooter.vue | 0 .../components/ui/sidebar/SidebarGroup.vue | 0 .../ui/sidebar/SidebarGroupAction.vue | 0 .../ui/sidebar/SidebarGroupContent.vue | 0 .../ui/sidebar/SidebarGroupLabel.vue | 0 .../components/ui/sidebar/SidebarHeader.vue | 0 .../components/ui/sidebar/SidebarInput.vue | 0 .../components/ui/sidebar/SidebarInset.vue | 0 .../components/ui/sidebar/SidebarMenu.vue | 0 .../ui/sidebar/SidebarMenuAction.vue | 0 .../ui/sidebar/SidebarMenuBadge.vue | 0 .../ui/sidebar/SidebarMenuButton.vue | 0 .../ui/sidebar/SidebarMenuButtonChild.vue | 0 .../components/ui/sidebar/SidebarMenuItem.vue | 0 .../ui/sidebar/SidebarMenuSkeleton.vue | 0 .../components/ui/sidebar/SidebarMenuSub.vue | 0 .../ui/sidebar/SidebarMenuSubButton.vue | 0 .../ui/sidebar/SidebarMenuSubItem.vue | 0 .../components/ui/sidebar/SidebarProvider.vue | 0 .../components/ui/sidebar/SidebarRail.vue | 0 .../ui/sidebar/SidebarSeparator.vue | 0 .../components/ui/sidebar/SidebarTrigger.vue | 0 .../duxt/app}/components/ui/sidebar/index.ts | 0 .../duxt/app}/components/ui/sidebar/utils.ts | 0 .../app}/components/ui/skeleton/Skeleton.vue | 0 .../duxt/app}/components/ui/skeleton/index.ts | 0 .../duxt/app}/components/ui/sonner/Sonner.vue | 0 .../duxt/app}/components/ui/sonner/index.ts | 0 .../duxt/app}/components/ui/tabs/Tabs.vue | 0 .../app}/components/ui/tabs/TabsContent.vue | 0 .../duxt/app}/components/ui/tabs/TabsList.vue | 0 .../app}/components/ui/tabs/TabsTrigger.vue | 0 .../duxt/app}/components/ui/tabs/index.ts | 0 .../app}/components/ui/textarea/Textarea.vue | 0 .../duxt/app}/components/ui/textarea/index.ts | 0 .../app}/components/ui/tooltip/Tooltip.vue | 0 .../components/ui/tooltip/TooltipContent.vue | 0 .../components/ui/tooltip/TooltipProvider.vue | 0 .../components/ui/tooltip/TooltipTrigger.vue | 0 .../duxt/app}/components/ui/tooltip/index.ts | 0 .../duxt/app}/composables/useActiveHeading.ts | 0 .../duxt/app}/composables/useDuxtAnalytics.ts | 0 .../app}/composables/useDuxtAnimatedHeight.ts | 0 .../app}/composables/useDuxtAnnouncements.ts | 0 .../app}/composables/useDuxtBreadcrumb.ts | 0 .../duxt/app}/composables/useDuxtChoice.ts | 0 .../app}/composables/useDuxtCollection.ts | 2 +- .../duxt/app}/composables/useDuxtConfig.ts | 0 .../duxt/app}/composables/useDuxtCopy.ts | 0 .../duxt/app}/composables/useDuxtDirection.ts | 0 .../app}/composables/useDuxtNavigation.ts | 2 +- .../duxt/app}/composables/useDuxtPageFocus.ts | 0 .../duxt/app}/composables/useDuxtPath.ts | 0 .../duxt/app}/composables/useDuxtSearch.ts | 2 +- .../duxt/app}/composables/useDuxtSection.ts | 0 .../app}/composables/useDuxtSectionRow.ts | 0 .../duxt/app}/composables/useDuxtShortcuts.ts | 0 .../duxt/app}/composables/useDuxtSiteUrl.ts | 0 .../app}/composables/useDuxtThemeToggle.ts | 0 .../duxt/app}/composables/useDuxtToast.ts | 0 .../duxt/app}/composables/useDuxtVersion.ts | 0 .../composables/useDuxtViewportMeasure.ts | 0 .../duxt/app}/composables/useFuzzySearch.ts | 0 .../app}/composables/usePackageManager.ts | 0 .../duxt/app}/composables/useRecentPages.ts | 0 {app => packages/duxt/app}/error.vue | 0 .../duxt/app}/layouts/changelog.vue | 0 .../duxt/app}/layouts/default.vue | 0 {app => packages/duxt/app}/layouts/docs.vue | 0 .../duxt/app}/layouts/reference.vue | 0 {app => packages/duxt/app}/lib/utils.ts | 0 .../middleware/duxt-section-layout.global.ts | 0 .../duxt/app}/pages/[...slug].vue | 0 {app => packages/duxt/app}/pages/index.vue | 0 {app => packages/duxt/app}/types/duxt.d.ts | 0 {app => packages/duxt/app}/utils/analytics.ts | 0 .../duxt/app}/utils/announcements.ts | 0 {app => packages/duxt/app}/utils/aside.ts | 0 {app => packages/duxt/app}/utils/changelog.ts | 0 .../duxt/app}/utils/code-highlight.ts | 0 .../duxt/app}/utils/contributor-avatar.ts | 0 {app => packages/duxt/app}/utils/direction.ts | 0 .../duxt/app}/utils/documentation-link.ts | 0 .../duxt/app}/utils/duxt-config.ts | 10 +- {app => packages/duxt/app}/utils/duxt-text.ts | 0 .../duxt/app}/utils/file-icons.ts | 0 .../duxt/app}/utils/format-date.ts | 0 .../duxt/app}/utils/generated-sections.ts | 0 .../duxt/app}/utils/generated-toc.ts | 0 .../duxt/app}/utils/heading-link.ts | 0 .../duxt/app}/utils/locale-path.ts | 0 .../duxt/app}/utils/navigation-tree.ts | 0 .../duxt/app}/utils/nearest-page.ts | 0 {app => packages/duxt/app}/utils/openapi.ts | 2 +- .../duxt/app}/utils/package-command.ts | 0 .../duxt/app}/utils/page-controls.ts | 0 {app => packages/duxt/app}/utils/pill.ts | 0 .../duxt/app}/utils/prose-image.ts | 0 .../duxt/app}/utils/request-samples.ts | 0 .../duxt/app}/utils/search-context.ts | 0 .../duxt/app}/utils/search-display.ts | 0 .../duxt/app}/utils/search-scope.ts | 2 +- .../duxt/app}/utils/section-scope.ts | 0 .../duxt/app}/utils/sheet-navigation.ts | 0 .../duxt/app}/utils/source-file.ts | 0 .../duxt/app}/utils/stored-json.ts | 0 .../duxt/app}/utils/version-choices.ts | 5 +- .../duxt/app}/utils/version-paths.ts | 2 +- {bin => packages/duxt/bin}/duxt-cache-key.mjs | 5 +- {bin => packages/duxt/bin}/duxt-og-cache.mjs | 4 +- {bin => packages/duxt/bin}/duxt.mjs | 4 +- .../duxt/build/bruno/bruno-model.ts | 0 .../duxt/build/bruno/bruno-parse.ts | 2 +- .../duxt/build/bruno/bruno-zip.ts | 2 +- cli.ts => packages/duxt/build/cli/cli.ts | 0 .../duxt/build/cli/report.ts | 14 +- .../duxt/build/cli/validate-report.ts | 6 +- .../duxt/build/config/duxt-app-config.ts | 7 +- .../duxt/build/content/content-cache.ts | 0 .../duxt/build/content/frontmatter.ts | 0 .../duxt/build/content/highlight-langs.ts | 0 .../duxt/build/git/git-contributors.ts | 0 .../duxt/build/og-image/og-image-cache.ts | 0 .../duxt/build/openapi/openapi-model.ts | 0 .../duxt/build/openapi/openapi-parse.ts | 0 .../duxt/build/search/search-records.ts | 6 +- .../duxt/build/sections/section-input.ts | 0 .../duxt/build/sections/section-markdown.ts | 0 .../duxt/build/sections/section-reports.ts | 4 +- .../duxt/build/sections/sections-bruno.ts | 8 +- .../duxt/build/sections/sections-changelog.ts | 8 +- .../duxt/build/sections/sections-openapi.ts | 6 +- .../duxt/build/sections/sections-resolve.ts | 4 +- .../duxt/build/sections/sections.ts | 8 +- .../duxt/build/sources/repository-root.ts | 0 .../duxt/build/sources/sources-cache.ts | 4 +- .../duxt/build/sources/sources-git.ts | 0 .../duxt/build/sources/sources-resolve.ts | 2 +- .../duxt/build/sources/sources.ts | 0 .../duxt/build/sources/tfplugindocs.ts | 0 .../duxt/components.json | 0 .../duxt/content.config.ts | 8 +- {i18n => packages/duxt/i18n}/i18n.config.ts | 0 .../i18n}/locales/de/duxt/announcement.json | 0 .../duxt/i18n}/locales/de/duxt/bruno.json | 0 .../duxt/i18n}/locales/de/duxt/changelog.json | 0 .../duxt/i18n}/locales/de/duxt/code.json | 0 .../duxt/i18n}/locales/de/duxt/defaults.json | 0 .../duxt/i18n}/locales/de/duxt/devtools.json | 0 .../duxt/i18n}/locales/de/duxt/error.json | 0 .../duxt/i18n}/locales/de/duxt/footer.json | 0 .../duxt/i18n}/locales/de/duxt/locale.json | 0 .../duxt/i18n}/locales/de/duxt/nav.json | 0 .../duxt/i18n}/locales/de/duxt/openapi.json | 0 .../duxt/i18n}/locales/de/duxt/page.json | 0 .../duxt/i18n}/locales/de/duxt/search.json | 0 .../duxt/i18n}/locales/de/duxt/shortcuts.json | 0 .../duxt/i18n}/locales/de/duxt/theme.json | 0 .../duxt/i18n}/locales/de/duxt/toc.json | 0 .../duxt/i18n}/locales/de/duxt/version.json | 0 .../i18n}/locales/en/duxt/announcement.json | 0 .../duxt/i18n}/locales/en/duxt/bruno.json | 0 .../duxt/i18n}/locales/en/duxt/changelog.json | 0 .../duxt/i18n}/locales/en/duxt/code.json | 0 .../duxt/i18n}/locales/en/duxt/defaults.json | 0 .../duxt/i18n}/locales/en/duxt/devtools.json | 0 .../duxt/i18n}/locales/en/duxt/error.json | 0 .../duxt/i18n}/locales/en/duxt/footer.json | 0 .../duxt/i18n}/locales/en/duxt/locale.json | 0 .../duxt/i18n}/locales/en/duxt/nav.json | 0 .../duxt/i18n}/locales/en/duxt/openapi.json | 0 .../duxt/i18n}/locales/en/duxt/page.json | 0 .../duxt/i18n}/locales/en/duxt/search.json | 0 .../duxt/i18n}/locales/en/duxt/shortcuts.json | 0 .../duxt/i18n}/locales/en/duxt/theme.json | 0 .../duxt/i18n}/locales/en/duxt/toc.json | 0 .../duxt/i18n}/locales/en/duxt/version.json | 0 .../i18n}/locales/es/duxt/announcement.json | 0 .../duxt/i18n}/locales/es/duxt/bruno.json | 0 .../duxt/i18n}/locales/es/duxt/changelog.json | 0 .../duxt/i18n}/locales/es/duxt/code.json | 0 .../duxt/i18n}/locales/es/duxt/defaults.json | 0 .../duxt/i18n}/locales/es/duxt/devtools.json | 0 .../duxt/i18n}/locales/es/duxt/error.json | 0 .../duxt/i18n}/locales/es/duxt/footer.json | 0 .../duxt/i18n}/locales/es/duxt/locale.json | 0 .../duxt/i18n}/locales/es/duxt/nav.json | 0 .../duxt/i18n}/locales/es/duxt/openapi.json | 0 .../duxt/i18n}/locales/es/duxt/page.json | 0 .../duxt/i18n}/locales/es/duxt/search.json | 0 .../duxt/i18n}/locales/es/duxt/shortcuts.json | 0 .../duxt/i18n}/locales/es/duxt/theme.json | 0 .../duxt/i18n}/locales/es/duxt/toc.json | 0 .../duxt/i18n}/locales/es/duxt/version.json | 0 .../i18n}/locales/fr/duxt/announcement.json | 0 .../duxt/i18n}/locales/fr/duxt/bruno.json | 0 .../duxt/i18n}/locales/fr/duxt/changelog.json | 0 .../duxt/i18n}/locales/fr/duxt/code.json | 0 .../duxt/i18n}/locales/fr/duxt/defaults.json | 0 .../duxt/i18n}/locales/fr/duxt/devtools.json | 0 .../duxt/i18n}/locales/fr/duxt/error.json | 0 .../duxt/i18n}/locales/fr/duxt/footer.json | 0 .../duxt/i18n}/locales/fr/duxt/locale.json | 0 .../duxt/i18n}/locales/fr/duxt/nav.json | 0 .../duxt/i18n}/locales/fr/duxt/openapi.json | 0 .../duxt/i18n}/locales/fr/duxt/page.json | 0 .../duxt/i18n}/locales/fr/duxt/search.json | 0 .../duxt/i18n}/locales/fr/duxt/shortcuts.json | 0 .../duxt/i18n}/locales/fr/duxt/theme.json | 0 .../duxt/i18n}/locales/fr/duxt/toc.json | 0 .../duxt/i18n}/locales/fr/duxt/version.json | 0 .../duxt/i18n}/locales/pt-BR/duxt/error.json | 0 .../duxt/i18n}/locales/pt-BR/duxt/nav.json | 0 .../duxt/i18n}/locales/pt-BR/duxt/page.json | 0 .../duxt/i18n}/locales/pt-BR/duxt/search.json | 0 .../i18n}/locales/pt-BR/duxt/version.json | 0 .../i18n}/locales/pt/duxt/announcement.json | 0 .../duxt/i18n}/locales/pt/duxt/bruno.json | 0 .../duxt/i18n}/locales/pt/duxt/changelog.json | 0 .../duxt/i18n}/locales/pt/duxt/code.json | 0 .../duxt/i18n}/locales/pt/duxt/defaults.json | 0 .../duxt/i18n}/locales/pt/duxt/devtools.json | 0 .../duxt/i18n}/locales/pt/duxt/error.json | 0 .../duxt/i18n}/locales/pt/duxt/footer.json | 0 .../duxt/i18n}/locales/pt/duxt/locale.json | 0 .../duxt/i18n}/locales/pt/duxt/nav.json | 0 .../duxt/i18n}/locales/pt/duxt/openapi.json | 0 .../duxt/i18n}/locales/pt/duxt/page.json | 0 .../duxt/i18n}/locales/pt/duxt/search.json | 0 .../duxt/i18n}/locales/pt/duxt/shortcuts.json | 0 .../duxt/i18n}/locales/pt/duxt/theme.json | 0 .../duxt/i18n}/locales/pt/duxt/toc.json | 0 .../duxt/i18n}/locales/pt/duxt/version.json | 0 mdc.config.ts => packages/duxt/mdc.config.ts | 0 {modules => packages/duxt/modules}/bruno.ts | 18 +- {modules => packages/duxt/modules}/config.ts | 15 +- .../duxt/modules}/devtools.ts | 0 .../duxt/modules}/git-meta.ts | 15 +- .../duxt/modules}/redirects.ts | 11 +- .../duxt/modules}/search-records.ts | 8 +- .../duxt/modules}/validate.ts | 15 +- .../duxt/nuxt.config.ts | 2 +- packages/duxt/package.json | 132 ++ .../duxt/public}/devtools/cache.html | 0 .../duxt/public}/devtools/checks.html | 0 .../duxt/public}/devtools/config.html | 0 .../duxt/public}/devtools/i18n.html | 0 .../duxt/public}/devtools/pages.html | 0 .../duxt/public}/devtools/paths.html | 0 .../duxt/public}/devtools/redirects.html | 0 .../duxt/public}/devtools/search.html | 0 .../duxt/public}/devtools/sources.html | 0 .../duxt/public}/devtools/versions.html | 0 .../duxt/scripts}/build-devtools-previews.ts | 0 .../duxt/scripts}/check-previews.ts | 0 .../duxt/server}/devtools/content.ts | 4 +- .../duxt/server}/devtools/context.ts | 0 .../duxt/server}/devtools/entry-path.ts | 0 .../duxt/server}/devtools/handler.ts | 0 .../duxt/server}/devtools/preview.ts | 6 +- .../duxt/server}/devtools/render/content.ts | 4 +- .../duxt/server}/devtools/render/sources.ts | 5 +- .../duxt/server}/devtools/render/system.ts | 0 .../duxt/server}/devtools/shell.ts | 0 .../duxt/server}/devtools/sources.ts | 2 +- .../duxt/server}/devtools/system.ts | 0 .../duxt/server}/mcp/tools/list-pages.ts | 0 .../duxt/server}/mcp/tools/list-versions.ts | 0 .../duxt/server}/mcp/tools/read-page.ts | 2 +- .../duxt/server}/mcp/tools/search-docs.ts | 0 .../duxt/server}/middleware/raw-markdown.ts | 4 +- .../duxt/server}/routes/llms-full.txt.get.ts | 2 +- .../duxt/server}/routes/llms.txt.get.ts | 0 .../duxt/server}/routes/rss.xml.get.ts | 0 .../duxt/server}/utils/duxt-server-text.ts | 0 .../duxt/server}/utils/llms-pages.ts | 2 +- .../duxt/server}/utils/mcp-docs.ts | 4 +- .../duxt/tests}/analytics.test.ts | 0 .../duxt/tests}/announcements.test.ts | 0 .../duxt/tests}/async-data-context.test.ts | 0 .../duxt/tests}/bruno-parse.test.ts | 4 +- .../duxt/tests}/bruno-zip.test.ts | 4 +- .../duxt/tests}/changelog.test.ts | 0 {tests => packages/duxt/tests}/cli.test.ts | 4 +- .../duxt/tests}/collections.test.ts | 9 +- .../duxt/tests}/content-cache-wal.test.ts | 2 +- .../duxt/tests}/content-database.test.ts | 15 +- .../duxt/tests}/contrast.test.ts | 0 .../duxt/tests}/contributor-avatar.test.ts | 0 .../duxt/tests}/devtools-cache.test.ts | 0 .../duxt/tests}/devtools-versions.test.ts | 2 +- .../duxt/tests}/direction-utilities.test.ts | 0 .../duxt/tests}/direction.test.ts | 0 .../duxt/tests}/documentation-link.test.ts | 0 .../duxt/tests}/duxt-app-config.test.ts | 2 +- .../duxt/tests}/duxt-config.test.ts | 0 .../duxt/tests}/duxt-text.test.ts | 0 .../duxt/tests}/file-icons.test.ts | 0 .../tfplugindocs/docs/functions/lookup.md | 0 .../docs/future-things/an-example.md | 0 .../docs/guides/getting-started.md | 0 .../fixtures/tfplugindocs/docs/index.md | 0 .../tfplugindocs/docs/resources/account.md | 0 .../tfplugindocs/docs/resources/team.md | 0 .../tfplugindocs/docs/resources/user.md | 0 .../duxt/tests}/format-date.test.ts | 0 .../duxt/tests}/frontmatter-yaml.test.ts | 11 +- .../duxt/tests}/frontmatter.test.ts | 5 +- .../duxt/tests}/generated-sections.test.ts | 0 .../duxt/tests}/generated-toc.test.ts | 0 .../duxt/tests}/git-contributors.test.ts | 2 +- .../duxt/tests}/git-meta.test.ts | 2 +- .../duxt/tests}/header-tooltips.test.ts | 0 .../duxt/tests}/heading-link.test.ts | 0 .../duxt/tests}/highlight-langs.test.ts | 2 +- .../duxt/tests}/i18n-ownership.test.ts | 2 +- .../duxt/tests}/i18n-parity.test.ts | 0 .../duxt/tests}/layer-dependencies.test.ts | 2 +- .../duxt/tests}/llms-exports.test.ts | 2 +- .../duxt/tests}/locale-path.test.ts | 0 .../duxt/tests}/mcp-tools.test.ts | 2 +- .../duxt/tests}/navigation-tree.test.ts | 0 .../duxt/tests}/nearest-page.test.ts | 0 .../duxt/tests}/og-image-cache.test.ts | 2 +- .../tests}/openapi-operation-props.test.ts | 0 .../duxt/tests}/openapi-parse.test.ts | 2 +- .../duxt/tests}/openapi-utils.test.ts | 2 +- .../duxt/tests}/package-command.test.ts | 0 .../duxt/tests}/package-files.test.ts | 48 +- .../duxt/tests}/page-controls.test.ts | 0 .../duxt/tests}/page-icon.test.ts | 0 .../duxt/tests}/page-schema-database.test.ts | 2 +- {tests => packages/duxt/tests}/pill.test.ts | 0 .../duxt/tests}/prose-image.test.ts | 0 .../duxt/tests}/raw-markdown.test.ts | 2 +- .../duxt/tests}/redirects.test.ts | 0 {tests => packages/duxt/tests}/report.test.ts | 4 +- .../duxt/tests}/request-samples.test.ts | 0 .../duxt/tests}/search-context.test.ts | 0 .../duxt/tests}/search-display.test.ts | 5 +- .../duxt/tests}/search-records.test.ts | 8 +- .../duxt/tests}/search-scope.test.ts | 5 +- {tests => packages/duxt/tests}/search.test.ts | 2 +- .../duxt/tests}/section-markdown.test.ts | 6 +- .../duxt/tests}/section-scope.test.ts | 0 .../duxt/tests}/sections-bruno.test.ts | 9 +- .../duxt/tests}/sections-changelog.test.ts | 6 +- .../duxt/tests}/sections-openapi.test.ts | 7 +- .../duxt/tests}/sections-resolve.test.ts | 11 +- .../duxt/tests}/sheet-navigation.test.ts | 0 .../duxt/tests}/shortcuts-surface.test.ts | 3 +- .../duxt/tests}/shortcuts.test.ts | 0 .../duxt/tests}/showcase-rows.test.ts | 0 .../duxt/tests}/source-file.test.ts | 0 .../duxt/tests}/sources-cache.test.ts | 4 +- .../duxt/tests}/sources-collections.test.ts | 8 +- .../duxt/tests}/sources-git.test.ts | 4 +- .../duxt/tests}/sources-locales.test.ts | 2 +- .../duxt/tests}/sources-resolve.test.ts | 6 +- .../duxt/tests}/sources-versions.test.ts | 2 +- .../duxt/tests}/sqlite-connector.test.ts | 0 .../duxt/tests}/stored-json.test.ts | 0 .../duxt/tests}/tfplugindocs.test.ts | 2 +- .../duxt/tests}/validate.test.ts | 2 +- .../duxt/tests}/version-choices.test.ts | 2 +- .../duxt/tests}/version-paths.test.ts | 2 +- .../duxt/vitest.config.ts | 2 +- pnpm-lock.yaml | 1104 +++++------------ pnpm-workspace.yaml | 63 +- release-please-config.json | 8 +- taze.config.ts | 3 + tsconfig.json | 4 +- turbo.json | 63 + 642 files changed, 1341 insertions(+), 1376 deletions(-) rename {www => apps/www}/.env.example (100%) rename {www => apps/www}/app/app.config.ts (99%) rename {www => apps/www}/app/assets/css/brand.css (100%) rename {www => apps/www}/demo/CHANGELOG.md (100%) rename {www => apps/www}/demo/collection/1.ping.bru (100%) rename {www => apps/www}/demo/collection/2.signed.bru (100%) rename {www => apps/www}/demo/collection/bruno.json (100%) rename {www => apps/www}/demo/collection/collection.bru (100%) rename {www => apps/www}/demo/collection/consignments/1.create.bru (100%) rename {www => apps/www}/demo/collection/consignments/2.list.bru (100%) rename {www => apps/www}/demo/collection/consignments/3.get.bru (100%) rename {www => apps/www}/demo/collection/consignments/folder.bru (100%) rename {www => apps/www}/demo/collection/environments/local.bru (100%) rename {www => apps/www}/demo/collection/tracking/1.events.bru (100%) rename {www => apps/www}/demo/collection/tracking/folder.bru (100%) rename {www => apps/www}/demo/docs/1.versions.md (100%) rename {www => apps/www}/demo/docs/2.source.md (100%) rename {www => apps/www}/demo/docs/3.images.md (100%) rename {www => apps/www}/demo/docs/index.md (100%) rename {www => apps/www}/demo/main.yaml (100%) rename {www => apps/www}/demo/v1.yaml (100%) rename {www => apps/www}/demo/v2.yaml (100%) rename {www => apps/www}/demo/v3.yaml (99%) rename {www => apps/www}/modules/search-records.ts (100%) rename {www => apps/www}/nuxt.config.ts (96%) rename {www => apps/www}/package.json (59%) rename {www => apps/www}/public/apple-touch-icon.png (100%) rename {www => apps/www}/public/demo/badge.png (100%) rename {www => apps/www}/public/demo/ornament.png (100%) rename {www => apps/www}/public/demo/pipeline.svg (100%) rename {www => apps/www}/public/demo/screenshot-dark.png (100%) rename {www => apps/www}/public/demo/screenshot.png (100%) rename {www => apps/www}/public/demo/spinner.gif (100%) rename {www => apps/www}/public/favicon.svg (100%) rename {www => apps/www}/public/wordmark-dark.svg (100%) rename {www => apps/www}/public/wordmark.svg (100%) rename {scripts => apps/www/scripts}/browser.ts (100%) rename {scripts => apps/www/scripts}/built-server.ts (98%) rename {scripts => apps/www/scripts}/check-a11y.ts (100%) rename {scripts => apps/www/scripts}/check-adapters.ts (99%) rename {scripts => apps/www/scripts}/check-images.ts (99%) rename {scripts => apps/www/scripts}/check-keyboard.ts (100%) rename {scripts => apps/www/scripts}/check-overflow.ts (100%) rename {scripts => apps/www/scripts}/check-routes.ts (98%) rename {scripts => apps/www/scripts}/check-seo.ts (100%) rename {scripts => apps/www/scripts}/deploy-summary.ts (100%) rename {scripts => apps/www/scripts}/nuxt-process-guard.ts (100%) rename {scripts => apps/www/scripts}/prerender-bench.ts (99%) rename {scripts => apps/www/scripts}/search-index-bench.ts (99%) rename {www => apps/www}/server/routes/demo/echo.ts (93%) rename {www => apps/www}/server/routes/demo/echo/[...path].ts (100%) rename {tests => apps/www/tests}/check-images.test.ts (100%) rename {tests => apps/www/tests}/check-routes.test.ts (100%) rename {tests => apps/www/tests}/check-seo.test.ts (95%) rename {tests => apps/www/tests}/deploy-summary.test.ts (100%) rename {tests => apps/www/tests}/deploy-workflow.test.ts (98%) rename {tests => apps/www/tests}/docs-locales.test.ts (87%) rename {tests => apps/www/tests}/fixtures/nuxt-ownership/process.ts (100%) rename {tests => apps/www/tests}/fixtures/seo-server.mjs (100%) rename {tests => apps/www/tests}/git-dir-guard.test.ts (98%) rename {tests => apps/www/tests}/nuxt-process-guard.test.ts (93%) rename {tests => apps/www/tests}/prerender-bench.test.ts (99%) rename {tests => apps/www/tests}/search-index-bench.test.ts (96%) rename {tests => apps/www/tests}/seo-fixture.test.ts (100%) rename {www => apps/www}/tsconfig.json (100%) create mode 100644 apps/www/vitest.config.ts rename {www => apps/www}/wrangler.jsonc (95%) rename {app => packages/duxt/app}/app.config.ts (100%) rename {app => packages/duxt/app}/app.vue (100%) rename {app => packages/duxt/app}/assets/css/duxt.css (100%) rename {app => packages/duxt/app}/assets/css/typeset.css (100%) rename {app => packages/duxt/app}/components/DuxtAnnouncements.vue (100%) rename {app => packages/duxt/app}/components/DuxtBrand.vue (95%) rename {app => packages/duxt/app}/components/DuxtBreadcrumb.vue (100%) rename {app => packages/duxt/app}/components/DuxtBrunoEntries.vue (96%) rename {app => packages/duxt/app}/components/DuxtCodeBlock.vue (100%) rename {app => packages/duxt/app}/components/DuxtCodeToolbar.vue (100%) rename {app => packages/duxt/app}/components/DuxtCopyButton.vue (100%) rename {app => packages/duxt/app}/components/DuxtCopyPage.vue (98%) rename {app => packages/duxt/app}/components/DuxtFooter.vue (100%) rename {app => packages/duxt/app}/components/DuxtHeader.vue (100%) rename {app => packages/duxt/app}/components/DuxtJsonEditor.vue (100%) rename {app => packages/duxt/app}/components/DuxtLandingProse.vue (100%) rename {app => packages/duxt/app}/components/DuxtLandingShowcase.vue (99%) rename {app => packages/duxt/app}/components/DuxtLinkLabel.vue (100%) rename {app => packages/duxt/app}/components/DuxtLiveWindow.vue (100%) rename {app => packages/duxt/app}/components/DuxtLocale.vue (100%) rename {app => packages/duxt/app}/components/DuxtMetaList.vue (100%) rename {app => packages/duxt/app}/components/DuxtNavigation.vue (100%) rename {app => packages/duxt/app}/components/DuxtNavigationLink.vue (100%) rename {app => packages/duxt/app}/components/DuxtOpenApiCallbacks.vue (97%) rename {app => packages/duxt/app}/components/DuxtOpenApiClient.vue (99%) rename {app => packages/duxt/app}/components/DuxtOpenApiMedia.vue (97%) rename {app => packages/duxt/app}/components/DuxtOpenApiMethod.vue (100%) rename {app => packages/duxt/app}/components/DuxtOpenApiParameters.vue (97%) rename {app => packages/duxt/app}/components/DuxtOpenApiResponses.vue (98%) rename {app => packages/duxt/app}/components/DuxtOpenApiSchema.vue (99%) rename {app => packages/duxt/app}/components/DuxtOpenApiSecurity.vue (98%) rename {app => packages/duxt/app}/components/DuxtPageFeedback.vue (100%) rename {app => packages/duxt/app}/components/DuxtPageInfo.vue (100%) rename {app => packages/duxt/app}/components/DuxtPageNav.vue (100%) rename {app => packages/duxt/app}/components/DuxtPreviewSkeleton.vue (100%) rename {app => packages/duxt/app}/components/DuxtProgress.vue (100%) rename {app => packages/duxt/app}/components/DuxtSearch.vue (100%) rename {app => packages/duxt/app}/components/DuxtSearchInput.vue (100%) rename {app => packages/duxt/app}/components/DuxtSections.vue (100%) rename {app => packages/duxt/app}/components/DuxtSegmented.vue (100%) rename {app => packages/duxt/app}/components/DuxtShortcuts.vue (100%) rename {app => packages/duxt/app}/components/DuxtShortcutsTrigger.vue (100%) rename {app => packages/duxt/app}/components/DuxtSkipLink.vue (100%) rename {app => packages/duxt/app}/components/DuxtToc.vue (100%) rename {app => packages/duxt/app}/components/DuxtTranslationBanner.vue (100%) rename {app => packages/duxt/app}/components/DuxtVersion.vue (100%) rename {app => packages/duxt/app}/components/DuxtVersionBanner.vue (100%) rename {app => packages/duxt/app}/components/OgImage/Duxt.satori.vue (100%) rename {app => packages/duxt/app}/components/content/Accordion.vue (100%) rename {app => packages/duxt/app}/components/content/AccordionItem.vue (100%) rename {app => packages/duxt/app}/components/content/BrunoOverview.vue (99%) rename {app => packages/duxt/app}/components/content/BrunoRequest.vue (97%) rename {app => packages/duxt/app}/components/content/BrunoRequests.vue (100%) rename {app => packages/duxt/app}/components/content/Callout.vue (100%) rename {app => packages/duxt/app}/components/content/ChangelogGroup.vue (100%) rename {app => packages/duxt/app}/components/content/ChangelogReleases.vue (100%) rename {app => packages/duxt/app}/components/content/CodeGroup.vue (100%) rename {app => packages/duxt/app}/components/content/DevtoolsPanel.vue (100%) rename {app => packages/duxt/app}/components/content/Field.vue (100%) rename {app => packages/duxt/app}/components/content/FieldGroup.vue (100%) rename {app => packages/duxt/app}/components/content/FileTree.vue (100%) rename {app => packages/duxt/app}/components/content/Mermaid.vue (100%) rename {app => packages/duxt/app}/components/content/OpenApiOperation.vue (99%) rename {app => packages/duxt/app}/components/content/OpenApiOperations.vue (96%) rename {app => packages/duxt/app}/components/content/OpenApiOverview.vue (99%) rename {app => packages/duxt/app}/components/content/PackageManagers.vue (100%) rename {app => packages/duxt/app}/components/content/PageCards.vue (100%) rename {app => packages/duxt/app}/components/content/Partial.vue (96%) rename {app => packages/duxt/app}/components/content/Preview.vue (100%) rename {app => packages/duxt/app}/components/content/ProseA.vue (100%) rename {app => packages/duxt/app}/components/content/ProseH2.vue (100%) rename {app => packages/duxt/app}/components/content/ProseH3.vue (100%) rename {app => packages/duxt/app}/components/content/ProseH4.vue (100%) rename {app => packages/duxt/app}/components/content/ProseImg.vue (99%) rename {app => packages/duxt/app}/components/content/ProsePre.vue (100%) rename {app => packages/duxt/app}/components/content/ProseTable.vue (100%) rename {app => packages/duxt/app}/components/content/Since.vue (100%) rename {app => packages/duxt/app}/components/content/Steps.vue (100%) rename {app => packages/duxt/app}/components/content/Tab.vue (100%) rename {app => packages/duxt/app}/components/content/TabGroup.vue (100%) rename {app => packages/duxt/app}/components/ui/alert/Alert.vue (100%) rename {app => packages/duxt/app}/components/ui/alert/AlertDescription.vue (100%) rename {app => packages/duxt/app}/components/ui/alert/AlertTitle.vue (100%) rename {app => packages/duxt/app}/components/ui/alert/index.ts (100%) rename {app => packages/duxt/app}/components/ui/badge/Badge.vue (100%) rename {app => packages/duxt/app}/components/ui/badge/index.ts (100%) rename {app => packages/duxt/app}/components/ui/breadcrumb/Breadcrumb.vue (100%) rename {app => packages/duxt/app}/components/ui/breadcrumb/BreadcrumbEllipsis.vue (100%) rename {app => packages/duxt/app}/components/ui/breadcrumb/BreadcrumbItem.vue (100%) rename {app => packages/duxt/app}/components/ui/breadcrumb/BreadcrumbLink.vue (100%) rename {app => packages/duxt/app}/components/ui/breadcrumb/BreadcrumbList.vue (100%) rename {app => packages/duxt/app}/components/ui/breadcrumb/BreadcrumbPage.vue (100%) rename {app => packages/duxt/app}/components/ui/breadcrumb/BreadcrumbSeparator.vue (100%) rename {app => packages/duxt/app}/components/ui/breadcrumb/index.ts (100%) rename {app => packages/duxt/app}/components/ui/button/Button.vue (100%) rename {app => packages/duxt/app}/components/ui/button/index.ts (100%) rename {app => packages/duxt/app}/components/ui/card/Card.vue (100%) rename {app => packages/duxt/app}/components/ui/card/CardAction.vue (100%) rename {app => packages/duxt/app}/components/ui/card/CardContent.vue (100%) rename {app => packages/duxt/app}/components/ui/card/CardDescription.vue (100%) rename {app => packages/duxt/app}/components/ui/card/CardFooter.vue (100%) rename {app => packages/duxt/app}/components/ui/card/CardHeader.vue (100%) rename {app => packages/duxt/app}/components/ui/card/CardTitle.vue (100%) rename {app => packages/duxt/app}/components/ui/card/index.ts (100%) rename {app => packages/duxt/app}/components/ui/collapsible/Collapsible.vue (100%) rename {app => packages/duxt/app}/components/ui/collapsible/CollapsibleContent.vue (100%) rename {app => packages/duxt/app}/components/ui/collapsible/CollapsibleTrigger.vue (100%) rename {app => packages/duxt/app}/components/ui/collapsible/index.ts (100%) rename {app => packages/duxt/app}/components/ui/command/Command.vue (100%) rename {app => packages/duxt/app}/components/ui/command/CommandDialog.vue (100%) rename {app => packages/duxt/app}/components/ui/command/CommandEmpty.vue (100%) rename {app => packages/duxt/app}/components/ui/command/CommandGroup.vue (100%) rename {app => packages/duxt/app}/components/ui/command/CommandInput.vue (100%) rename {app => packages/duxt/app}/components/ui/command/CommandItem.vue (100%) rename {app => packages/duxt/app}/components/ui/command/CommandList.vue (100%) rename {app => packages/duxt/app}/components/ui/command/CommandSeparator.vue (100%) rename {app => packages/duxt/app}/components/ui/command/CommandShortcut.vue (100%) rename {app => packages/duxt/app}/components/ui/command/index.ts (100%) rename {app => packages/duxt/app}/components/ui/dialog/Dialog.vue (100%) rename {app => packages/duxt/app}/components/ui/dialog/DialogClose.vue (100%) rename {app => packages/duxt/app}/components/ui/dialog/DialogContent.vue (100%) rename {app => packages/duxt/app}/components/ui/dialog/DialogDescription.vue (100%) rename {app => packages/duxt/app}/components/ui/dialog/DialogFooter.vue (100%) rename {app => packages/duxt/app}/components/ui/dialog/DialogHeader.vue (100%) rename {app => packages/duxt/app}/components/ui/dialog/DialogOverlay.vue (100%) rename {app => packages/duxt/app}/components/ui/dialog/DialogScrollContent.vue (100%) rename {app => packages/duxt/app}/components/ui/dialog/DialogTitle.vue (100%) rename {app => packages/duxt/app}/components/ui/dialog/DialogTrigger.vue (100%) rename {app => packages/duxt/app}/components/ui/dialog/index.ts (100%) rename {app => packages/duxt/app}/components/ui/dropdown-menu/DropdownMenu.vue (100%) rename {app => packages/duxt/app}/components/ui/dropdown-menu/DropdownMenuCheckboxItem.vue (100%) rename {app => packages/duxt/app}/components/ui/dropdown-menu/DropdownMenuContent.vue (100%) rename {app => packages/duxt/app}/components/ui/dropdown-menu/DropdownMenuGroup.vue (100%) rename {app => packages/duxt/app}/components/ui/dropdown-menu/DropdownMenuItem.vue (100%) rename {app => packages/duxt/app}/components/ui/dropdown-menu/DropdownMenuLabel.vue (100%) rename {app => packages/duxt/app}/components/ui/dropdown-menu/DropdownMenuRadioGroup.vue (100%) rename {app => packages/duxt/app}/components/ui/dropdown-menu/DropdownMenuRadioItem.vue (100%) rename {app => packages/duxt/app}/components/ui/dropdown-menu/DropdownMenuSeparator.vue (100%) rename {app => packages/duxt/app}/components/ui/dropdown-menu/DropdownMenuShortcut.vue (100%) rename {app => packages/duxt/app}/components/ui/dropdown-menu/DropdownMenuSub.vue (100%) rename {app => packages/duxt/app}/components/ui/dropdown-menu/DropdownMenuSubContent.vue (100%) rename {app => packages/duxt/app}/components/ui/dropdown-menu/DropdownMenuSubTrigger.vue (100%) rename {app => packages/duxt/app}/components/ui/dropdown-menu/DropdownMenuTrigger.vue (100%) rename {app => packages/duxt/app}/components/ui/dropdown-menu/index.ts (100%) rename {app => packages/duxt/app}/components/ui/input/Input.vue (100%) rename {app => packages/duxt/app}/components/ui/input/index.ts (100%) rename {app => packages/duxt/app}/components/ui/navigation-menu/NavigationMenu.vue (100%) rename {app => packages/duxt/app}/components/ui/navigation-menu/NavigationMenuContent.vue (100%) rename {app => packages/duxt/app}/components/ui/navigation-menu/NavigationMenuIndicator.vue (100%) rename {app => packages/duxt/app}/components/ui/navigation-menu/NavigationMenuItem.vue (100%) rename {app => packages/duxt/app}/components/ui/navigation-menu/NavigationMenuLink.vue (100%) rename {app => packages/duxt/app}/components/ui/navigation-menu/NavigationMenuList.vue (100%) rename {app => packages/duxt/app}/components/ui/navigation-menu/NavigationMenuTrigger.vue (100%) rename {app => packages/duxt/app}/components/ui/navigation-menu/NavigationMenuViewport.vue (100%) rename {app => packages/duxt/app}/components/ui/navigation-menu/index.ts (100%) rename {app => packages/duxt/app}/components/ui/scroll-area/ScrollArea.vue (100%) rename {app => packages/duxt/app}/components/ui/scroll-area/ScrollBar.vue (100%) rename {app => packages/duxt/app}/components/ui/scroll-area/index.ts (100%) rename {app => packages/duxt/app}/components/ui/select/Select.vue (100%) rename {app => packages/duxt/app}/components/ui/select/SelectContent.vue (100%) rename {app => packages/duxt/app}/components/ui/select/SelectGroup.vue (100%) rename {app => packages/duxt/app}/components/ui/select/SelectItem.vue (100%) rename {app => packages/duxt/app}/components/ui/select/SelectItemText.vue (100%) rename {app => packages/duxt/app}/components/ui/select/SelectLabel.vue (100%) rename {app => packages/duxt/app}/components/ui/select/SelectScrollDownButton.vue (100%) rename {app => packages/duxt/app}/components/ui/select/SelectScrollUpButton.vue (100%) rename {app => packages/duxt/app}/components/ui/select/SelectSeparator.vue (100%) rename {app => packages/duxt/app}/components/ui/select/SelectTrigger.vue (100%) rename {app => packages/duxt/app}/components/ui/select/SelectValue.vue (100%) rename {app => packages/duxt/app}/components/ui/select/index.ts (100%) rename {app => packages/duxt/app}/components/ui/separator/Separator.vue (100%) rename {app => packages/duxt/app}/components/ui/separator/index.ts (100%) rename {app => packages/duxt/app}/components/ui/sheet/Sheet.vue (100%) rename {app => packages/duxt/app}/components/ui/sheet/SheetClose.vue (100%) rename {app => packages/duxt/app}/components/ui/sheet/SheetContent.vue (100%) rename {app => packages/duxt/app}/components/ui/sheet/SheetDescription.vue (100%) rename {app => packages/duxt/app}/components/ui/sheet/SheetFooter.vue (100%) rename {app => packages/duxt/app}/components/ui/sheet/SheetHeader.vue (100%) rename {app => packages/duxt/app}/components/ui/sheet/SheetOverlay.vue (100%) rename {app => packages/duxt/app}/components/ui/sheet/SheetTitle.vue (100%) rename {app => packages/duxt/app}/components/ui/sheet/SheetTrigger.vue (100%) rename {app => packages/duxt/app}/components/ui/sheet/index.ts (100%) rename {app => packages/duxt/app}/components/ui/sidebar/Sidebar.vue (100%) rename {app => packages/duxt/app}/components/ui/sidebar/SidebarContent.vue (100%) rename {app => packages/duxt/app}/components/ui/sidebar/SidebarFooter.vue (100%) rename {app => packages/duxt/app}/components/ui/sidebar/SidebarGroup.vue (100%) rename {app => packages/duxt/app}/components/ui/sidebar/SidebarGroupAction.vue (100%) rename {app => packages/duxt/app}/components/ui/sidebar/SidebarGroupContent.vue (100%) rename {app => packages/duxt/app}/components/ui/sidebar/SidebarGroupLabel.vue (100%) rename {app => packages/duxt/app}/components/ui/sidebar/SidebarHeader.vue (100%) rename {app => packages/duxt/app}/components/ui/sidebar/SidebarInput.vue (100%) rename {app => packages/duxt/app}/components/ui/sidebar/SidebarInset.vue (100%) rename {app => packages/duxt/app}/components/ui/sidebar/SidebarMenu.vue (100%) rename {app => packages/duxt/app}/components/ui/sidebar/SidebarMenuAction.vue (100%) rename {app => packages/duxt/app}/components/ui/sidebar/SidebarMenuBadge.vue (100%) rename {app => packages/duxt/app}/components/ui/sidebar/SidebarMenuButton.vue (100%) rename {app => packages/duxt/app}/components/ui/sidebar/SidebarMenuButtonChild.vue (100%) rename {app => packages/duxt/app}/components/ui/sidebar/SidebarMenuItem.vue (100%) rename {app => packages/duxt/app}/components/ui/sidebar/SidebarMenuSkeleton.vue (100%) rename {app => packages/duxt/app}/components/ui/sidebar/SidebarMenuSub.vue (100%) rename {app => packages/duxt/app}/components/ui/sidebar/SidebarMenuSubButton.vue (100%) rename {app => packages/duxt/app}/components/ui/sidebar/SidebarMenuSubItem.vue (100%) rename {app => packages/duxt/app}/components/ui/sidebar/SidebarProvider.vue (100%) rename {app => packages/duxt/app}/components/ui/sidebar/SidebarRail.vue (100%) rename {app => packages/duxt/app}/components/ui/sidebar/SidebarSeparator.vue (100%) rename {app => packages/duxt/app}/components/ui/sidebar/SidebarTrigger.vue (100%) rename {app => packages/duxt/app}/components/ui/sidebar/index.ts (100%) rename {app => packages/duxt/app}/components/ui/sidebar/utils.ts (100%) rename {app => packages/duxt/app}/components/ui/skeleton/Skeleton.vue (100%) rename {app => packages/duxt/app}/components/ui/skeleton/index.ts (100%) rename {app => packages/duxt/app}/components/ui/sonner/Sonner.vue (100%) rename {app => packages/duxt/app}/components/ui/sonner/index.ts (100%) rename {app => packages/duxt/app}/components/ui/tabs/Tabs.vue (100%) rename {app => packages/duxt/app}/components/ui/tabs/TabsContent.vue (100%) rename {app => packages/duxt/app}/components/ui/tabs/TabsList.vue (100%) rename {app => packages/duxt/app}/components/ui/tabs/TabsTrigger.vue (100%) rename {app => packages/duxt/app}/components/ui/tabs/index.ts (100%) rename {app => packages/duxt/app}/components/ui/textarea/Textarea.vue (100%) rename {app => packages/duxt/app}/components/ui/textarea/index.ts (100%) rename {app => packages/duxt/app}/components/ui/tooltip/Tooltip.vue (100%) rename {app => packages/duxt/app}/components/ui/tooltip/TooltipContent.vue (100%) rename {app => packages/duxt/app}/components/ui/tooltip/TooltipProvider.vue (100%) rename {app => packages/duxt/app}/components/ui/tooltip/TooltipTrigger.vue (100%) rename {app => packages/duxt/app}/components/ui/tooltip/index.ts (100%) rename {app => packages/duxt/app}/composables/useActiveHeading.ts (100%) rename {app => packages/duxt/app}/composables/useDuxtAnalytics.ts (100%) rename {app => packages/duxt/app}/composables/useDuxtAnimatedHeight.ts (100%) rename {app => packages/duxt/app}/composables/useDuxtAnnouncements.ts (100%) rename {app => packages/duxt/app}/composables/useDuxtBreadcrumb.ts (100%) rename {app => packages/duxt/app}/composables/useDuxtChoice.ts (100%) rename {app => packages/duxt/app}/composables/useDuxtCollection.ts (97%) rename {app => packages/duxt/app}/composables/useDuxtConfig.ts (100%) rename {app => packages/duxt/app}/composables/useDuxtCopy.ts (100%) rename {app => packages/duxt/app}/composables/useDuxtDirection.ts (100%) rename {app => packages/duxt/app}/composables/useDuxtNavigation.ts (98%) rename {app => packages/duxt/app}/composables/useDuxtPageFocus.ts (100%) rename {app => packages/duxt/app}/composables/useDuxtPath.ts (100%) rename {app => packages/duxt/app}/composables/useDuxtSearch.ts (99%) rename {app => packages/duxt/app}/composables/useDuxtSection.ts (100%) rename {app => packages/duxt/app}/composables/useDuxtSectionRow.ts (100%) rename {app => packages/duxt/app}/composables/useDuxtShortcuts.ts (100%) rename {app => packages/duxt/app}/composables/useDuxtSiteUrl.ts (100%) rename {app => packages/duxt/app}/composables/useDuxtThemeToggle.ts (100%) rename {app => packages/duxt/app}/composables/useDuxtToast.ts (100%) rename {app => packages/duxt/app}/composables/useDuxtVersion.ts (100%) rename {app => packages/duxt/app}/composables/useDuxtViewportMeasure.ts (100%) rename {app => packages/duxt/app}/composables/useFuzzySearch.ts (100%) rename {app => packages/duxt/app}/composables/usePackageManager.ts (100%) rename {app => packages/duxt/app}/composables/useRecentPages.ts (100%) rename {app => packages/duxt/app}/error.vue (100%) rename {app => packages/duxt/app}/layouts/changelog.vue (100%) rename {app => packages/duxt/app}/layouts/default.vue (100%) rename {app => packages/duxt/app}/layouts/docs.vue (100%) rename {app => packages/duxt/app}/layouts/reference.vue (100%) rename {app => packages/duxt/app}/lib/utils.ts (100%) rename {app => packages/duxt/app}/middleware/duxt-section-layout.global.ts (100%) rename {app => packages/duxt/app}/pages/[...slug].vue (100%) rename {app => packages/duxt/app}/pages/index.vue (100%) rename {app => packages/duxt/app}/types/duxt.d.ts (100%) rename {app => packages/duxt/app}/utils/analytics.ts (100%) rename {app => packages/duxt/app}/utils/announcements.ts (100%) rename {app => packages/duxt/app}/utils/aside.ts (100%) rename {app => packages/duxt/app}/utils/changelog.ts (100%) rename {app => packages/duxt/app}/utils/code-highlight.ts (100%) rename {app => packages/duxt/app}/utils/contributor-avatar.ts (100%) rename {app => packages/duxt/app}/utils/direction.ts (100%) rename {app => packages/duxt/app}/utils/documentation-link.ts (100%) rename {app => packages/duxt/app}/utils/duxt-config.ts (96%) rename {app => packages/duxt/app}/utils/duxt-text.ts (100%) rename {app => packages/duxt/app}/utils/file-icons.ts (100%) rename {app => packages/duxt/app}/utils/format-date.ts (100%) rename {app => packages/duxt/app}/utils/generated-sections.ts (100%) rename {app => packages/duxt/app}/utils/generated-toc.ts (100%) rename {app => packages/duxt/app}/utils/heading-link.ts (100%) rename {app => packages/duxt/app}/utils/locale-path.ts (100%) rename {app => packages/duxt/app}/utils/navigation-tree.ts (100%) rename {app => packages/duxt/app}/utils/nearest-page.ts (100%) rename {app => packages/duxt/app}/utils/openapi.ts (99%) rename {app => packages/duxt/app}/utils/package-command.ts (100%) rename {app => packages/duxt/app}/utils/page-controls.ts (100%) rename {app => packages/duxt/app}/utils/pill.ts (100%) rename {app => packages/duxt/app}/utils/prose-image.ts (100%) rename {app => packages/duxt/app}/utils/request-samples.ts (100%) rename {app => packages/duxt/app}/utils/search-context.ts (100%) rename {app => packages/duxt/app}/utils/search-display.ts (100%) rename {app => packages/duxt/app}/utils/search-scope.ts (96%) rename {app => packages/duxt/app}/utils/section-scope.ts (100%) rename {app => packages/duxt/app}/utils/sheet-navigation.ts (100%) rename {app => packages/duxt/app}/utils/source-file.ts (100%) rename {app => packages/duxt/app}/utils/stored-json.ts (100%) rename {app => packages/duxt/app}/utils/version-choices.ts (97%) rename {app => packages/duxt/app}/utils/version-paths.ts (97%) rename {bin => packages/duxt/bin}/duxt-cache-key.mjs (94%) rename {bin => packages/duxt/bin}/duxt-og-cache.mjs (95%) rename {bin => packages/duxt/bin}/duxt.mjs (94%) rename bruno-model.ts => packages/duxt/build/bruno/bruno-model.ts (100%) rename bruno-parse.ts => packages/duxt/build/bruno/bruno-parse.ts (99%) rename bruno-zip.ts => packages/duxt/build/bruno/bruno-zip.ts (99%) rename cli.ts => packages/duxt/build/cli/cli.ts (100%) rename report.ts => packages/duxt/build/cli/report.ts (95%) rename validate-report.ts => packages/duxt/build/cli/validate-report.ts (98%) rename duxt-app-config.ts => packages/duxt/build/config/duxt-app-config.ts (97%) rename content-cache.ts => packages/duxt/build/content/content-cache.ts (100%) rename frontmatter.ts => packages/duxt/build/content/frontmatter.ts (100%) rename highlight-langs.ts => packages/duxt/build/content/highlight-langs.ts (100%) rename git-contributors.ts => packages/duxt/build/git/git-contributors.ts (100%) rename og-image-cache.ts => packages/duxt/build/og-image/og-image-cache.ts (100%) rename openapi-model.ts => packages/duxt/build/openapi/openapi-model.ts (100%) rename openapi-parse.ts => packages/duxt/build/openapi/openapi-parse.ts (100%) rename search-records.ts => packages/duxt/build/search/search-records.ts (98%) rename section-input.ts => packages/duxt/build/sections/section-input.ts (100%) rename section-markdown.ts => packages/duxt/build/sections/section-markdown.ts (100%) rename section-reports.ts => packages/duxt/build/sections/section-reports.ts (94%) rename sections-bruno.ts => packages/duxt/build/sections/sections-bruno.ts (98%) rename sections-changelog.ts => packages/duxt/build/sections/sections-changelog.ts (99%) rename sections-openapi.ts => packages/duxt/build/sections/sections-openapi.ts (98%) rename sections-resolve.ts => packages/duxt/build/sections/sections-resolve.ts (99%) rename sections.ts => packages/duxt/build/sections/sections.ts (96%) rename repository-root.ts => packages/duxt/build/sources/repository-root.ts (100%) rename sources-cache.ts => packages/duxt/build/sources/sources-cache.ts (98%) rename sources-git.ts => packages/duxt/build/sources/sources-git.ts (100%) rename sources-resolve.ts => packages/duxt/build/sources/sources-resolve.ts (99%) rename sources.ts => packages/duxt/build/sources/sources.ts (100%) rename tfplugindocs.ts => packages/duxt/build/sources/tfplugindocs.ts (100%) rename components.json => packages/duxt/components.json (100%) rename content.config.ts => packages/duxt/content.config.ts (84%) rename {i18n => packages/duxt/i18n}/i18n.config.ts (100%) rename {i18n => packages/duxt/i18n}/locales/de/duxt/announcement.json (100%) rename {i18n => packages/duxt/i18n}/locales/de/duxt/bruno.json (100%) rename {i18n => packages/duxt/i18n}/locales/de/duxt/changelog.json (100%) rename {i18n => packages/duxt/i18n}/locales/de/duxt/code.json (100%) rename {i18n => packages/duxt/i18n}/locales/de/duxt/defaults.json (100%) rename {i18n => packages/duxt/i18n}/locales/de/duxt/devtools.json (100%) rename {i18n => packages/duxt/i18n}/locales/de/duxt/error.json (100%) rename {i18n => packages/duxt/i18n}/locales/de/duxt/footer.json (100%) rename {i18n => packages/duxt/i18n}/locales/de/duxt/locale.json (100%) rename {i18n => packages/duxt/i18n}/locales/de/duxt/nav.json (100%) rename {i18n => packages/duxt/i18n}/locales/de/duxt/openapi.json (100%) rename {i18n => packages/duxt/i18n}/locales/de/duxt/page.json (100%) rename {i18n => packages/duxt/i18n}/locales/de/duxt/search.json (100%) rename {i18n => packages/duxt/i18n}/locales/de/duxt/shortcuts.json (100%) rename {i18n => packages/duxt/i18n}/locales/de/duxt/theme.json (100%) rename {i18n => packages/duxt/i18n}/locales/de/duxt/toc.json (100%) rename {i18n => packages/duxt/i18n}/locales/de/duxt/version.json (100%) rename {i18n => packages/duxt/i18n}/locales/en/duxt/announcement.json (100%) rename {i18n => packages/duxt/i18n}/locales/en/duxt/bruno.json (100%) rename {i18n => packages/duxt/i18n}/locales/en/duxt/changelog.json (100%) rename {i18n => packages/duxt/i18n}/locales/en/duxt/code.json (100%) rename {i18n => packages/duxt/i18n}/locales/en/duxt/defaults.json (100%) rename {i18n => packages/duxt/i18n}/locales/en/duxt/devtools.json (100%) rename {i18n => packages/duxt/i18n}/locales/en/duxt/error.json (100%) rename {i18n => packages/duxt/i18n}/locales/en/duxt/footer.json (100%) rename {i18n => packages/duxt/i18n}/locales/en/duxt/locale.json (100%) rename {i18n => packages/duxt/i18n}/locales/en/duxt/nav.json (100%) rename {i18n => packages/duxt/i18n}/locales/en/duxt/openapi.json (100%) rename {i18n => packages/duxt/i18n}/locales/en/duxt/page.json (100%) rename {i18n => packages/duxt/i18n}/locales/en/duxt/search.json (100%) rename {i18n => packages/duxt/i18n}/locales/en/duxt/shortcuts.json (100%) rename {i18n => packages/duxt/i18n}/locales/en/duxt/theme.json (100%) rename {i18n => packages/duxt/i18n}/locales/en/duxt/toc.json (100%) rename {i18n => packages/duxt/i18n}/locales/en/duxt/version.json (100%) rename {i18n => packages/duxt/i18n}/locales/es/duxt/announcement.json (100%) rename {i18n => packages/duxt/i18n}/locales/es/duxt/bruno.json (100%) rename {i18n => packages/duxt/i18n}/locales/es/duxt/changelog.json (100%) rename {i18n => packages/duxt/i18n}/locales/es/duxt/code.json (100%) rename {i18n => packages/duxt/i18n}/locales/es/duxt/defaults.json (100%) rename {i18n => packages/duxt/i18n}/locales/es/duxt/devtools.json (100%) rename {i18n => packages/duxt/i18n}/locales/es/duxt/error.json (100%) rename {i18n => packages/duxt/i18n}/locales/es/duxt/footer.json (100%) rename {i18n => packages/duxt/i18n}/locales/es/duxt/locale.json (100%) rename {i18n => packages/duxt/i18n}/locales/es/duxt/nav.json (100%) rename {i18n => packages/duxt/i18n}/locales/es/duxt/openapi.json (100%) rename {i18n => packages/duxt/i18n}/locales/es/duxt/page.json (100%) rename {i18n => packages/duxt/i18n}/locales/es/duxt/search.json (100%) rename {i18n => packages/duxt/i18n}/locales/es/duxt/shortcuts.json (100%) rename {i18n => packages/duxt/i18n}/locales/es/duxt/theme.json (100%) rename {i18n => packages/duxt/i18n}/locales/es/duxt/toc.json (100%) rename {i18n => packages/duxt/i18n}/locales/es/duxt/version.json (100%) rename {i18n => packages/duxt/i18n}/locales/fr/duxt/announcement.json (100%) rename {i18n => packages/duxt/i18n}/locales/fr/duxt/bruno.json (100%) rename {i18n => packages/duxt/i18n}/locales/fr/duxt/changelog.json (100%) rename {i18n => packages/duxt/i18n}/locales/fr/duxt/code.json (100%) rename {i18n => packages/duxt/i18n}/locales/fr/duxt/defaults.json (100%) rename {i18n => packages/duxt/i18n}/locales/fr/duxt/devtools.json (100%) rename {i18n => packages/duxt/i18n}/locales/fr/duxt/error.json (100%) rename {i18n => packages/duxt/i18n}/locales/fr/duxt/footer.json (100%) rename {i18n => packages/duxt/i18n}/locales/fr/duxt/locale.json (100%) rename {i18n => packages/duxt/i18n}/locales/fr/duxt/nav.json (100%) rename {i18n => packages/duxt/i18n}/locales/fr/duxt/openapi.json (100%) rename {i18n => packages/duxt/i18n}/locales/fr/duxt/page.json (100%) rename {i18n => packages/duxt/i18n}/locales/fr/duxt/search.json (100%) rename {i18n => packages/duxt/i18n}/locales/fr/duxt/shortcuts.json (100%) rename {i18n => packages/duxt/i18n}/locales/fr/duxt/theme.json (100%) rename {i18n => packages/duxt/i18n}/locales/fr/duxt/toc.json (100%) rename {i18n => packages/duxt/i18n}/locales/fr/duxt/version.json (100%) rename {i18n => packages/duxt/i18n}/locales/pt-BR/duxt/error.json (100%) rename {i18n => packages/duxt/i18n}/locales/pt-BR/duxt/nav.json (100%) rename {i18n => packages/duxt/i18n}/locales/pt-BR/duxt/page.json (100%) rename {i18n => packages/duxt/i18n}/locales/pt-BR/duxt/search.json (100%) rename {i18n => packages/duxt/i18n}/locales/pt-BR/duxt/version.json (100%) rename {i18n => packages/duxt/i18n}/locales/pt/duxt/announcement.json (100%) rename {i18n => packages/duxt/i18n}/locales/pt/duxt/bruno.json (100%) rename {i18n => packages/duxt/i18n}/locales/pt/duxt/changelog.json (100%) rename {i18n => packages/duxt/i18n}/locales/pt/duxt/code.json (100%) rename {i18n => packages/duxt/i18n}/locales/pt/duxt/defaults.json (100%) rename {i18n => packages/duxt/i18n}/locales/pt/duxt/devtools.json (100%) rename {i18n => packages/duxt/i18n}/locales/pt/duxt/error.json (100%) rename {i18n => packages/duxt/i18n}/locales/pt/duxt/footer.json (100%) rename {i18n => packages/duxt/i18n}/locales/pt/duxt/locale.json (100%) rename {i18n => packages/duxt/i18n}/locales/pt/duxt/nav.json (100%) rename {i18n => packages/duxt/i18n}/locales/pt/duxt/openapi.json (100%) rename {i18n => packages/duxt/i18n}/locales/pt/duxt/page.json (100%) rename {i18n => packages/duxt/i18n}/locales/pt/duxt/search.json (100%) rename {i18n => packages/duxt/i18n}/locales/pt/duxt/shortcuts.json (100%) rename {i18n => packages/duxt/i18n}/locales/pt/duxt/theme.json (100%) rename {i18n => packages/duxt/i18n}/locales/pt/duxt/toc.json (100%) rename {i18n => packages/duxt/i18n}/locales/pt/duxt/version.json (100%) rename mdc.config.ts => packages/duxt/mdc.config.ts (100%) rename {modules => packages/duxt/modules}/bruno.ts (91%) rename {modules => packages/duxt/modules}/config.ts (97%) rename {modules => packages/duxt/modules}/devtools.ts (100%) rename {modules => packages/duxt/modules}/git-meta.ts (95%) rename {modules => packages/duxt/modules}/redirects.ts (94%) rename {modules => packages/duxt/modules}/search-records.ts (94%) rename {modules => packages/duxt/modules}/validate.ts (91%) rename nuxt.config.ts => packages/duxt/nuxt.config.ts (99%) create mode 100644 packages/duxt/package.json rename {public => packages/duxt/public}/devtools/cache.html (100%) rename {public => packages/duxt/public}/devtools/checks.html (100%) rename {public => packages/duxt/public}/devtools/config.html (100%) rename {public => packages/duxt/public}/devtools/i18n.html (100%) rename {public => packages/duxt/public}/devtools/pages.html (100%) rename {public => packages/duxt/public}/devtools/paths.html (100%) rename {public => packages/duxt/public}/devtools/redirects.html (100%) rename {public => packages/duxt/public}/devtools/search.html (100%) rename {public => packages/duxt/public}/devtools/sources.html (100%) rename {public => packages/duxt/public}/devtools/versions.html (100%) rename {scripts => packages/duxt/scripts}/build-devtools-previews.ts (100%) rename {scripts => packages/duxt/scripts}/check-previews.ts (100%) rename {server => packages/duxt/server}/devtools/content.ts (96%) rename {server => packages/duxt/server}/devtools/context.ts (100%) rename {server => packages/duxt/server}/devtools/entry-path.ts (100%) rename {server => packages/duxt/server}/devtools/handler.ts (100%) rename {server => packages/duxt/server}/devtools/preview.ts (98%) rename {server => packages/duxt/server}/devtools/render/content.ts (98%) rename {server => packages/duxt/server}/devtools/render/sources.ts (99%) rename {server => packages/duxt/server}/devtools/render/system.ts (100%) rename {server => packages/duxt/server}/devtools/shell.ts (100%) rename {server => packages/duxt/server}/devtools/sources.ts (98%) rename {server => packages/duxt/server}/devtools/system.ts (100%) rename {server => packages/duxt/server}/mcp/tools/list-pages.ts (100%) rename {server => packages/duxt/server}/mcp/tools/list-versions.ts (100%) rename {server => packages/duxt/server}/mcp/tools/read-page.ts (97%) rename {server => packages/duxt/server}/mcp/tools/search-docs.ts (100%) rename {server => packages/duxt/server}/middleware/raw-markdown.ts (94%) rename {server => packages/duxt/server}/routes/llms-full.txt.get.ts (96%) rename {server => packages/duxt/server}/routes/llms.txt.get.ts (100%) rename {server => packages/duxt/server}/routes/rss.xml.get.ts (100%) rename {server => packages/duxt/server}/utils/duxt-server-text.ts (100%) rename {server => packages/duxt/server}/utils/llms-pages.ts (98%) rename {server => packages/duxt/server}/utils/mcp-docs.ts (99%) rename {tests => packages/duxt/tests}/analytics.test.ts (100%) rename {tests => packages/duxt/tests}/announcements.test.ts (100%) rename {tests => packages/duxt/tests}/async-data-context.test.ts (100%) rename {tests => packages/duxt/tests}/bruno-parse.test.ts (98%) rename {tests => packages/duxt/tests}/bruno-zip.test.ts (97%) rename {tests => packages/duxt/tests}/changelog.test.ts (100%) rename {tests => packages/duxt/tests}/cli.test.ts (98%) rename {tests => packages/duxt/tests}/collections.test.ts (94%) rename {tests => packages/duxt/tests}/content-cache-wal.test.ts (96%) rename {tests => packages/duxt/tests}/content-database.test.ts (93%) rename {tests => packages/duxt/tests}/contrast.test.ts (100%) rename {tests => packages/duxt/tests}/contributor-avatar.test.ts (100%) rename {tests => packages/duxt/tests}/devtools-cache.test.ts (100%) rename {tests => packages/duxt/tests}/devtools-versions.test.ts (98%) rename {tests => packages/duxt/tests}/direction-utilities.test.ts (100%) rename {tests => packages/duxt/tests}/direction.test.ts (100%) rename {tests => packages/duxt/tests}/documentation-link.test.ts (100%) rename {tests => packages/duxt/tests}/duxt-app-config.test.ts (95%) rename {tests => packages/duxt/tests}/duxt-config.test.ts (100%) rename {tests => packages/duxt/tests}/duxt-text.test.ts (100%) rename {tests => packages/duxt/tests}/file-icons.test.ts (100%) rename {tests => packages/duxt/tests}/fixtures/tfplugindocs/docs/functions/lookup.md (100%) rename {tests => packages/duxt/tests}/fixtures/tfplugindocs/docs/future-things/an-example.md (100%) rename {tests => packages/duxt/tests}/fixtures/tfplugindocs/docs/guides/getting-started.md (100%) rename {tests => packages/duxt/tests}/fixtures/tfplugindocs/docs/index.md (100%) rename {tests => packages/duxt/tests}/fixtures/tfplugindocs/docs/resources/account.md (100%) rename {tests => packages/duxt/tests}/fixtures/tfplugindocs/docs/resources/team.md (100%) rename {tests => packages/duxt/tests}/fixtures/tfplugindocs/docs/resources/user.md (100%) rename {tests => packages/duxt/tests}/format-date.test.ts (100%) rename {tests => packages/duxt/tests}/frontmatter-yaml.test.ts (80%) rename {tests => packages/duxt/tests}/frontmatter.test.ts (93%) rename {tests => packages/duxt/tests}/generated-sections.test.ts (100%) rename {tests => packages/duxt/tests}/generated-toc.test.ts (100%) rename {tests => packages/duxt/tests}/git-contributors.test.ts (99%) rename {tests => packages/duxt/tests}/git-meta.test.ts (98%) rename {tests => packages/duxt/tests}/header-tooltips.test.ts (100%) rename {tests => packages/duxt/tests}/heading-link.test.ts (100%) rename {tests => packages/duxt/tests}/highlight-langs.test.ts (98%) rename {tests => packages/duxt/tests}/i18n-ownership.test.ts (99%) rename {tests => packages/duxt/tests}/i18n-parity.test.ts (100%) rename {tests => packages/duxt/tests}/layer-dependencies.test.ts (98%) rename {tests => packages/duxt/tests}/llms-exports.test.ts (99%) rename {tests => packages/duxt/tests}/locale-path.test.ts (100%) rename {tests => packages/duxt/tests}/mcp-tools.test.ts (99%) rename {tests => packages/duxt/tests}/navigation-tree.test.ts (100%) rename {tests => packages/duxt/tests}/nearest-page.test.ts (100%) rename {tests => packages/duxt/tests}/og-image-cache.test.ts (99%) rename {tests => packages/duxt/tests}/openapi-operation-props.test.ts (100%) rename {tests => packages/duxt/tests}/openapi-parse.test.ts (99%) rename {tests => packages/duxt/tests}/openapi-utils.test.ts (99%) rename {tests => packages/duxt/tests}/package-command.test.ts (100%) rename {tests => packages/duxt/tests}/package-files.test.ts (61%) rename {tests => packages/duxt/tests}/page-controls.test.ts (100%) rename {tests => packages/duxt/tests}/page-icon.test.ts (100%) rename {tests => packages/duxt/tests}/page-schema-database.test.ts (99%) rename {tests => packages/duxt/tests}/pill.test.ts (100%) rename {tests => packages/duxt/tests}/prose-image.test.ts (100%) rename {tests => packages/duxt/tests}/raw-markdown.test.ts (98%) rename {tests => packages/duxt/tests}/redirects.test.ts (100%) rename {tests => packages/duxt/tests}/report.test.ts (97%) rename {tests => packages/duxt/tests}/request-samples.test.ts (100%) rename {tests => packages/duxt/tests}/search-context.test.ts (100%) rename {tests => packages/duxt/tests}/search-display.test.ts (98%) rename {tests => packages/duxt/tests}/search-records.test.ts (97%) rename {tests => packages/duxt/tests}/search-scope.test.ts (94%) rename {tests => packages/duxt/tests}/search.test.ts (99%) rename {tests => packages/duxt/tests}/section-markdown.test.ts (92%) rename {tests => packages/duxt/tests}/section-scope.test.ts (100%) rename {tests => packages/duxt/tests}/sections-bruno.test.ts (98%) rename {tests => packages/duxt/tests}/sections-changelog.test.ts (98%) rename {tests => packages/duxt/tests}/sections-openapi.test.ts (98%) rename {tests => packages/duxt/tests}/sections-resolve.test.ts (98%) rename {tests => packages/duxt/tests}/sheet-navigation.test.ts (100%) rename {tests => packages/duxt/tests}/shortcuts-surface.test.ts (98%) rename {tests => packages/duxt/tests}/shortcuts.test.ts (100%) rename {tests => packages/duxt/tests}/showcase-rows.test.ts (100%) rename {tests => packages/duxt/tests}/source-file.test.ts (100%) rename {tests => packages/duxt/tests}/sources-cache.test.ts (98%) rename {tests => packages/duxt/tests}/sources-collections.test.ts (96%) rename {tests => packages/duxt/tests}/sources-git.test.ts (98%) rename {tests => packages/duxt/tests}/sources-locales.test.ts (99%) rename {tests => packages/duxt/tests}/sources-resolve.test.ts (99%) rename {tests => packages/duxt/tests}/sources-versions.test.ts (99%) rename {tests => packages/duxt/tests}/sqlite-connector.test.ts (100%) rename {tests => packages/duxt/tests}/stored-json.test.ts (100%) rename {tests => packages/duxt/tests}/tfplugindocs.test.ts (99%) rename {tests => packages/duxt/tests}/validate.test.ts (99%) rename {tests => packages/duxt/tests}/version-choices.test.ts (99%) rename {tests => packages/duxt/tests}/version-paths.test.ts (98%) rename vitest.config.ts => packages/duxt/vitest.config.ts (93%) create mode 100644 turbo.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6c5db17f..0f62e534 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,4 +34,4 @@ jobs: # and the path list must agree. The command answers `cacheable=false` for a # private or authenticated source, and the body then caches nothing. with: - remote-source-cache-command: node ./bin/duxt-cache-key.mjs --root www --github >> "$GITHUB_OUTPUT" + remote-source-cache-command: node ./packages/duxt/bin/duxt-cache-key.mjs --root apps/www --github >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 5d0a602c..fb3edd7d 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -22,7 +22,7 @@ on: # the hostname — none of which is a commit. workflow_dispatch: -# ON EVERY PUSH TO MAIN, THEN AGAIN FOR A PUBLISHED RELEASE. `www/` reads +# ON EVERY PUSH TO MAIN, THEN AGAIN FOR A PUBLISHED RELEASE. `apps/www/` reads # `docs/` off the CHECKOUT — `origin.ref` in its `app.config.ts` names the # repository for the edit links and downloads nothing — so a documentation fix # does not wait for a release. The second build is deliberately redundant: it @@ -100,7 +100,7 @@ jobs: run: pnpm install --frozen-lockfile # THE DOWNLOAD CACHE, NOT THE PARSE CACHE. Content clones every remote - # source into `www/.data/content` and keys the directory by repository AND + # source into `apps/www/.data/content` and keys the directory by repository AND # ref, so what a run downloads is a function of the RESOLVED source list — # which is exactly what this command prints, `latest` already replaced by # the tag it means. `contents.sqlite` lives in the same directory and is @@ -118,7 +118,7 @@ jobs: # `remote-source-cache-command`, which `ci.yml` passes this same command. - name: Resolve the remote-source cache key id: sources - run: node ./bin/duxt-cache-key.mjs --root www --github >> "$GITHUB_OUTPUT" + run: node ./packages/duxt/bin/duxt-cache-key.mjs --root apps/www --github >> "$GITHUB_OUTPUT" # NO `restore-keys`, deliberately. An exact key means a hit never re-saves, # which is right for the tags this site pins — they cannot move, so one @@ -174,7 +174,7 @@ jobs: env: SOURCE_SHA: ${{ steps.source.outputs.sha }} run: | - node ./bin/duxt-og-cache.mjs --root www --run "$SOURCE_SHA" --github \ + node ./packages/duxt/bin/duxt-og-cache.mjs --root apps/www --run "$SOURCE_SHA" --github \ >> "$GITHUB_OUTPUT" - name: Restore the rendered OG images @@ -222,7 +222,7 @@ jobs: BUILD_MS: ${{ steps.build.outputs.ms }} run: | report() { - node ./bin/duxt-og-cache.mjs --root www --report \ + node ./packages/duxt/bin/duxt-og-cache.mjs --root apps/www --report \ --since "$SINCE" --log build.log --build-ms "$BUILD_MS" "$@" } report >> "$GITHUB_STEP_SUMMARY" @@ -243,7 +243,7 @@ jobs: # The build/publication boundary, made literal. `.output` is the whole of # what wrangler uploads: the module Worker and the prerendered assets - # beside it, exactly as `www/wrangler.jsonc` names them. + # beside it, exactly as `apps/www/wrangler.jsonc` names them. # # This round trip is the price of being able to cancel a build without # risking an upload, and it is paid on every deploy. `retention-days: 1` @@ -252,7 +252,7 @@ jobs: - uses: actions/upload-artifact@v7 with: name: www-output - path: www/.output + path: apps/www/.output retention-days: 1 # Nitro emits dotfiles into `.output/public` — `_headers`' neighbours, # and whatever a module drops under `.well-known`. The action skips @@ -286,7 +286,7 @@ jobs: group: deploy-www-publish cancel-in-progress: false steps: - # wrangler, `www/wrangler.jsonc` and the workspace it resolves from. The + # wrangler, `apps/www/wrangler.jsonc` and the workspace it resolves from. The # site itself is not rebuilt here; it arrives as the artefact below, so # this checkout follows the build's ref rather than the trigger's. - uses: actions/checkout@v6 @@ -306,14 +306,14 @@ jobs: - uses: actions/download-artifact@v8 with: name: www-output - path: www/.output + path: apps/www/.output # The Cloudflare deploy token from the kirchDev-ci Bitwarden project. It # needs `Workers Scripts: Edit` AND `D1: Edit`; the D1 half is newer than # the token, so verify it is there before blaming the build. # # No `Workers Routes: Edit`: the route is tofu's, not wrangler's — see the - # header of `www/wrangler.jsonc`. + # header of `apps/www/wrangler.jsonc`. - name: Fetch secrets from Bitwarden uses: bitwarden/sm-action@1238aae8fc64b212641190a9227c8a734ab1a793 # v3.0.1 with: @@ -337,4 +337,4 @@ jobs: DEPLOY_COMMITTED_AT: ${{ needs.build.outputs.committed-at }} DEPLOY_TRIGGER: ${{ github.event_name }} DEPLOY_WORKER: duxt-www - run: node scripts/deploy-summary.ts >> "$GITHUB_STEP_SUMMARY" + run: node apps/www/scripts/deploy-summary.ts >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/prerender-bench.yml b/.github/workflows/prerender-bench.yml index f6a87faf..9f42e812 100644 --- a/.github/workflows/prerender-bench.yml +++ b/.github/workflows/prerender-bench.yml @@ -5,7 +5,7 @@ # no body for "build one repository's site eighteen times and compare the # prerender phase", and there is unlikely ever to be a second caller for one. # -# WHAT IT IS FOR. `prerender.concurrency: 8` in `www/nuxt.config.ts` is the +# WHAT IT IS FOR. `prerender.concurrency: 8` in `apps/www/nuxt.config.ts` is the # answer to a build that produced 335 `createImage timeout` lines — 335 pages # that shipped with no OG image, silently, because a missing OG image fails # nothing. Eight brought that to 140 and the number was never revisited. Issue @@ -119,7 +119,7 @@ jobs: # number: Nitro's own timing starts after the content is parsed. - name: Resolve the remote-source cache key id: sources - run: node ./bin/duxt-cache-key.mjs --root www --github >> "$GITHUB_OUTPUT" + run: node ./packages/duxt/bin/duxt-cache-key.mjs --root apps/www --github >> "$GITHUB_OUTPUT" - name: Restore the downloaded sources if: steps.sources.outputs.cacheable == 'true' @@ -158,8 +158,8 @@ jobs: for run in $(seq 1 "$REPETITIONS"); do for state in cold warm; do - rm -rf www/.output - if [ "$state" = cold ]; then rm -rf www/.cache/og-image; fi + rm -rf apps/www/.output + if [ "$state" = cold ]; then rm -rf apps/www/.cache/og-image; fi started=$(date +%s%3N) # `/usr/bin/time` for #44's "resource use where available": `%M` @@ -172,10 +172,10 @@ jobs: | tee build.log; then ok=true; else ok=false; fi elapsed=$(($(date +%s%3N) - started)) - node ./bin/duxt-og-cache.mjs --root www --report --github \ + node ./packages/duxt/bin/duxt-og-cache.mjs --root apps/www --report --github \ --since "$started" --log build.log > og.txt - node scripts/prerender-bench.ts --measure \ + node apps/www/scripts/prerender-bench.ts --measure \ --concurrency "$SETTING" --state "$state" --run "$run" \ --log build.log --og og.txt --build-ms "$elapsed" --ok "$ok" \ --max-rss-kb "$(tail -n 1 rss.txt)" --cores "$(nproc)" \ @@ -194,7 +194,7 @@ jobs: env: SETTING: ${{ matrix.concurrency }} run: | - node scripts/prerender-bench.ts --verdict \ + node apps/www/scripts/prerender-bench.ts --verdict \ --results "runs-$SETTING.jsonl" --baseline "$SETTING" \ >> "$GITHUB_STEP_SUMMARY" @@ -251,7 +251,7 @@ jobs: find runs -name '*.jsonl' -exec cat {} + >> all.jsonl decide() { - node scripts/prerender-bench.ts --verdict --results all.jsonl \ + node apps/www/scripts/prerender-bench.ts --verdict --results all.jsonl \ --baseline "$BASELINE" --threshold "$THRESHOLD" \ --repetitions "$REPETITIONS" "$@" } @@ -267,7 +267,7 @@ jobs: retention-days: 90 # A benchmark that found a better setting has found a CODE CHANGE, and - # changing `www/nuxt.config.ts` from a workflow would land an unreviewed + # changing `apps/www/nuxt.config.ts` from a workflow would land an unreviewed # commit off the back of a measurement. So it says so and stops. - name: Say what the measurement asks for if: steps.verdict.outputs.adopt == 'true' @@ -275,4 +275,4 @@ jobs: CHOSEN: ${{ steps.verdict.outputs.chosen }} run: | echo "::notice::Measured: prerender.concurrency should become" \ - "$CHOSEN. Change www/nuxt.config.ts in a pull request." + "$CHOSEN. Change apps/www/nuxt.config.ts in a pull request." diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 3dc7668f..3ccdaee6 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -1,7 +1,28 @@ # yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json -# release-please and the npm publish both live in kirchDev/workflows; this file -# carries the triggers and composes the two. +# release-please lives in kirchDev/workflows and is called as a stub. The npm +# publish does NOT, and this header is the written argument CLAUDE.md asks of a +# workflow that carries its own jobs. +# +# WHY THE PUBLISH JOBS ARE OWN BODIES. The central `_publish-npm.yml` publishes +# the repository ROOT: it runs `pnpm publish` where it checks out, and derives a +# prerelease from the newest release tag with a leading `v` stripped. Since the +# monorepo move the root is a private workspace, the layer lives in +# `packages/duxt`, and its tags read `duxt@vX.Y.Z` — so that body would publish +# the wrong thing or nothing. And `_release-please.yml` forwards only +# release-please's ROOT `release_created`, which a path package never sets +# (release-please writes `packages/duxt--release_created` instead), so the old +# gate would never have opened again. What belongs centrally is a working +# directory input and the per-path outputs, which is a change to +# kirchDev/workflows; until that exists, the jobs below are what publishes. +# +# THE REGISTRY DECIDES A STABLE PUBLISH, NOT AN OUTPUT. Every workspace package +# under `packages/` that is not private is a candidate, and one is published +# when its `package.json` version has a `@v` tag and npm +# does not have that version yet. A package release-please did not bump has +# neither a new tag nor a new version, so nothing unchanged is ever released — +# the rule the monorepo decision rests on — and a publish that failed halfway is +# picked up again by the next push to main. name: Release Please on: @@ -25,34 +46,165 @@ jobs: secrets: BWS_ACCESS_TOKEN: ${{ secrets.BWS_ACCESS_TOKEN }} - publish-release: - name: Publish stable release + plan: + name: Plan the publishes needs: release-please - if: needs.release-please.outputs.release-created == 'true' + if: github.ref_name == 'main' + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + stable: ${{ steps.plan.outputs.stable }} + prerelease: ${{ steps.plan.outputs.prerelease }} + steps: + - uses: actions/checkout@v6 + with: + # The push's `before` commit has to be present to tell which packages + # this push changed. + fetch-depth: 0 + + - uses: actions/setup-node@v6 + with: + node-version: 24 + + - name: List what to publish + id: plan + env: + BEFORE: ${{ github.event.before }} + HEAD_COMMIT_MESSAGE: ${{ github.event.head_commit.message }} + run: | + node --input-type=module -e ' + import { execFileSync } from "node:child_process"; + import { appendFileSync, existsSync, readFileSync, readdirSync } from "node:fs"; + + const run = (command, args) => + execFileSync(command, args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }); + const succeeds = (command, args) => { + try { run(command, args); return true; } catch { return false; } + }; + + const packages = readdirSync("packages", { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && existsSync(`packages/${entry.name}/package.json`)) + .map((entry) => { + const dir = `packages/${entry.name}`; + const manifest = JSON.parse(readFileSync(`${dir}/package.json`, "utf8")); + return { dir, name: manifest.name, version: manifest.version, private: manifest.private === true }; + }) + .filter((pkg) => !pkg.private) + // release-please strips the scope to name the component. + .map((pkg) => ({ ...pkg, tag: `${pkg.name.replace(/^@[^/]+\//, "")}@v${pkg.version}` })); + + const published = (pkg) => { + try { return run("npm", ["view", `${pkg.name}@${pkg.version}`, "version"]).trim() !== ""; } + catch { return false; } + }; + + const stable = packages.filter((pkg) => + succeeds("git", ["ls-remote", "--exit-code", "--tags", "origin", `refs/tags/${pkg.tag}`]) && + !published(pkg) + ); + + // A prerelease for every package this push changed, and none on the + // version-bump commit release-please merges onto main. + const message = process.env.HEAD_COMMIT_MESSAGE ?? ""; + const releaseCommit = /^chore(\(main\))?: release/.test(message); + const before = process.env.BEFORE ?? ""; + const known = /^[0-9a-f]{40}$/.test(before) && !/^0+$/.test(before) && + succeeds("git", ["cat-file", "-e", `${before}^{commit}`]); + const changed = (pkg) => + !known || run("git", ["diff", "--name-only", before, "HEAD", "--", pkg.dir]).trim() !== ""; + const prerelease = releaseCommit ? [] : packages.filter(changed); + + appendFileSync( + process.env.GITHUB_OUTPUT, + `stable=${JSON.stringify(stable)}\nprerelease=${JSON.stringify(prerelease)}\n` + ); + console.log(`stable: ${stable.map((pkg) => pkg.tag).join(", ") || "none"}`); + console.log(`prerelease: ${prerelease.map((pkg) => pkg.name).join(", ") || "none"}`); + ' + + publish-release: + name: Publish ${{ matrix.package.tag }} + needs: plan + if: needs.plan.outputs.stable != '' && needs.plan.outputs.stable != '[]' + runs-on: ubuntu-latest permissions: contents: read # npm Trusted Publishing (OIDC) and the provenance attestation. No # NPM_TOKEN exists in this repo. id-token: write - uses: kirchDev/workflows/.github/workflows/_publish-npm.yml@1e57db95ff601f03ae87b78e329ef238f9d0cbf6 # v0.8.2 - with: - tag-name: ${{ needs.release-please.outputs.tag-name }} - # The layer ships source files; build the consumer to verify SSR. - build-script: build:app + strategy: + fail-fast: false + matrix: + package: ${{ fromJSON(needs.plan.outputs.stable) }} + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ matrix.package.tag }} + + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6 + + - uses: actions/setup-node@v6 + with: + node-version: 24 + cache: pnpm + registry-url: https://registry.npmjs.org + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + # The layer ships source files; build the site that consumes it to verify + # SSR before anything reaches the registry. + - name: Build + run: pnpm build:app + + - name: Publish + env: + PACKAGE_DIR: ${{ matrix.package.dir }} + working-directory: ${{ matrix.package.dir }} + run: pnpm publish --access public --provenance --no-git-checks --tag latest publish-prerelease: - name: Publish prerelease - # Skip the version-bump merges release-please pushes onto main. - if: >- - ${{ - github.ref_name == 'main' - && !startsWith(github.event.head_commit.message, 'chore(main): release') - && !startsWith(github.event.head_commit.message, 'chore: release') - }} + name: Publish ${{ matrix.package.name }} prerelease + needs: plan + if: needs.plan.outputs.prerelease != '' && needs.plan.outputs.prerelease != '[]' + runs-on: ubuntu-latest permissions: contents: read id-token: write - uses: kirchDev/workflows/.github/workflows/_publish-npm.yml@1e57db95ff601f03ae87b78e329ef238f9d0cbf6 # v0.8.2 - with: - prerelease: true - build-script: build:app + strategy: + fail-fast: false + matrix: + package: ${{ fromJSON(needs.plan.outputs.prerelease) }} + steps: + - uses: actions/checkout@v6 + + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6 + + - uses: actions/setup-node@v6 + with: + node-version: 24 + cache: pnpm + registry-url: https://registry.npmjs.org + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build + run: pnpm build:app + + # The same scheme the central body used: the next patch after the version + # on main, then `dev-main.-`, under the `dev-main` dist-tag. + - name: Compute the prerelease version + working-directory: ${{ matrix.package.dir }} + env: + RUN_NUMBER: ${{ github.run_number }} + run: | + set -euo pipefail + base=$(node -p 'const [a, b, c] = require("./package.json").version.split("-")[0].split(".").map(Number); `${a}.${b}.${c + 1}`') + npm version --no-git-tag-version --allow-same-version \ + "${base}-dev-main.${RUN_NUMBER}-${GITHUB_SHA::7}" + + - name: Publish + working-directory: ${{ matrix.package.dir }} + run: pnpm publish --access public --provenance --no-git-checks --tag dev-main diff --git a/.gitignore b/.gitignore index ed33049e..55d433c3 100644 --- a/.gitignore +++ b/.gitignore @@ -115,7 +115,17 @@ $RECYCLE.BIN/ # The rendered OG images kept between builds. Not `node_modules/.cache`, where # nuxt-og-image would put them by default: they survive a wiped install on # purpose, and a deploy carries this directory between runs by name. -www/.cache +apps/www/.cache + +# Turborepo's local task cache and per-package logs. Local only by decision: no +# remote cache, no account and no token secret. +.turbo + +# `prepack` copies the repository's README and LICENSE into the layer so the +# published tarball carries them, and `postpack` removes them again. Ignored in +# case a pack is interrupted between the two. +/packages/duxt/README.md +/packages/duxt/LICENSE # The benchmark harness writes these beside the repo root when its commands # are run by hand rather than by `.github/workflows/prerender-bench.yml` — the diff --git a/.oxfmtrc.json b/.oxfmtrc.json index 35515e1b..65c61a6a 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -13,7 +13,6 @@ "CLAUDE.md", "AGENTS.md", "docs/**/*.md", - "www/content/**/*.md", - "public/devtools/*.html" + "packages/duxt/public/devtools/*.html" ] } diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 2537c1f1..fc3205a5 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.4.0" + "packages/duxt": "0.4.0" } diff --git a/.vscode/settings.json b/.vscode/settings.json index bfb6dfc4..3ceccb54 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,3 @@ { - "i18n-ally.localesPaths": ["i18n", "i18n/locales"] + "i18n-ally.localesPaths": ["packages/duxt/i18n", "packages/duxt/i18n/locales"] } diff --git a/AGENTS.md b/AGENTS.md index 0603dadf..a24f3274 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,15 +47,17 @@ What that leaves as candidate value: the **ergonomics** (a compact `sources` lis - **The theme is shadcn-vue, wired through `shadcn-nuxt`** — a clean Tailwind 4 base with owned components in `app/components/ui/`, not Docus or Nuxt UI. Add one with `pnpm dlx shadcn-vue@latest add `; `components.json` already points the CLI at the layer's own alias. If the CLI refuses (it wants to install `reka-ui`/`@vueuse/core`/`@lucide/vue` into the workspace root and gives up), fetch the component from `https://www.shadcn-vue.com/r/styles/new-york/.json` and write its files by hand — then swap `@/…` for `@duxt/…` and every `lucide-vue-next` import for ``, which is what the components already here use. **They are prefixed `Ui`** (`shadcn.prefix` in `nuxt.config.ts`), so it is ``, ``, ``: without a prefix the layer auto-imports `Button`, `Input`, `Card` and a hundred other ordinary words into every site that extends it, and a collision with a consumer's own component resolves silently. The palette is the neutral shadcn set as CSS variables in `app/assets/css/duxt.css`, `dark` class toggled by `@nuxtjs/color-mode`; a consumer redefines a token in its own stylesheet rather than forking the file. - **Four icon sets, and the DOMAIN decides which.** The **stack** — a file, a fence's language, a tool you run — is `vscode-icons`, which carries the real colours in the file (`app/utils/file-icons.ts` and the package-manager tabs). A **third party as itself** — a platform, service or company the site links to or hands something to — is `simple-icons` (GitHub, Discord, Claude, OpenAI, Laravel). A locale's region is `flag`. Everything that is not a mark at all is `lucide`, which inherits `currentColor` and is the default. Where both collections carry a brand, the domain still decides: `vscode-icons:file-type-claude` is the icon for a Claude *file*, while the button that sends a page to the Claude *platform* is `simple-icons:claude`. Do not tint a monochrome mark by hand; if a coloured one belongs there, use it. The whole rule, the licences and why a licence is not a trademark: `docs/6.conventions/2.icons.md`. - **Markdown components are MDC, not MDX.** Content ships MDC, so `::callout{type="tip"}` works with no extra module. Components live in `app/components/content/`. -- **Numbered section prefixes are a non-issue.** Content strips them itself: `1.guides/` renders at `/guides`, `99.adr/` at `/adr`. Verified in `www/`. Reordering does not move a URL; only renaming the name part does. -- **No SQLite driver is installed.** Content's default is `better-sqlite3`, a native addon needing a node-gyp toolchain. `content.experimental.sqliteConnector: 'native'` uses Node 24's built-in `node:sqlite` instead, which needs no package at all — an ordinary `www/` build reads and renders through neither `better-sqlite3` nor `@libsql/client`. It is flagged experimental in Content; if that changes, `@libsql/client` is the prebuilt fallback, not `better-sqlite3`. The option was `experimental.nativeSqlite: true` until Content deprecated it in favour of the connector name — both still resolve to the same driver, but a **layer** is the worst place to carry a deprecation, because the consumer who sees the warning cannot see where it comes from or turn it off. `tests/sqlite-connector.test.ts` therefore reads the `@deprecated` tags out of the installed `@nuxt/content`'s own declarations and fails if anything the layer sets under `content.experimental` is one — so the next rename lands here rather than in someone else's build log. (`@libsql/client` and `pg` **are** now in `www/`'s devDependencies, and that is not a contradiction: they exist solely so the adapter matrix below can build this site against those two adapters. Nothing imports either, and a build with `DUXT_CONTENT_ADAPTER` unset loads neither.) -- **Which Content database adapters pass through, and how far that is actually known.** Content runs on five — `sqlite`, `d1`, `postgresql`, `libsql`, `pglite`. The layer is transparent to all of them because it reads the deployed database **only** through `queryCollection()`; `tests/content-database.test.ts` enforces exactly that, failing on any driver import under `server/` or `app/` and keeping the list of build-time files that may open a database closed. The fact that makes it work is that **Content keeps two databases**: `content._localDatabase` — the parse cache at `.data/content/contents.sqlite`, typed `sqlite | d1` and nothing else — and `content.database`, the deployed one. `content-cache.ts`, `modules/git-meta.ts` and `duxt report` all read the **first**, during a build, so no adapter choice moves them. `sqlite`, `d1`, `libsql` and `postgresql` are verified; **`pglite` is untested and is documented as untested** — do not upgrade that claim without a run behind it. `.github/workflows/adapters.yml` keeps `libsql` and `postgresql` honest by building `www/` against each (`DUXT_CONTENT_ADAPTER`, read in `www/nuxt.config.ts`) and then probing the endpoints that read the database at runtime with `pnpm check:adapters`. It is the second workflow here that is **not** a stub, and it is deliberately **not** in `pnpm check`: each leg is a second full Nuxt build, and the PostgreSQL one needs a server. Written up for consumers in `docs/2.concepts/11.databases.md`. -- **The extension surface is one build-time hook, and a duxt extension is a Nuxt layer.** `duxt:search:records` fires once per build — after Content has parsed every source, version, locale and generated section, and before Nitro prerenders or bundles — and hands a **frozen** `readonly DuxtSearchRecord[]`: `id` (a sha-256 prefix of `url`, because Meilisearch only accepts `A-Za-z0-9_-` as a document id), `url`, `title`, `content`, `source`, optional `version`, `locale`. No excerpt, no collection name, no display label: a snippet belongs to whoever draws the result list, a collection name is duxt's own bookkeeping, and a label is translated prose. The contract and the reasoning live in `search-records.ts`; `modules/search-records.ts` only decides when, and **skips building the payload entirely while nothing is listening** (it reads hookable's private `_hooks` and fails OPEN, because a hook that silently never fires is a bug with no symptom). There is **no `duxt.plugins` API and no generic hook registry**: `extends: ['@kirchdev/duxt', 'duxt-typesense']` already carries components, `app.config` defaults, collections, server routes, MCP tools and a module in one versioned package, so a plugin system would reimplement layer resolution inside a layer — and a registry a plugin writes into at import time hits the same two-loader wall that made `sectionTypes` a map rather than a registration call. `sectionTypes` stays the extension point for generated content types; UI changes stay slots and component shadowing. **A second hook needs a consumer that cannot be written without it** — a hook name is public surface, so renaming one is a breaking change and an unused one can never be removed. +- **Numbered section prefixes are a non-issue.** Content strips them itself: `1.guides/` renders at `/guides`, `99.adr/` at `/adr`. Verified in `apps/www/`. Reordering does not move a URL; only renaming the name part does. +- **No SQLite driver is installed.** Content's default is `better-sqlite3`, a native addon needing a node-gyp toolchain. `content.experimental.sqliteConnector: 'native'` uses Node 24's built-in `node:sqlite` instead, which needs no package at all — an ordinary `apps/www/` build reads and renders through neither `better-sqlite3` nor `@libsql/client`. It is flagged experimental in Content; if that changes, `@libsql/client` is the prebuilt fallback, not `better-sqlite3`. The option was `experimental.nativeSqlite: true` until Content deprecated it in favour of the connector name — both still resolve to the same driver, but a **layer** is the worst place to carry a deprecation, because the consumer who sees the warning cannot see where it comes from or turn it off. `tests/sqlite-connector.test.ts` therefore reads the `@deprecated` tags out of the installed `@nuxt/content`'s own declarations and fails if anything the layer sets under `content.experimental` is one — so the next rename lands here rather than in someone else's build log. (`@libsql/client` and `pg` **are** now in `apps/www/`'s devDependencies, and that is not a contradiction: they exist solely so the adapter matrix below can build this site against those two adapters. Nothing imports either, and a build with `DUXT_CONTENT_ADAPTER` unset loads neither.) +- **Which Content database adapters pass through, and how far that is actually known.** Content runs on five — `sqlite`, `d1`, `postgresql`, `libsql`, `pglite`. The layer is transparent to all of them because it reads the deployed database **only** through `queryCollection()`; `tests/content-database.test.ts` enforces exactly that, failing on any driver import under `server/` or `app/` and keeping the list of build-time files that may open a database closed. The fact that makes it work is that **Content keeps two databases**: `content._localDatabase` — the parse cache at `.data/content/contents.sqlite`, typed `sqlite | d1` and nothing else — and `content.database`, the deployed one. `build/content/content-cache.ts`, `modules/git-meta.ts` and `duxt report` all read the **first**, during a build, so no adapter choice moves them. `sqlite`, `d1`, `libsql` and `postgresql` are verified; **`pglite` is untested and is documented as untested** — do not upgrade that claim without a run behind it. `.github/workflows/adapters.yml` keeps `libsql` and `postgresql` honest by building `apps/www/` against each (`DUXT_CONTENT_ADAPTER`, read in `apps/www/nuxt.config.ts`) and then probing the endpoints that read the database at runtime with `pnpm check:adapters`. It is the second workflow here that is **not** a stub, and it is deliberately **not** in `pnpm check`: each leg is a second full Nuxt build, and the PostgreSQL one needs a server. Written up for consumers in `docs/2.concepts/11.databases.md`. +- **The extension surface is one build-time hook, and a duxt extension is a Nuxt layer.** `duxt:search:records` fires once per build — after Content has parsed every source, version, locale and generated section, and before Nitro prerenders or bundles — and hands a **frozen** `readonly DuxtSearchRecord[]`: `id` (a sha-256 prefix of `url`, because Meilisearch only accepts `A-Za-z0-9_-` as a document id), `url`, `title`, `content`, `source`, optional `version`, `locale`. No excerpt, no collection name, no display label: a snippet belongs to whoever draws the result list, a collection name is duxt's own bookkeeping, and a label is translated prose. The contract and the reasoning live in `build/search/search-records.ts`; `modules/search-records.ts` only decides when, and **skips building the payload entirely while nothing is listening** (it reads hookable's private `_hooks` and fails OPEN, because a hook that silently never fires is a bug with no symptom). There is **no `duxt.plugins` API and no generic hook registry**: `extends: ['@kirchdev/duxt', 'duxt-typesense']` already carries components, `app.config` defaults, collections, server routes, MCP tools and a module in one versioned package, so a plugin system would reimplement layer resolution inside a layer — and a registry a plugin writes into at import time hits the same two-loader wall that made `sectionTypes` a map rather than a registration call. `sectionTypes` stays the extension point for generated content types; UI changes stay slots and component shadowing. **A second hook needs a consumer that cannot be written without it** — a hook name is public surface, so renaming one is a breaking change and an unused one can never be removed. - **An MCP tool reaches the request through `useEvent()`, and the layer turns `nitro.experimental.asyncContext` on so it can.** `@nuxtjs/mcp-toolkit` registers a tool's handler with the MCP SDK unwrapped, so what the handler is handed is the SDK's own `RequestHandlerExtra` — `signal`, `requestId`, `sessionId`, `authInfo`, `requestInfo`, `sendNotification`, `sendRequest` — and **nothing of H3's**. All four tools read `extra.event` off it for their first nine days, which was always `undefined`; `queryCollection(undefined, …)` falls back to the global `$fetch` and `useRuntimeConfig(undefined)` to the global config, so it looked like it worked until `duxtLocaleSetup` dereferenced `event.context` for the i18n fallback and **every** tool call began answering `Cannot read properties of undefined (reading 'context')` (#90). Two things made that survivable for a day: the SDK turns a thrown handler into an `isError` **result**, not a JSON-RPC error, so a caller reading `payload.error` sees a successful call; and `tests/mcp-tools.test.ts` invented an `extra` with an `event` on it, a shape no transport produces. Both are closed — that `extra` is now a proxy that throws on any field the SDK does not define, and `check:adapters` reports an `isError` result as a failure with its text. **The flag is the price of having the real event**, and it is the one Nitro flag this layer imposes: the alternative is four tools answering from the global `$fetch` and the default locale, a second database-reading code path that exists only under `/mcp` and that only a Worker would ever have disagreed with. The toolkit's own `useMcpServer`, `useMcpSession` and `useMcpLogger` are built on the same call, so it is a prerequisite of mounting an MCP server rather than a preference. -- **A bounded listing is not a database assertion.** `check:adapters` asserted that `list_pages` returned `/getting-started`, but `list_pages` answers with the first `DUXT_PAGE_SIZE` paths in sort order — 25 of `www/`'s sort before the letter g, so the assertion was about page size and never about the database. It now asks the two questions separately: `list_pages` must come back with **some** rows, and `read_page` must come back with **that** page. Same rule as the OG-image and image-zoom fixtures: an assertion that cannot fail for the reason it names is not a check. -- **Content's per-collection search stays the default, and that was measured rather than assumed.** ADR-0010. Pagefind was evaluated as the first consumer of `duxt:search:records`, over `www/`'s real content — 33 collections, 2,918 pages, **11,937 records** in five languages and four versions — and `scripts/search-index-bench.ts` is what computed the numbers, with `tests/search-index-bench.test.ts` pinning the rule they were read by. It **wins on payload**: an English reader's first query costs 1,448 KB today (1,108 KB of WASM SQLite and its worker, 340 KB of dumps across the seven collections in scope) against **150 KB**, and 13 KB for every query after it. It **wins on ranking, per language**, and the filters fall straight out of the record contract (`source` → `/`, `/demo`, `/tf` …; `version` → `main`, `v0.1.0` …) with no adaptation at all. It **loses on two**: Pagefind has no fuzzy matching, and `verison` returned three unrelated pages rather than none — so Fuse has to stay *and* the `empty result → fall back` trigger stops firing; and it emits **one file per indexed section**, 12,066 of them against the 1,414 a Cloudflare build writes today, on a platform that caps a Workers version at 20,000 static files. One of three problems solved, one solved only within a language, one made worse, plus a file-count growth law of sections × sources × versions × locales — a different shape, not a better one. Pagefind stays a **layer** anyone can add (`extends: ['@kirchdev/duxt', 'duxt-pagefind']`), which is what the hook was opened for. `searchIndexVerdict` states the six conditions a replacement has to clear, so the next candidate is re-run rather than re-argued. +- **A bounded listing is not a database assertion.** `check:adapters` asserted that `list_pages` returned `/getting-started`, but `list_pages` answers with the first `DUXT_PAGE_SIZE` paths in sort order — 25 of `apps/www/`'s sort before the letter g, so the assertion was about page size and never about the database. It now asks the two questions separately: `list_pages` must come back with **some** rows, and `read_page` must come back with **that** page. Same rule as the OG-image and image-zoom fixtures: an assertion that cannot fail for the reason it names is not a check. +- **Content's per-collection search stays the default, and that was measured rather than assumed.** ADR-0010. Pagefind was evaluated as the first consumer of `duxt:search:records`, over `apps/www/`'s real content — 33 collections, 2,918 pages, **11,937 records** in five languages and four versions — and `apps/www/scripts/search-index-bench.ts` is what computed the numbers, with `apps/www/tests/search-index-bench.test.ts` pinning the rule they were read by. It **wins on payload**: an English reader's first query costs 1,448 KB today (1,108 KB of WASM SQLite and its worker, 340 KB of dumps across the seven collections in scope) against **150 KB**, and 13 KB for every query after it. It **wins on ranking, per language**, and the filters fall straight out of the record contract (`source` → `/`, `/demo`, `/tf` …; `version` → `main`, `v0.1.0` …) with no adaptation at all. It **loses on two**: Pagefind has no fuzzy matching, and `verison` returned three unrelated pages rather than none — so Fuse has to stay *and* the `empty result → fall back` trigger stops firing; and it emits **one file per indexed section**, 12,066 of them against the 1,414 a Cloudflare build writes today, on a platform that caps a Workers version at 20,000 static files. One of three problems solved, one solved only within a language, one made worse, plus a file-count growth law of sections × sources × versions × locales — a different shape, not a better one. Pagefind stays a **layer** anyone can add (`extends: ['@kirchdev/duxt', 'duxt-pagefind']`), which is what the hook was opened for. `searchIndexVerdict` states the six conditions a replacement has to clear, so the next candidate is re-run rather than re-argued. -**Repo shape — the root IS the layer.** `nuxt.config.ts`, `content.config.ts` and `app/` sit at the repo root, and `package.json` points at them with `main: "./nuxt.config.ts"` plus a `files` allowlist, so `extends: ['@kirchdev/duxt']` resolves. `www/` is the consuming site beside it — the only workspace package, and the development target, exactly as `www/` is in `ZTL-UwU/shadcn-docs-nuxt`. `nuxt` is a peerDependency of the layer and a real dependency only of `www/`. +**Repo shape — a Turborepo monorepo, and the root is not a package.** ADR-0011. The layer is `packages/duxt`: `nuxt.config.ts`, `content.config.ts`, `app/`, `modules/`, `server/`, `i18n/`, `public/`, `bin/`, and the build-time modules in topic folders under `build/` (`sources/`, `sections/`, `bruno/`, `openapi/`, `search/`, `content/`, `og-image/`, `git/`, `config/`, `cli/`). Its `package.json` points at them with `main: "./nuxt.config.ts"` plus a `files` allowlist of directories, so `extends: ['@kirchdev/duxt']` resolves; the `exports` map keeps every subpath name it had before the move. `apps/www` is the consuming site — the development target, exactly as `www/` is in `ZTL-UwU/shadcn-docs-nuxt` — and carries the scripts and tests that read its build. The root keeps only workspace and meta configuration (`turbo.json`, `pnpm-workspace.yaml`, lint/format, husky, `.github`, the agent files, `scripts/check-policy-parity.ts` and `scripts/git-dir-guard.ts`) plus `docs/`, which `apps/www` publishes, and the layer's `CHANGELOG.md` (see release-please below). `nuxt` is a peerDependency of the layer and a real dependency only of `apps/www`. The provider layers from #74 and #75 land as `packages/duxt-typesense` and `packages/duxt-meilisearch`, peer-depending on `@kirchdev/duxt` with a wide range (`>=0.4.0 <1`), never `^0.x`. + +**How to read a path in this file.** One that starts with `app/`, `build/`, `i18n/`, `modules/`, `server/` or `tests/`, and a bare `nuxt.config.ts` or `content.config.ts`, is the layer's — relative to `packages/duxt`. Every other path is relative to the repository root, `apps/www/…` included. > [!IMPORTANT] > **Nothing layer-relative resolves the way it reads.** Three places have hit this already, and a fourth will: the Content collection `cwd`, the `css` entry and `componentDir` in `nuxt.config.ts` (both go through the `layer()` helper resolving against `import.meta.url`), and the `@` alias — which belongs to whoever extends the layer, so the layer's own imports use `@duxt` instead. Assume any path written here is read from the consumer's directory until proven otherwise. @@ -63,7 +65,7 @@ What that leaves as candidate value: the **ergonomics** (a compact `sources` lis > [!IMPORTANT] > **A layer's collections resolve against the LAYER, not the consumer.** Content sets `collection.__rootDir = curr.cwd` per layer, so a relative `source.cwd` in this repo's `content.config.ts` points into this repo — never into the site that extends it. The layer therefore computes an absolute path at load time (`join(process.cwd(), 'docs')`), which works because c12 executes the config. This is the seam the whole `sources` shorthand sits on. -`www/` wants edge cases, ugly frontmatter, several sources and a tag to read from. [`kirchDev/duxt-starter`](https://github.com/kirchDev/duxt-starter) would be a **different artifact** — minimal and exemplary, what a stranger clones with `npx nuxi@latest init -t github:kirchDev/duxt-starter`. Do not conflate the two; a development site makes a bad starter. +`apps/www/` wants edge cases, ugly frontmatter, several sources and a tag to read from. [`kirchDev/duxt-starter`](https://github.com/kirchDev/duxt-starter) would be a **different artifact** — minimal and exemplary, what a stranger clones with `npx nuxi@latest init -t github:kirchDev/duxt-starter`. Do not conflate the two; a development site makes a bad starter. ## Commands @@ -72,13 +74,13 @@ What that leaves as candidate value: the **ergonomics** (a compact `sources` lis | `pnpm install` | Install deps and wire husky hooks via the `prepare` script | | `pnpm lint` | `oxlint . --deny-warnings` | | `pnpm format` | `oxfmt --check .` (note: `format` is the check, not fix) | -| `pnpm typecheck` | `tsc --noEmit` over the meta scripts | -| `pnpm typecheck:app`| `nuxt typecheck` over the layer, run through `www/` | -| `pnpm test` | `vitest run` over the layer's pure logic | -| `pnpm build:app` | `nuxt build` in `www/` — the gate's SSR check | -| `pnpm build:www` | `nuxt build` in `www/` with the Workers preset, then `check:routes` over what it wrote | -| `pnpm publish:www` | `wrangler deploy`s whatever `www/.output` already holds | -| `pnpm deploy:www` | Builds `www/` for Workers and `wrangler deploy`s it | +| `pnpm typecheck` | `tsc --noEmit` over the meta scripts and both packages' `scripts/` | +| `pnpm typecheck:app`| `turbo run typecheck --filter=www` — `nuxt typecheck` over the layer, run through `apps/www` | +| `pnpm test` | `turbo run test` — the layer's pure logic in `packages/duxt`, the site's checks and workflows in `apps/www` | +| `pnpm build:app` | `turbo run build --filter=www` — `nuxt build` in `apps/www`, the gate's SSR check | +| `pnpm build:www` | `nuxt build` in `apps/www` with the Workers preset, then `check:routes` over what it wrote | +| `pnpm publish:www` | `wrangler deploy`s whatever `apps/www/.output` already holds | +| `pnpm deploy:www` | Builds `apps/www` for Workers and `wrangler deploy`s it | | `pnpm og:cache` | Prints the rendered-OG-image cache key, or `--report`s what a build reused | | `pnpm check` | Runs `lint` + `format` + both typechecks + `test` + `check:policy` + `check:previews` + `build:app` + `check:a11y` + `check:seo` + `check:images` + `check:keyboard` + `check:overflow` — the CI gate | | `pnpm check:policy` | Proves the two agent policy files ban the same commands | @@ -95,17 +97,19 @@ What that leaves as candidate value: the **ergonomics** (a compact `sources` lis | `pnpm taze` | Interactive dependency upgrade check | | `pnpm taze:w` | Write upgrade results | -Tests cover the layer's pure logic — the source resolver, the config merge, the icon lookup, the build validator, the redirect map, the 404's nearest-page scoring — in `tests/`, run by vitest. `tests/contrast.test.ts` is the odd one out and deliberate: it parses the palette out of `duxt.css` and measures every foreground against the background it is paired with, because that is the one accessibility rule `check:a11y` cannot answer (jsdom has no computed colour) and the one this theme actually broke. Component rendering is not covered: it needs a Nuxt environment, and the failures this repo actually had were SSR failures, which is why `check` builds the site instead. `check:a11y` then runs axe-core over that build: jsdom has no layout, so `color-contrast` and `target-size` are reported as skipped rather than passed, and the structural rules — landmarks, names, heading order, ARIA — are what it enforces. `check:images` reads one more thing off the same build, and exists because of what that gap let through: `ProseImg`'s zoom had been dead since the day it was written — Vue casts an absent `boolean | string` prop to `false`, so no image was ever zoomable — and nobody could see it, because every `![…]` in the reference sits inside a fenced code block and the build therefore rendered no image at all. `www/demo/docs/3.images.md` is the fixture that renders one of each shape; the check reads its `srcset`, `sizes`, pass-through formats and zoom button back off the rendered page, then opens the viewer in Chromium to prove that it enlarges the image and keeps its close control off the pixels. **A component the build never renders is a component nothing checks** — that is the general rule, and the fixture is how this one pays it. `check:keyboard` uses that browser one step further on: #89 was a dialog that rendered perfectly and could not be used, because its input was a plain `` and reka's listbox therefore wired no arrow handling and no `aria-activedescendant` to it — results reachable by mouse alone, with every gate green. No parse can see that, because the defect is in what a KEY does; axe under jsdom has no focus model and the component does not mount outside a Nuxt environment. So `playwright-core` drives the built site — and **no browser is ever downloaded**: it runs whatever Chromium-family browser the machine already has (a runner image's Chrome, a contributor's own, a Playwright cache from any project), which is what makes it affordable in the gate and why `check:a11y` stays on jsdom instead of moving here. It walks the dialog's entry list, which every site draws with no query typed, and asserts the input keeps focus while the list takes the highlight, that arrowing only ever lands on a real `role="option"` and never on a heading or caption, and that Enter opens and Escape closes. A typed query is exercised on top and **reported rather than required**: search results are where the `aria-hidden` provenance captions are, but a site whose search returns nothing draws none — so that leg says out loud when it was given no rows rather than making this gate depend on another bug's fix. `check:overflow` is another browser check and the reason finding one lives in `scripts/browser.ts` rather than in any caller: #91 was `/getting-started` overflowing the document by **56px at 320px**, and `/adr` and `/demo` did the same — the title row was `nowrap`, `DuxtCopyPage` is `shrink-0` by choice, and an `

` keeps `min-width: auto`, so *Introduction* as one unbreakable word at `text-4xl` pinned the row at 199.2px of min-content and the documentation's own entry point scrolled sideways. **A width nothing measures is a width nothing protects**: axe under jsdom has no layout at all, the static half of `check:images` measures only declared image sizes, and `check:keyboard` looks at one desktop viewport. So six pages — one of each header and body kind, each carrying the reason it is on the list — are loaded at 320px and 412px and asserted to have `scrollWidth - clientWidth === 0`. It asserts a **second** thing deliberately, because the obvious cure is worse than the disease: the heading must not be clipped inside its own box and the copy control must lie fully within the viewport, so `truncate` on the title or `overflow-hidden` on the row cannot buy a green gate. Content that is legitimately wider than a phone needs no exemption — a code fence, a wide table and a diagram each sit in their own `overflow-x: auto` scroller and so never reach the document's scroll width, which is why `/adr` is one of the six rather than an exception to them. The fix itself was to let the row wrap below `sm` with `min-w-0` + `wrap-break-word` on the heading as the backstop; `sm:flex-nowrap` is not decoration, because `flex-wrap` breaks lines on an item's *max*-content and wrapping unconditionally would demote the control below any ordinary long title on a desktop. CI runs whatever `check` chains on PR; adding a check to the `check` script is enough, no workflow change needed. The one exception is `check:adapters`, which is driven by `.github/workflows/adapters.yml` instead — it needs a build per adapter and, for PostgreSQL, a server, so it parallelises as a matrix rather than lengthening the gate. +Tests cover the layer's pure logic — the source resolver, the config merge, the icon lookup, the build validator, the redirect map, the 404's nearest-page scoring — in `tests/`, run by vitest; the site's own checks, workflows, process guard and documentation tree are tested in `apps/www/tests`, and `pnpm test` runs both through `turbo run test`. `tests/contrast.test.ts` is the odd one out and deliberate: it parses the palette out of `duxt.css` and measures every foreground against the background it is paired with, because that is the one accessibility rule `check:a11y` cannot answer (jsdom has no computed colour) and the one this theme actually broke. Component rendering is not covered: it needs a Nuxt environment, and the failures this repo actually had were SSR failures, which is why `check` builds the site instead. `check:a11y` then runs axe-core over that build: jsdom has no layout, so `color-contrast` and `target-size` are reported as skipped rather than passed, and the structural rules — landmarks, names, heading order, ARIA — are what it enforces. `check:images` reads one more thing off the same build, and exists because of what that gap let through: `ProseImg`'s zoom had been dead since the day it was written — Vue casts an absent `boolean | string` prop to `false`, so no image was ever zoomable — and nobody could see it, because every `![…]` in the reference sits inside a fenced code block and the build therefore rendered no image at all. `apps/www/demo/docs/3.images.md` is the fixture that renders one of each shape; the check reads its `srcset`, `sizes`, pass-through formats and zoom button back off the rendered page, then opens the viewer in Chromium to prove that it enlarges the image and keeps its close control off the pixels. **A component the build never renders is a component nothing checks** — that is the general rule, and the fixture is how this one pays it. `check:keyboard` uses that browser one step further on: #89 was a dialog that rendered perfectly and could not be used, because its input was a plain `` and reka's listbox therefore wired no arrow handling and no `aria-activedescendant` to it — results reachable by mouse alone, with every gate green. No parse can see that, because the defect is in what a KEY does; axe under jsdom has no focus model and the component does not mount outside a Nuxt environment. So `playwright-core` drives the built site — and **no browser is ever downloaded**: it runs whatever Chromium-family browser the machine already has (a runner image's Chrome, a contributor's own, a Playwright cache from any project), which is what makes it affordable in the gate and why `check:a11y` stays on jsdom instead of moving here. It walks the dialog's entry list, which every site draws with no query typed, and asserts the input keeps focus while the list takes the highlight, that arrowing only ever lands on a real `role="option"` and never on a heading or caption, and that Enter opens and Escape closes. A typed query is exercised on top and **reported rather than required**: search results are where the `aria-hidden` provenance captions are, but a site whose search returns nothing draws none — so that leg says out loud when it was given no rows rather than making this gate depend on another bug's fix. `check:overflow` is another browser check and the reason finding one lives in `apps/www/scripts/browser.ts` rather than in any caller: #91 was `/getting-started` overflowing the document by **56px at 320px**, and `/adr` and `/demo` did the same — the title row was `nowrap`, `DuxtCopyPage` is `shrink-0` by choice, and an `

` keeps `min-width: auto`, so *Introduction* as one unbreakable word at `text-4xl` pinned the row at 199.2px of min-content and the documentation's own entry point scrolled sideways. **A width nothing measures is a width nothing protects**: axe under jsdom has no layout at all, the static half of `check:images` measures only declared image sizes, and `check:keyboard` looks at one desktop viewport. So six pages — one of each header and body kind, each carrying the reason it is on the list — are loaded at 320px and 412px and asserted to have `scrollWidth - clientWidth === 0`. It asserts a **second** thing deliberately, because the obvious cure is worse than the disease: the heading must not be clipped inside its own box and the copy control must lie fully within the viewport, so `truncate` on the title or `overflow-hidden` on the row cannot buy a green gate. Content that is legitimately wider than a phone needs no exemption — a code fence, a wide table and a diagram each sit in their own `overflow-x: auto` scroller and so never reach the document's scroll width, which is why `/adr` is one of the six rather than an exception to them. The fix itself was to let the row wrap below `sm` with `min-w-0` + `wrap-break-word` on the heading as the backstop; `sm:flex-nowrap` is not decoration, because `flex-wrap` breaks lines on an item's *max*-content and wrapping unconditionally would demote the control below any ordinary long title on a desktop. CI runs whatever `check` chains on PR; adding a check to the `check` script is enough, no workflow change needed. The one exception is `check:adapters`, which is driven by `.github/workflows/adapters.yml` instead — it needs a build per adapter and, for PostgreSQL, a server, so it parallelises as a matrix rather than lengthening the gate. ## Architecture / conventions - **Node 24, pnpm 12, TypeScript 6.** Pinned via `.nvmrc`, `engines`, and `packageManager`. `pnpm-workspace.yaml` enforces `minimumReleaseAge=4320` (3-day cooldown), isolated node-linker. Don't loosen these without reason. **TypeScript stays on 6 deliberately**: 7 is the native port, whose `exports` map no longer exposes the compiler internals Volar builds on, so `vue-tsc` cannot run on it — and without `vue-tsc` the layer has no typecheck at all. Move to 7 when Volar does, not before. Package-manager enforcement carries no key on purpose: pnpm 11 replaced `packageManagerStrict`/`packageManagerStrictVersion` with `pmOnFail`, whose default `download` already errors on a foreign package manager and fetches the pinned pnpm version — every other value only weakens it, so leave it unset (the rationale sits as a comment in the file). - **A heavy browser dependency is loaded on demand, never bundled.** `mermaid` (~0.5 MB) and CodeMirror 6 (the request-body editor in the API reference) are both `import()`ed inside the component that draws them, so a page without a diagram or an endpoint downloads neither. That is the bar a new one has to clear — which is also why the body editor is not Monaco: an editor whose workers and megabytes every consumer site carries, for a box that holds ten lines of JSON, is the wrong trade for a package. - **oxc, not eslint/prettier.** Linting via `oxlint`, formatting via `oxfmt`. Configs live in `.oxlintrc.json` / `.oxfmtrc.json`. `oxlint` uses `unicorn` + `oxc` plugins; rules deliberately minimal. -- **TypeScript, no build step.** The meta scripts and the three tool configs are `.ts` — Node 24 strips types natively, so `scripts/check-policy-parity.ts`, `commitlint.config.ts`, `lint-staged.config.ts` and `taze.config.ts` stay directly executable and each tool loads its own `.ts` config unaided. `tsconfig.json` is `noEmit` + `strict` + `erasableSyntaxOnly`, so only strippable syntax (no enums, no parameter properties) can be written; `pnpm typecheck` is the gate for those. The layer itself is checked separately by `pnpm typecheck:app` (`nuxt typecheck` in `www/`), because `tsc` cannot see a Nuxt config's module options — those exist only in generated types. `oxlint` + `oxfmt` cover `.vue`, so no ESLint is coming. +- **Turborepo runs the package tasks, and its cache is local only.** ADR-0011. The root scripts stay a `&&` chain of `pnpm ` calls — the central CI body splits `check` on exactly that shape — and each package task delegates to `turbo run --filter=…`. No remote cache, no account, no token. A task is cached only where its declared inputs determine its result: `test` is, with `docs/`, the site's `app.config.ts`, the root `scripts/` and the workflows named as inputs where a suite reads them; `typecheck`, `build` and every check over a build resolve `latest` against a remote and are `cache: false`. `envMode` is `loose`, because the checks read `DUXT_*`, `NITRO_PRESET` and the runner's own variables. `turbo` stays out of the permission allow list, as the policy below requires of anything that runs arbitrary code. +- **The layer's dependencies are hoisted to the workspace root.** `publicHoistPattern` in `pnpm-workspace.yaml` lists every direct dependency of `packages/duxt` that the site resolved while the root was the layer — 35 of the 41. `apps/www` builds the layer's code from its own directory and resolves bare imports upwards, and those packages used to sit in the root `node_modules` by accident of the old layout. Without the hoist, two things broke that the move never meant to change: `nuxt typecheck` lost the packages its generated declarations import by name, so every Content query typed as `never`; and the Workers build, which bundles dependencies rather than externalising them, could no longer resolve `shiki` and died on its `onig.wasm`. The six that did not resolve before stay unhoisted. **A dependency added to the layer that the site has to reach belongs in that list too.** +- **TypeScript, no build step.** The meta scripts and the three tool configs are `.ts` — Node 24 strips types natively, so `scripts/check-policy-parity.ts`, `commitlint.config.ts`, `lint-staged.config.ts` and `taze.config.ts` stay directly executable and each tool loads its own `.ts` config unaided. The root `tsconfig.json` is `noEmit` + `strict` + `erasableSyntaxOnly` over those and over both packages' `scripts/`, so only strippable syntax (no enums, no parameter properties) can be written; `pnpm typecheck` is the gate for those. The layer itself is checked separately by `pnpm typecheck:app` (`nuxt typecheck` in `apps/www/`), because `tsc` cannot see a Nuxt config's module options — those exist only in generated types. `oxlint` + `oxfmt` cover `.vue`, so no ESLint is coming. - **Husky hooks** (`.husky/pre-commit`, `.husky/commit-msg`) run `lint-staged` and `commitlint`. `lint-staged.config.ts` excludes `README.md`, `CLAUDE.md`, and `AGENTS.md` (free-form prose) and `pnpm-lock.yaml`. `oxlint --fix --deny-warnings` then `oxfmt` on JS/TS; `oxfmt` only on JSON/YAML/MD. - **Conventional Commits enforced** via `@commitlint/config-conventional`. Don't `--no-verify` unless explicitly asked. -- **release-please** drives the versioning. Files: `release-please-config.json`, `.release-please-manifest.json`, `.github/workflows/release-please.yml`. `release-type: node` (this is a published package, so `package.json` gets bumped too), `include-v-in-tag: true`, starting from `0.0.0`. Publishing to npm is a job added to `release-please.yml`, gated on `needs.release-please.outputs.release-created`. +- **release-please** drives the versioning, in manifest mode with **one entry per published package**. Files: `release-please-config.json`, `.release-please-manifest.json`, `.github/workflows/release-please.yml`. `release-type: node`, and every package tags `@vX.Y.Z` — `include-component-in-tag: true` with `tag-separator: "@"`, the component being the package name without its scope — so `duxt@v0.5.0` stands beside `duxt-typesense@v0.1.0`, while `package.json` and npm keep the bare number. A release publishes only the packages it bumped; **no package is ever released without changes**, which is why there is no shared version. The plain `v0.1.0`…`v0.4.0` tags stay, and the top-level `last-release-sha` bridges from `v0.4.0` to the new tag pattern once. **The layer's changelog stays at the repository root** (`changelog-path: "/CHANGELOG.md"`, which release-please reads as root-relative): `apps/www` publishes the changelog of every version it serves, read at that version's own checkout, and every tag cut before the move has the file there. A provider package keeps its changelog in its own directory. **Tag-based versioning in the layer reads component tags** — see `tagComponent` in `build/sources/sources-resolve.ts` — and `apps/www`'s own source names `tagComponent: 'duxt'`, so `latest` follows the layer's releases rather than a provider's. - **Workflows** use `actions/checkout@v6`, `actions/setup-node@v6`, `pnpm/action-setup@v6`, `github/codeql-action/{init,analyze}@v4`. Keep these pinned to major versions; Dependabot bumps them monthly. - **CodeQL** scans `actions` + `javascript-typescript` with `security-extended,security-and-quality` queries, gated by path filters so non-code changes don't trigger it. - **Dependabot** groups all minor/patch updates per ecosystem into a single PR (`npm-minor-patch`, `actions-minor-patch`). Majors come as separate PRs. @@ -148,18 +152,18 @@ codex execpolicy check --pretty --rules .codex/rules/default.rules -- git push - ## Workflows are calls, not copies -Every file in `.github/workflows/` is a **stub**: a trigger and a `uses:` pointing at a body in [`kirchDev/workflows`](https://github.com/kirchDev/workflows). This repo carries the calls, not 727 lines of workflow — and a fix made centrally reaches it on its next Dependabot bump instead of never. **Two carry their own bodies, and both say why in their headers**: `deploy.yml`, because there is no central body for a Cloudflare deploy, and `prerender-bench.yml`, because "build one repository's site eighteen times and compare the prerender phase" is unlikely ever to have a second caller. A third needs the same argument made in writing before it is written. +Every file in `.github/workflows/` is a **stub**: a trigger and a `uses:` pointing at a body in [`kirchDev/workflows`](https://github.com/kirchDev/workflows). This repo carries the calls, not 727 lines of workflow — and a fix made centrally reaches it on its next Dependabot bump instead of never. **Three carry their own jobs, and each says why in its header**: `deploy.yml`, because there is no central body for a Cloudflare deploy; `prerender-bench.yml`, because "build one repository's site eighteen times and compare the prerender phase" is unlikely ever to have a second caller; and the publish jobs in `release-please.yml`, because the central `_publish-npm.yml` publishes the repository root and `_release-please.yml` forwards only the root package's outputs — neither fits a workspace whose packages live under `packages/`. That third one is a gap in `kirchDev/workflows` rather than a permanent exception: a working-directory input and the per-path outputs there would let it become a call again. A fourth needs the same argument made in writing before it is written. What follows: - **Do not paste a workflow body back in.** If a stub almost fits, the answer is an input on the body or an own job beside the call — see that repository's `docs/1.guides/2.add-a-body.md`. - **The pins are commit SHAs with the version as a trailing comment.** Dependabot raises the bumps; the `github-actions` ecosystem is already configured in `.github/dependabot.yml`. -- **Publishing** is an own job in `release-please.yml`, gated on `needs.release-please.outputs.release-created` — not a forked workflow. +- **Publishing** is own jobs in `release-please.yml` — not a forked workflow — and **the registry decides, not an output**: a workspace package under `packages/` is published when its version has a `@v` tag and npm does not have that version yet, and a `dev-main` prerelease goes out only for the packages the push to `main` changed. A package release-please did not bump therefore never publishes again, and a publish that failed halfway is picked up by the next push. - **Checks come from `package.json`.** `ci.yml` runs whatever the `check` script chains, so adding a check needs no workflow change at all. -## Deployment — `www/` on Cloudflare Workers +## Deployment — `apps/www/` on Cloudflare Workers -`www/` is deployed as **one Worker with static assets**, built in GitHub Actions and uploaded with wrangler. There is no Pages git integration and no build on Cloudflare's side: `.github/workflows/deploy.yml` deploys on every push to `main` and again when a release is published, building with `NITRO_PRESET=cloudflare-module` and then `wrangler deploy`ing the result. One place builds, and it is the one whose logs we keep. **It is two jobs, not one** — `build` and `publish`, for the reason below. +`apps/www/` is deployed as **one Worker with static assets**, built in GitHub Actions and uploaded with wrangler. There is no Pages git integration and no build on Cloudflare's side: `.github/workflows/deploy.yml` deploys on every push to `main` and again when a release is published, building with `NITRO_PRESET=cloudflare-module` and then `wrangler deploy`ing the result. One place builds, and it is the one whose logs we keep. **It is two jobs, not one** — `build` and `publish`, for the reason below. **SSR on the edge, with everything prerendered that can be.** Not a choice between static and SSR: `routeRules: { '/**': { prerender: true } }` writes every doc page and every OG image into `.output/public`, which the assets binding serves without ever invoking the Worker. **Six of the routes still running** are the reason the site is not simply `nuxt generate`d: @@ -173,28 +177,28 @@ What follows: A static build kills all six, which are the layer's whole pitch. That is the trade, and it was taken deliberately. **Those six are not everything the Worker answers**, and reading the list as exhaustive is how this drifted the first time: `robots.txt`, the two `/mcp` helpers beside the server itself (`/mcp/deeplink`, `/mcp/badge.svg`) and the sitemap's `style.xsl` and `nuxt-content-urls.json` run too. The table below classifies all of them, and everything else the build registers is named there as well. > [!IMPORTANT] -> **That list is a summary of `scripts/check-routes.ts`, which is the authoritative one.** It used to be written out three times — here, in `www/nuxt.config.ts` and in `www/wrangler.jsonc` — the three named different sets, and `llms-full.txt` and `rss.xml` fell through every gap between them. `DEPLOYMENT_ROUTES` there answers asset-or-Worker, D1-or-not, the fallback and the reason per route, and `pnpm check:routes` walks a Cloudflare build's `.output/public` and fails if the artifact disagrees. **It also reads Nitro's handler manifest out of the built bundle and requires every registered route to be either a row in that table or a named exemption with a reason** — the file comparison alone can only check the routes that already have a row, which is exactly the blindness that let `llms-full.txt` and `rss.xml` go unaccounted for. Reading the manifest fails open: a bundle it cannot find one in is reported, never passed. It is the last step of `build:cf`, so every Workers build runs it — CI's build job included, which means a build whose artifact disagrees never reaches the publish job. It is **not** in `pnpm check`, which builds the Node server. Change the classification there first; a route that moves side is a change to that table, not to prose. +> **That list is a summary of `apps/www/scripts/check-routes.ts`, which is the authoritative one.** It used to be written out three times — here, in `apps/www/nuxt.config.ts` and in `apps/www/wrangler.jsonc` — the three named different sets, and `llms-full.txt` and `rss.xml` fell through every gap between them. `DEPLOYMENT_ROUTES` there answers asset-or-Worker, D1-or-not, the fallback and the reason per route, and `pnpm check:routes` walks a Cloudflare build's `.output/public` and fails if the artifact disagrees. **It also reads Nitro's handler manifest out of the built bundle and requires every registered route to be either a row in that table or a named exemption with a reason** — the file comparison alone can only check the routes that already have a row, which is exactly the blindness that let `llms-full.txt` and `rss.xml` go unaccounted for. Reading the manifest fails open: a bundle it cannot find one in is reported, never passed. It is the last step of `build:cf`, so every Workers build runs it — CI's build job included, which means a build whose artifact disagrees never reaches the publish job. It is **not** in `pnpm check`, which builds the Node server. Change the classification there first; a route that moves side is a change to that table, not to prose. -Three Workers facts follow, and all three are guarded by `const cloudflare = NITRO_PRESET.startsWith('cloudflare')` in `www/nuxt.config.ts` — `pnpm build:app`, the gate's SSR check, still builds an ordinary Node server, because every one of them would be wrong locally: +Three Workers facts follow, and all three are guarded by `const cloudflare = NITRO_PRESET.startsWith('cloudflare')` in `apps/www/nuxt.config.ts` — `pnpm build:app`, the gate's SSR check, still builds an ordinary Node server, because every one of them would be wrong locally: - **Content needs D1.** A Worker has no filesystem and no `node:sqlite`, so `content.database = { type: 'd1', bindingName: 'DB' }` and Content restores its dump into D1 on the first request after a deploy. Five of the six above read it — every one but `/demo/echo` calls `queryCollection()` at request time — and the table names one more D1 reader that list does not: `/__sitemap__/nuxt-content-urls.json`, which queries every page collection and which nothing on a deployed site asks for, because the sitemaps themselves are prerendered. The prerendered pages never touch the binding. `experimental.sqliteConnector` in the layer covers the local case and means nothing here. - **OG images are build-time only.** `@resvg/resvg-js` is a native Node binding and cannot run on workerd at all, so `ogImage.zeroRuntime` strips the renderer out of the bundle and leaves the images the prerender pass wrote. Every OG image here is a function of a page, and every page is prerendered, so nothing is lost. If a dynamically rendered image is ever needed, the answer is the Takumi/WASM renderer, not the native one. -- **And they are kept between builds.** Deploy #4 rendered 1,053 of them in a 158-second prerender pass, almost all redrawing images no commit had touched. nuxt-og-image can keep them — `ogImage.buildCache`, keyed per image by the page's own options, the template's source and the module's version — and does nothing with that until the directory outlives the runner, which is the cache step in `deploy.yml`. `og-image-cache.ts` in the layer supplies both halves: the **directory**, stamped with a digest of every rendering input that key cannot see (the fonts, the renderer options, the renderer packages) and emptied when one moves, so a stale image is never served; and the **CI key**, namespaced by the renderer versions alone so a `satori` bump misses the restore rather than downloading images it is about to discard. The command cannot compute the fuller digest, because a site's renderer options live in a `nuxt.config.ts` that would claim a Nuxt process if a command loaded it — so the level that decides whether an image may be *served* is the build's, and the key only decides whether a tarball is worth *downloading*. `pnpm og:cache --report` prints what a build reused, rendered and timed out on, and the deploy writes it into the run summary. **Measured on three local Workers builds of one commit**: cold, 1,132 routes prerendered in 190s with 237 images rendered; warm, the same 1,132 routes in **117s with nothing rendered at all** — a 38% cut to the prerender phase, 281 images in the output both times and zero timeouts in either. A third build with one colour changed in the template re-rendered all 237, which is the invalidation that matters most and the one nothing else would have caught. It rests on a property #45 measured independently from the other side: the 281 OG images come out **byte-identical** between builds, so satori renders reproducibly and a cached image is the same image. +- **And they are kept between builds.** Deploy #4 rendered 1,053 of them in a 158-second prerender pass, almost all redrawing images no commit had touched. nuxt-og-image can keep them — `ogImage.buildCache`, keyed per image by the page's own options, the template's source and the module's version — and does nothing with that until the directory outlives the runner, which is the cache step in `deploy.yml`. `build/og-image/og-image-cache.ts` in the layer supplies both halves: the **directory**, stamped with a digest of every rendering input that key cannot see (the fonts, the renderer options, the renderer packages) and emptied when one moves, so a stale image is never served; and the **CI key**, namespaced by the renderer versions alone so a `satori` bump misses the restore rather than downloading images it is about to discard. The command cannot compute the fuller digest, because a site's renderer options live in a `nuxt.config.ts` that would claim a Nuxt process if a command loaded it — so the level that decides whether an image may be *served* is the build's, and the key only decides whether a tarball is worth *downloading*. `pnpm og:cache --report` prints what a build reused, rendered and timed out on, and the deploy writes it into the run summary. **Measured on three local Workers builds of one commit**: cold, 1,132 routes prerendered in 190s with 237 images rendered; warm, the same 1,132 routes in **117s with nothing rendered at all** — a 38% cut to the prerender phase, 281 images in the output both times and zero timeouts in either. A third build with one colour changed in the template re-rendered all 237, which is the invalidation that matters most and the one nothing else would have caught. It rests on a property #45 measured independently from the other side: the 281 OG images come out **byte-identical** between builds, so satori renders reproducibly and a cached image is the same image. - **`nodejs_compat` is not optional.** Content's Nitro half, the MCP SDK and Nitro's own runtime all reach for node builtins; without the flag the Worker fails at the first import. -- **`/mcp` needs the `agents` package.** `@nuxtjs/mcp-toolkit` picks a provider by preset, and its Cloudflare one imports `agents/mcp` — Cloudflare's MCP Handler API, a stateless handler, so no Durable Object and no binding. It is an optional peer dependency, so nothing installs it for you: without it the Nitro build dies with `Cannot resolve "agents/mcp" … and externals are not allowed`. It sits in `www/`, not in the layer — a consumer deploying to Node must not carry it — and a consumer deploying duxt to Workers has to add it for the same reason. -- **The site's origin has to be stated.** `i18n.baseUrl` in `www/nuxt.config.ts` is `https://duxt.app`, and the layer's module turns it into `site.url` — the sitemap, the canonicals, robots.txt and the absolute OG URLs all read it. It does not degrade when missing: the sitemap fails the prerender outright with "You must provide a site URL". -- **The OG renders time out under the crawl, and that is not fully solved.** Every page renders an OG image through satori while the crawler walks the site, and at Nitro's default concurrency hundreds contend for one process until they exceed the renderer's 15-second budget: one build produced 335 `createImage timeout` lines and therefore 335 pages with no image — silently, because a missing OG image fails nothing. `prerender.concurrency: 8` brought that to 140, and `ogImage.security.renderTimeout` is raised to 60s as the second lever. **The combination has not yet been measured on a green build.** The deploy now counts them for you — the run summary carries the number and a non-zero one raises a warning annotation — but it should be zero, and caching the rendered images does not make it so: a cache hit skips a render, so a warm build times out less by rendering less, and the first cold build after any invalidation is exactly as exposed as before. **There is now an instrument rather than an argument.** `.github/workflows/prerender-bench.yml` builds the site six times at each concurrency — three cold, three warm — takes the prerender phase from Nitro's own `Prerendered N routes in X seconds` rather than from a stopwatch around the whole build, and applies the rule #44 settled: a candidate replaces 8 only on a warm median at least 10% lower with zero timeouts, complete OG output and no new prerender errors, and retaining 8 is a valid outcome named in advance. It is `workflow_dispatch` only, because eighteen full builds of this site is about an hour of runner time; `DUXT_PRERENDER_CONCURRENCY` is the lever it sets and the only thing that sets it. The rule lives in `scripts/prerender-bench.ts` and is pinned by `tests/prerender-bench.test.ts`, so the conclusion can be recomputed rather than remembered. **It has not been run yet, which is why the sentence above still stands** — the harness is the answer to "how would we know", not to "what is the number". It also **cannot** be run before the promotion PR merges: GitHub only makes a `workflow_dispatch` workflow triggerable once it exists on the **default** branch, so dispatching it from `dev` answers `HTTP 404: … not found on the default branch`. The `ref` it runs against stays free, so once it is on `main` it can still measure any branch. +- **`/mcp` needs the `agents` package.** `@nuxtjs/mcp-toolkit` picks a provider by preset, and its Cloudflare one imports `agents/mcp` — Cloudflare's MCP Handler API, a stateless handler, so no Durable Object and no binding. It is an optional peer dependency, so nothing installs it for you: without it the Nitro build dies with `Cannot resolve "agents/mcp" … and externals are not allowed`. It sits in `apps/www/`, not in the layer — a consumer deploying to Node must not carry it — and a consumer deploying duxt to Workers has to add it for the same reason. +- **The site's origin has to be stated.** `i18n.baseUrl` in `apps/www/nuxt.config.ts` is `https://duxt.app`, and the layer's module turns it into `site.url` — the sitemap, the canonicals, robots.txt and the absolute OG URLs all read it. It does not degrade when missing: the sitemap fails the prerender outright with "You must provide a site URL". +- **The OG renders time out under the crawl, and that is not fully solved.** Every page renders an OG image through satori while the crawler walks the site, and at Nitro's default concurrency hundreds contend for one process until they exceed the renderer's 15-second budget: one build produced 335 `createImage timeout` lines and therefore 335 pages with no image — silently, because a missing OG image fails nothing. `prerender.concurrency: 8` brought that to 140, and `ogImage.security.renderTimeout` is raised to 60s as the second lever. **The combination has not yet been measured on a green build.** The deploy now counts them for you — the run summary carries the number and a non-zero one raises a warning annotation — but it should be zero, and caching the rendered images does not make it so: a cache hit skips a render, so a warm build times out less by rendering less, and the first cold build after any invalidation is exactly as exposed as before. **There is now an instrument rather than an argument.** `.github/workflows/prerender-bench.yml` builds the site six times at each concurrency — three cold, three warm — takes the prerender phase from Nitro's own `Prerendered N routes in X seconds` rather than from a stopwatch around the whole build, and applies the rule #44 settled: a candidate replaces 8 only on a warm median at least 10% lower with zero timeouts, complete OG output and no new prerender errors, and retaining 8 is a valid outcome named in advance. It is `workflow_dispatch` only, because eighteen full builds of this site is about an hour of runner time; `DUXT_PRERENDER_CONCURRENCY` is the lever it sets and the only thing that sets it. The rule lives in `apps/www/scripts/prerender-bench.ts` and is pinned by `apps/www/tests/prerender-bench.test.ts`, so the conclusion can be recomputed rather than remembered. **It has not been run yet, which is why the sentence above still stands** — the harness is the answer to "how would we know", not to "what is the number". It also **cannot** be run before the promotion PR merges: GitHub only makes a `workflow_dispatch` workflow triggerable once it exists on the **default** branch, so dispatching it from `dev` answers `HTTP 404: … not found on the default branch`. The `ref` it runs against stays free, so once it is on `main` it can still measure any branch. - **The route rule alone prerenders nothing.** `routeRules` says a page *may* be prerendered; it seeds no crawl. Left at that, the build rendered the 17 Content SQL dumps and not one page — a build that looks fine and ships a fully dynamic site. `nitro.prerender.crawlLinks` with `routes: ['/']` is what actually walks the sidebar. **It walks pages and nothing else.** Nitro queues a discovered link only when its extension is `""` or `.json`, so the `.md` twin beside every page, the `llms.txt` every page's head link points at, and `rss.xml` are skipped however prominently they are linked — which is why all three are Worker routes above, and it is a property of Nitro rather than of this config. The one thing that does get past it is `prerenderRoutes` in `DuxtHeader`, and it is there because a version segment like `v0.1.0` reads to the crawler as a file with extension `.0`. - **`failOnError` is off, and the crawler is now this repo's link checker.** Nuxt exits the build on the first prerender error, and crawling every link finds every dead one: `/demo/openapi/shipments` and its two operations are linked by the versioned demo section and served by nothing, 42 times across the locales. Those pages fall through to the Worker, which answers them as it would anyway. **The links are a real defect and want fixing where they are generated** — the build prints each one, so the list stays visible rather than going quiet. - **The origin has to be pinned twice, and the second one is not a duplicate.** `@nuxtjs/i18n` copies its `baseUrl` into `runtimeConfig.public.i18n` with `defu`, and something in the SEO chain seeds that key first, so the module option never reaches the runtime. The runtime then holds an empty string, falls back to the request's own origin, and every page rendered at build time is rendered against `localhost:3000` — nuxt-site-config pushes that over `site.url`, and the prerendered HTML ships ``. `runtimeConfig.public.i18n.baseUrl` set explicitly is what fixes it. A served site never shows this, because the fallback resolves to the real host; it took the first prerendered build to surface. - **Half of every deploy's upload is the same files again, and that is measured rather than suspected.** wrangler uploads what changed; two unrelated pushes uploaded 2,829 of 5,709 assets and 2,876 of 5,774 — within 0.2 points of each other, which is the signature of a floor and not of a diff. Four clean local builds of one commit put a number on it: **808 of 1,414 assets get a new content hash with no source change at all** — every `index.html`, every `_payload.json`, the eight sitemaps and the two build-manifest files. Three values do it: `payload.prerenderedAt` (`Date.now()` per rendered route, written into the payload and into the page's inlined `__NUXT_DATA__`), Nuxt's `buildId` (`randomUUID()`, in three places in every page) and the `new Date()` inside `@nuxtjs/sitemap`'s credits comment. **Everything else is already deterministic** — the 281 OG images, the 37 Content dumps and 227 of 228 `_nuxt` chunks come out byte-identical, so satori renders reproducibly and the build id does not cascade into chunk hashes. Mask the three and two builds still disagree on 19 files, because `payload.data` keys and a sitemap's `hreflang` alternates are written in async-completion order. **Nothing is being changed about any of it, deliberately**: the upload step is 33–39 s of a six-to-seven-minute job, `sitemap.credits: false` is the only supported lever and buys 8 files, pinning `buildId` to the commit is wrong *here* because the release trigger rebuilds the same commit and that build may legitimately differ, and `prerenderedAt` is hard-coded in Nuxt's renderer — read only for truthiness, but overriding it means a layer server plugin rewriting a core payload field in every site that extends duxt. The cost that is worth watching is not the seconds: it is that no edge or browser cache entry for any page survives a deploy that did not touch that page. The full measurement is on #45. > [!IMPORTANT] -> **The hostname is not in `www/wrangler.jsonc`, deliberately.** DNS record and Workers route are owned end to end by the OpenTofu estate, not by wrangler: wrangler creates and updates routes but **deletes nothing** that disappears from the file, so a retired hostname keeps answering forever. `workers_dev = false` for the same reason a second front door is a bypass. A new hostname is an infrastructure change that has to be **applied**, not merely merged — and nothing here fails if it is missing: the deploy succeeds and the name stays dark. +> **The hostname is not in `apps/www/wrangler.jsonc`, deliberately.** DNS record and Workers route are owned end to end by the OpenTofu estate, not by wrangler: wrangler creates and updates routes but **deletes nothing** that disappears from the file, so a retired hostname keeps answering forever. `workers_dev = false` for the same reason a second front door is a bypass. A new hostname is an infrastructure change that has to be **applied**, not merely merged — and nothing here fails if it is missing: the deploy succeeds and the name stays dark. -**The D1 database itself belongs to tofu, not to wrangler** — the same seam the hostname runs along, and for the same reason: no wrangler config creates a D1 database, so one made with `wrangler d1 create` is a resource no state names and nothing ever deletes. `www/wrangler.jsonc` declares only the binding and repeats the id, which is an account-scoped identifier rather than a secret; wrangler refuses to deploy without it. The deploy token in Bitwarden needs `Workers Scripts: Edit` **and `D1: Edit`**. It needs no `Workers Routes: Edit`, because the route is tofu's. +**The D1 database itself belongs to tofu, not to wrangler** — the same seam the hostname runs along, and for the same reason: no wrangler config creates a D1 database, so one made with `wrangler d1 create` is a resource no state names and nothing ever deletes. `apps/www/wrangler.jsonc` declares only the binding and repeats the id, which is an account-scoped identifier rather than a secret; wrangler refuses to deploy without it. The deploy token in Bitwarden needs `Workers Scripts: Edit` **and `D1: Edit`**. It needs no `Workers Routes: Edit`, because the route is tofu's. -**Deploying on every push to `main`, plus every published release.** `www/` reads `docs/` off the checkout — `origin.ref` in its `app.config.ts` names the repository for the edit links and downloads nothing — so the site publishes the documentation of the commit it is built from, and a documentation fix should not wait for a release. A published release triggers a second, deliberately redundant build after its tag exists: `latest` is resolved from that tag rather than racing release-please. The consequence, stated rather than hidden: the version badge comes from `package.json`, which release-please bumps, so between a promotion and its release the site shows the last released number while documenting what is already on `main`. `main` only moves when the promotion PR merges, so the window is short — but it is real. +**Deploying on every push to `main`, plus every published release.** `apps/www/` reads `docs/` off the checkout — `origin.ref` in its `app.config.ts` names the repository for the edit links and downloads nothing — so the site publishes the documentation of the commit it is built from, and a documentation fix should not wait for a release. A published release triggers a second, deliberately redundant build after its tag exists: `latest` is resolved from that tag rather than racing release-please. The consequence, stated rather than hidden: the version badge comes from `package.json`, which release-please bumps, so between a promotion and its release the site shows the last released number while documenting what is already on `main`. `main` only moves when the promotion PR merges, so the window is short — but it is real. **A build is disposable, a publication is not — which is why the deploy is two jobs.** The workflow used to be one job under one `concurrency: deploy-www, cancel-in-progress: false` group, and that serialized whole *runs*: a build for the newest commit could not start while an older publication was still uploading, so a burst of pushes delivered the newest state one full build-and-upload late, every time. Cancelling the combined job instead is not the fix either — it cannot tell a throwaway build from an upload halfway into Cloudflare. So the two halves carry opposite policies: @@ -205,7 +209,7 @@ Three Workers facts follow, and all three are guarded by `const cloudflare = NIT **Both jobs build `main`, not the ref they were triggered from.** A release event points at the tag, and building that would roll the site back whenever `main` has moved past it — the release build exists to refresh `latest`, not to republish the tag. A `workflow_dispatch` points at whichever branch the dropdown was left on, and honouring it would make the Run button a two-click route from any branch into production. Only a `push` builds its own commit. -**Freshness is reported, not gated.** The deployment summary (`scripts/deploy-summary.ts`) carries a *Time to publish* row — from the committer date of the published commit to the moment the upload finished. There is no threshold and no non-zero exit: by the time it runs the Worker is live, and a slow queue is a fact to look at rather than an error to raise. The clock starts on the committer date rather than the push because only a `push` payload carries a push time, and a number meaning a different thing per trigger is worse than one that means the same thing every time. +**Freshness is reported, not gated.** The deployment summary (`apps/www/scripts/deploy-summary.ts`) carries a *Time to publish* row — from the committer date of the published commit to the moment the upload finished. There is no threshold and no non-zero exit: by the time it runs the Worker is live, and a slow queue is a fact to look at rather than an error to raise. The clock starts on the committer date rather than the push because only a `push` payload carries a push time, and a number meaning a different thing per trigger is worse than one that means the same thing every time. **The Cloudflare build is not part of `pnpm check`.** `check` builds the Node server, which is the SSR gate; a second full Nuxt build would roughly double CI for a target only `main` ever reaches. So a Workers-only breakage surfaces in the deploy rather than in the pull request. That is the accepted trade — `pnpm --filter www preview:cf` runs the built Worker on miniflare locally when a change looks like it might land on that side. @@ -234,7 +238,7 @@ It calls a central body that picks its own target: with a `stage` branch it prom ## When working here -- **A checkout under a `.git` directory cannot build this repo, and the failure names the wrong thing.** Two upstream tools treat `.git` as a place source cannot live, and both match on the ABSOLUTE path of every file: nitropack's imports resolver seeds `imports.exclude` with `/[/\\]\.git[/\\]/` whenever the option is still empty, so unimport injects no auto-import into any server file and the build dies in the prerender pass as `ERROR defineMcpTool is not defined` — a real symbol, in a real file, that nothing has broken; and Vite's `server.fs.deny` defaults to `['**/.git/**', …]`, so vitest cannot load `tests/shortcuts.test.ts` either (`Cannot find module`, 0 tests collected — it is the only jsdom suite, which is why it is the only one that fails). Traced on #87 by building the same commit from a worktree under `.git/` with nitro's exclusion suppressed: green. **Neither is duxt's to fix** — overriding either means shipping a weakened upstream default to every site that extends this layer — so `scripts/git-dir-guard.ts` refuses instead, from `www/nuxt.config.ts` and from `vitest.config.ts`, naming the reason. It matters because nobody picks this location by hand: agent worktrees land under `.git/tituskirch-skills/work/…`, so it is on the path every parallel implement or review run takes. **Create the worktree outside the repository** (`git worktree add ../duxt-`); `.claude/worktrees/…` is fine, `.git/…` is not. +- **A checkout under a `.git` directory cannot build this repo, and the failure names the wrong thing.** Two upstream tools treat `.git` as a place source cannot live, and both match on the ABSOLUTE path of every file: nitropack's imports resolver seeds `imports.exclude` with `/[/\\]\.git[/\\]/` whenever the option is still empty, so unimport injects no auto-import into any server file and the build dies in the prerender pass as `ERROR defineMcpTool is not defined` — a real symbol, in a real file, that nothing has broken; and Vite's `server.fs.deny` defaults to `['**/.git/**', …]`, so vitest cannot load `tests/shortcuts.test.ts` either (`Cannot find module`, 0 tests collected — it is the only jsdom suite, which is why it is the only one that fails). Traced on #87 by building the same commit from a worktree under `.git/` with nitro's exclusion suppressed: green. **Neither is duxt's to fix** — overriding either means shipping a weakened upstream default to every site that extends this layer — so `scripts/git-dir-guard.ts` refuses instead, from `apps/www/nuxt.config.ts` and from both packages' `vitest.config.ts`, naming the reason. It matters because nobody picks this location by hand: agent worktrees land under `.git/tituskirch-skills/work/…`, so it is on the path every parallel implement or review run takes. **Create the worktree outside the repository** (`git worktree add ../duxt-`); `.claude/worktrees/…` is fine, `.git/…` is not. - `forgemap` (sibling repo at `../forgemap`) is the de-facto reference implementation of the kirchDev meta conventions. When unsure about a config choice, check what forgemap does. -- The package is published as `@kirchdev/duxt` with `publishConfig.access: public`. It is **not** `"private": true` — do not add that back. +- The layer is published as `@kirchdev/duxt` with `publishConfig.access: public` from `packages/duxt/package.json`. That package is **not** `"private": true` — do not add that back. The workspace root's `package.json` **is** private, deliberately: it is not a package, and nothing publishes it. `prepack` copies the root `README.md` and `LICENSE` into the layer for the tarball and `postpack` removes them, so the published package still carries both. - Once the layer exists, its public surface is what a consumer can override: component, page and `app.config` names would need to stay stable, and a rename becomes a breaking change (`feat!:`). Until then there is no surface to protect. diff --git a/CLAUDE.md b/CLAUDE.md index 0603dadf..a24f3274 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,15 +47,17 @@ What that leaves as candidate value: the **ergonomics** (a compact `sources` lis - **The theme is shadcn-vue, wired through `shadcn-nuxt`** — a clean Tailwind 4 base with owned components in `app/components/ui/`, not Docus or Nuxt UI. Add one with `pnpm dlx shadcn-vue@latest add `; `components.json` already points the CLI at the layer's own alias. If the CLI refuses (it wants to install `reka-ui`/`@vueuse/core`/`@lucide/vue` into the workspace root and gives up), fetch the component from `https://www.shadcn-vue.com/r/styles/new-york/.json` and write its files by hand — then swap `@/…` for `@duxt/…` and every `lucide-vue-next` import for ``, which is what the components already here use. **They are prefixed `Ui`** (`shadcn.prefix` in `nuxt.config.ts`), so it is ``, ``, ``: without a prefix the layer auto-imports `Button`, `Input`, `Card` and a hundred other ordinary words into every site that extends it, and a collision with a consumer's own component resolves silently. The palette is the neutral shadcn set as CSS variables in `app/assets/css/duxt.css`, `dark` class toggled by `@nuxtjs/color-mode`; a consumer redefines a token in its own stylesheet rather than forking the file. - **Four icon sets, and the DOMAIN decides which.** The **stack** — a file, a fence's language, a tool you run — is `vscode-icons`, which carries the real colours in the file (`app/utils/file-icons.ts` and the package-manager tabs). A **third party as itself** — a platform, service or company the site links to or hands something to — is `simple-icons` (GitHub, Discord, Claude, OpenAI, Laravel). A locale's region is `flag`. Everything that is not a mark at all is `lucide`, which inherits `currentColor` and is the default. Where both collections carry a brand, the domain still decides: `vscode-icons:file-type-claude` is the icon for a Claude *file*, while the button that sends a page to the Claude *platform* is `simple-icons:claude`. Do not tint a monochrome mark by hand; if a coloured one belongs there, use it. The whole rule, the licences and why a licence is not a trademark: `docs/6.conventions/2.icons.md`. - **Markdown components are MDC, not MDX.** Content ships MDC, so `::callout{type="tip"}` works with no extra module. Components live in `app/components/content/`. -- **Numbered section prefixes are a non-issue.** Content strips them itself: `1.guides/` renders at `/guides`, `99.adr/` at `/adr`. Verified in `www/`. Reordering does not move a URL; only renaming the name part does. -- **No SQLite driver is installed.** Content's default is `better-sqlite3`, a native addon needing a node-gyp toolchain. `content.experimental.sqliteConnector: 'native'` uses Node 24's built-in `node:sqlite` instead, which needs no package at all — an ordinary `www/` build reads and renders through neither `better-sqlite3` nor `@libsql/client`. It is flagged experimental in Content; if that changes, `@libsql/client` is the prebuilt fallback, not `better-sqlite3`. The option was `experimental.nativeSqlite: true` until Content deprecated it in favour of the connector name — both still resolve to the same driver, but a **layer** is the worst place to carry a deprecation, because the consumer who sees the warning cannot see where it comes from or turn it off. `tests/sqlite-connector.test.ts` therefore reads the `@deprecated` tags out of the installed `@nuxt/content`'s own declarations and fails if anything the layer sets under `content.experimental` is one — so the next rename lands here rather than in someone else's build log. (`@libsql/client` and `pg` **are** now in `www/`'s devDependencies, and that is not a contradiction: they exist solely so the adapter matrix below can build this site against those two adapters. Nothing imports either, and a build with `DUXT_CONTENT_ADAPTER` unset loads neither.) -- **Which Content database adapters pass through, and how far that is actually known.** Content runs on five — `sqlite`, `d1`, `postgresql`, `libsql`, `pglite`. The layer is transparent to all of them because it reads the deployed database **only** through `queryCollection()`; `tests/content-database.test.ts` enforces exactly that, failing on any driver import under `server/` or `app/` and keeping the list of build-time files that may open a database closed. The fact that makes it work is that **Content keeps two databases**: `content._localDatabase` — the parse cache at `.data/content/contents.sqlite`, typed `sqlite | d1` and nothing else — and `content.database`, the deployed one. `content-cache.ts`, `modules/git-meta.ts` and `duxt report` all read the **first**, during a build, so no adapter choice moves them. `sqlite`, `d1`, `libsql` and `postgresql` are verified; **`pglite` is untested and is documented as untested** — do not upgrade that claim without a run behind it. `.github/workflows/adapters.yml` keeps `libsql` and `postgresql` honest by building `www/` against each (`DUXT_CONTENT_ADAPTER`, read in `www/nuxt.config.ts`) and then probing the endpoints that read the database at runtime with `pnpm check:adapters`. It is the second workflow here that is **not** a stub, and it is deliberately **not** in `pnpm check`: each leg is a second full Nuxt build, and the PostgreSQL one needs a server. Written up for consumers in `docs/2.concepts/11.databases.md`. -- **The extension surface is one build-time hook, and a duxt extension is a Nuxt layer.** `duxt:search:records` fires once per build — after Content has parsed every source, version, locale and generated section, and before Nitro prerenders or bundles — and hands a **frozen** `readonly DuxtSearchRecord[]`: `id` (a sha-256 prefix of `url`, because Meilisearch only accepts `A-Za-z0-9_-` as a document id), `url`, `title`, `content`, `source`, optional `version`, `locale`. No excerpt, no collection name, no display label: a snippet belongs to whoever draws the result list, a collection name is duxt's own bookkeeping, and a label is translated prose. The contract and the reasoning live in `search-records.ts`; `modules/search-records.ts` only decides when, and **skips building the payload entirely while nothing is listening** (it reads hookable's private `_hooks` and fails OPEN, because a hook that silently never fires is a bug with no symptom). There is **no `duxt.plugins` API and no generic hook registry**: `extends: ['@kirchdev/duxt', 'duxt-typesense']` already carries components, `app.config` defaults, collections, server routes, MCP tools and a module in one versioned package, so a plugin system would reimplement layer resolution inside a layer — and a registry a plugin writes into at import time hits the same two-loader wall that made `sectionTypes` a map rather than a registration call. `sectionTypes` stays the extension point for generated content types; UI changes stay slots and component shadowing. **A second hook needs a consumer that cannot be written without it** — a hook name is public surface, so renaming one is a breaking change and an unused one can never be removed. +- **Numbered section prefixes are a non-issue.** Content strips them itself: `1.guides/` renders at `/guides`, `99.adr/` at `/adr`. Verified in `apps/www/`. Reordering does not move a URL; only renaming the name part does. +- **No SQLite driver is installed.** Content's default is `better-sqlite3`, a native addon needing a node-gyp toolchain. `content.experimental.sqliteConnector: 'native'` uses Node 24's built-in `node:sqlite` instead, which needs no package at all — an ordinary `apps/www/` build reads and renders through neither `better-sqlite3` nor `@libsql/client`. It is flagged experimental in Content; if that changes, `@libsql/client` is the prebuilt fallback, not `better-sqlite3`. The option was `experimental.nativeSqlite: true` until Content deprecated it in favour of the connector name — both still resolve to the same driver, but a **layer** is the worst place to carry a deprecation, because the consumer who sees the warning cannot see where it comes from or turn it off. `tests/sqlite-connector.test.ts` therefore reads the `@deprecated` tags out of the installed `@nuxt/content`'s own declarations and fails if anything the layer sets under `content.experimental` is one — so the next rename lands here rather than in someone else's build log. (`@libsql/client` and `pg` **are** now in `apps/www/`'s devDependencies, and that is not a contradiction: they exist solely so the adapter matrix below can build this site against those two adapters. Nothing imports either, and a build with `DUXT_CONTENT_ADAPTER` unset loads neither.) +- **Which Content database adapters pass through, and how far that is actually known.** Content runs on five — `sqlite`, `d1`, `postgresql`, `libsql`, `pglite`. The layer is transparent to all of them because it reads the deployed database **only** through `queryCollection()`; `tests/content-database.test.ts` enforces exactly that, failing on any driver import under `server/` or `app/` and keeping the list of build-time files that may open a database closed. The fact that makes it work is that **Content keeps two databases**: `content._localDatabase` — the parse cache at `.data/content/contents.sqlite`, typed `sqlite | d1` and nothing else — and `content.database`, the deployed one. `build/content/content-cache.ts`, `modules/git-meta.ts` and `duxt report` all read the **first**, during a build, so no adapter choice moves them. `sqlite`, `d1`, `libsql` and `postgresql` are verified; **`pglite` is untested and is documented as untested** — do not upgrade that claim without a run behind it. `.github/workflows/adapters.yml` keeps `libsql` and `postgresql` honest by building `apps/www/` against each (`DUXT_CONTENT_ADAPTER`, read in `apps/www/nuxt.config.ts`) and then probing the endpoints that read the database at runtime with `pnpm check:adapters`. It is the second workflow here that is **not** a stub, and it is deliberately **not** in `pnpm check`: each leg is a second full Nuxt build, and the PostgreSQL one needs a server. Written up for consumers in `docs/2.concepts/11.databases.md`. +- **The extension surface is one build-time hook, and a duxt extension is a Nuxt layer.** `duxt:search:records` fires once per build — after Content has parsed every source, version, locale and generated section, and before Nitro prerenders or bundles — and hands a **frozen** `readonly DuxtSearchRecord[]`: `id` (a sha-256 prefix of `url`, because Meilisearch only accepts `A-Za-z0-9_-` as a document id), `url`, `title`, `content`, `source`, optional `version`, `locale`. No excerpt, no collection name, no display label: a snippet belongs to whoever draws the result list, a collection name is duxt's own bookkeeping, and a label is translated prose. The contract and the reasoning live in `build/search/search-records.ts`; `modules/search-records.ts` only decides when, and **skips building the payload entirely while nothing is listening** (it reads hookable's private `_hooks` and fails OPEN, because a hook that silently never fires is a bug with no symptom). There is **no `duxt.plugins` API and no generic hook registry**: `extends: ['@kirchdev/duxt', 'duxt-typesense']` already carries components, `app.config` defaults, collections, server routes, MCP tools and a module in one versioned package, so a plugin system would reimplement layer resolution inside a layer — and a registry a plugin writes into at import time hits the same two-loader wall that made `sectionTypes` a map rather than a registration call. `sectionTypes` stays the extension point for generated content types; UI changes stay slots and component shadowing. **A second hook needs a consumer that cannot be written without it** — a hook name is public surface, so renaming one is a breaking change and an unused one can never be removed. - **An MCP tool reaches the request through `useEvent()`, and the layer turns `nitro.experimental.asyncContext` on so it can.** `@nuxtjs/mcp-toolkit` registers a tool's handler with the MCP SDK unwrapped, so what the handler is handed is the SDK's own `RequestHandlerExtra` — `signal`, `requestId`, `sessionId`, `authInfo`, `requestInfo`, `sendNotification`, `sendRequest` — and **nothing of H3's**. All four tools read `extra.event` off it for their first nine days, which was always `undefined`; `queryCollection(undefined, …)` falls back to the global `$fetch` and `useRuntimeConfig(undefined)` to the global config, so it looked like it worked until `duxtLocaleSetup` dereferenced `event.context` for the i18n fallback and **every** tool call began answering `Cannot read properties of undefined (reading 'context')` (#90). Two things made that survivable for a day: the SDK turns a thrown handler into an `isError` **result**, not a JSON-RPC error, so a caller reading `payload.error` sees a successful call; and `tests/mcp-tools.test.ts` invented an `extra` with an `event` on it, a shape no transport produces. Both are closed — that `extra` is now a proxy that throws on any field the SDK does not define, and `check:adapters` reports an `isError` result as a failure with its text. **The flag is the price of having the real event**, and it is the one Nitro flag this layer imposes: the alternative is four tools answering from the global `$fetch` and the default locale, a second database-reading code path that exists only under `/mcp` and that only a Worker would ever have disagreed with. The toolkit's own `useMcpServer`, `useMcpSession` and `useMcpLogger` are built on the same call, so it is a prerequisite of mounting an MCP server rather than a preference. -- **A bounded listing is not a database assertion.** `check:adapters` asserted that `list_pages` returned `/getting-started`, but `list_pages` answers with the first `DUXT_PAGE_SIZE` paths in sort order — 25 of `www/`'s sort before the letter g, so the assertion was about page size and never about the database. It now asks the two questions separately: `list_pages` must come back with **some** rows, and `read_page` must come back with **that** page. Same rule as the OG-image and image-zoom fixtures: an assertion that cannot fail for the reason it names is not a check. -- **Content's per-collection search stays the default, and that was measured rather than assumed.** ADR-0010. Pagefind was evaluated as the first consumer of `duxt:search:records`, over `www/`'s real content — 33 collections, 2,918 pages, **11,937 records** in five languages and four versions — and `scripts/search-index-bench.ts` is what computed the numbers, with `tests/search-index-bench.test.ts` pinning the rule they were read by. It **wins on payload**: an English reader's first query costs 1,448 KB today (1,108 KB of WASM SQLite and its worker, 340 KB of dumps across the seven collections in scope) against **150 KB**, and 13 KB for every query after it. It **wins on ranking, per language**, and the filters fall straight out of the record contract (`source` → `/`, `/demo`, `/tf` …; `version` → `main`, `v0.1.0` …) with no adaptation at all. It **loses on two**: Pagefind has no fuzzy matching, and `verison` returned three unrelated pages rather than none — so Fuse has to stay *and* the `empty result → fall back` trigger stops firing; and it emits **one file per indexed section**, 12,066 of them against the 1,414 a Cloudflare build writes today, on a platform that caps a Workers version at 20,000 static files. One of three problems solved, one solved only within a language, one made worse, plus a file-count growth law of sections × sources × versions × locales — a different shape, not a better one. Pagefind stays a **layer** anyone can add (`extends: ['@kirchdev/duxt', 'duxt-pagefind']`), which is what the hook was opened for. `searchIndexVerdict` states the six conditions a replacement has to clear, so the next candidate is re-run rather than re-argued. +- **A bounded listing is not a database assertion.** `check:adapters` asserted that `list_pages` returned `/getting-started`, but `list_pages` answers with the first `DUXT_PAGE_SIZE` paths in sort order — 25 of `apps/www/`'s sort before the letter g, so the assertion was about page size and never about the database. It now asks the two questions separately: `list_pages` must come back with **some** rows, and `read_page` must come back with **that** page. Same rule as the OG-image and image-zoom fixtures: an assertion that cannot fail for the reason it names is not a check. +- **Content's per-collection search stays the default, and that was measured rather than assumed.** ADR-0010. Pagefind was evaluated as the first consumer of `duxt:search:records`, over `apps/www/`'s real content — 33 collections, 2,918 pages, **11,937 records** in five languages and four versions — and `apps/www/scripts/search-index-bench.ts` is what computed the numbers, with `apps/www/tests/search-index-bench.test.ts` pinning the rule they were read by. It **wins on payload**: an English reader's first query costs 1,448 KB today (1,108 KB of WASM SQLite and its worker, 340 KB of dumps across the seven collections in scope) against **150 KB**, and 13 KB for every query after it. It **wins on ranking, per language**, and the filters fall straight out of the record contract (`source` → `/`, `/demo`, `/tf` …; `version` → `main`, `v0.1.0` …) with no adaptation at all. It **loses on two**: Pagefind has no fuzzy matching, and `verison` returned three unrelated pages rather than none — so Fuse has to stay *and* the `empty result → fall back` trigger stops firing; and it emits **one file per indexed section**, 12,066 of them against the 1,414 a Cloudflare build writes today, on a platform that caps a Workers version at 20,000 static files. One of three problems solved, one solved only within a language, one made worse, plus a file-count growth law of sections × sources × versions × locales — a different shape, not a better one. Pagefind stays a **layer** anyone can add (`extends: ['@kirchdev/duxt', 'duxt-pagefind']`), which is what the hook was opened for. `searchIndexVerdict` states the six conditions a replacement has to clear, so the next candidate is re-run rather than re-argued. -**Repo shape — the root IS the layer.** `nuxt.config.ts`, `content.config.ts` and `app/` sit at the repo root, and `package.json` points at them with `main: "./nuxt.config.ts"` plus a `files` allowlist, so `extends: ['@kirchdev/duxt']` resolves. `www/` is the consuming site beside it — the only workspace package, and the development target, exactly as `www/` is in `ZTL-UwU/shadcn-docs-nuxt`. `nuxt` is a peerDependency of the layer and a real dependency only of `www/`. +**Repo shape — a Turborepo monorepo, and the root is not a package.** ADR-0011. The layer is `packages/duxt`: `nuxt.config.ts`, `content.config.ts`, `app/`, `modules/`, `server/`, `i18n/`, `public/`, `bin/`, and the build-time modules in topic folders under `build/` (`sources/`, `sections/`, `bruno/`, `openapi/`, `search/`, `content/`, `og-image/`, `git/`, `config/`, `cli/`). Its `package.json` points at them with `main: "./nuxt.config.ts"` plus a `files` allowlist of directories, so `extends: ['@kirchdev/duxt']` resolves; the `exports` map keeps every subpath name it had before the move. `apps/www` is the consuming site — the development target, exactly as `www/` is in `ZTL-UwU/shadcn-docs-nuxt` — and carries the scripts and tests that read its build. The root keeps only workspace and meta configuration (`turbo.json`, `pnpm-workspace.yaml`, lint/format, husky, `.github`, the agent files, `scripts/check-policy-parity.ts` and `scripts/git-dir-guard.ts`) plus `docs/`, which `apps/www` publishes, and the layer's `CHANGELOG.md` (see release-please below). `nuxt` is a peerDependency of the layer and a real dependency only of `apps/www`. The provider layers from #74 and #75 land as `packages/duxt-typesense` and `packages/duxt-meilisearch`, peer-depending on `@kirchdev/duxt` with a wide range (`>=0.4.0 <1`), never `^0.x`. + +**How to read a path in this file.** One that starts with `app/`, `build/`, `i18n/`, `modules/`, `server/` or `tests/`, and a bare `nuxt.config.ts` or `content.config.ts`, is the layer's — relative to `packages/duxt`. Every other path is relative to the repository root, `apps/www/…` included. > [!IMPORTANT] > **Nothing layer-relative resolves the way it reads.** Three places have hit this already, and a fourth will: the Content collection `cwd`, the `css` entry and `componentDir` in `nuxt.config.ts` (both go through the `layer()` helper resolving against `import.meta.url`), and the `@` alias — which belongs to whoever extends the layer, so the layer's own imports use `@duxt` instead. Assume any path written here is read from the consumer's directory until proven otherwise. @@ -63,7 +65,7 @@ What that leaves as candidate value: the **ergonomics** (a compact `sources` lis > [!IMPORTANT] > **A layer's collections resolve against the LAYER, not the consumer.** Content sets `collection.__rootDir = curr.cwd` per layer, so a relative `source.cwd` in this repo's `content.config.ts` points into this repo — never into the site that extends it. The layer therefore computes an absolute path at load time (`join(process.cwd(), 'docs')`), which works because c12 executes the config. This is the seam the whole `sources` shorthand sits on. -`www/` wants edge cases, ugly frontmatter, several sources and a tag to read from. [`kirchDev/duxt-starter`](https://github.com/kirchDev/duxt-starter) would be a **different artifact** — minimal and exemplary, what a stranger clones with `npx nuxi@latest init -t github:kirchDev/duxt-starter`. Do not conflate the two; a development site makes a bad starter. +`apps/www/` wants edge cases, ugly frontmatter, several sources and a tag to read from. [`kirchDev/duxt-starter`](https://github.com/kirchDev/duxt-starter) would be a **different artifact** — minimal and exemplary, what a stranger clones with `npx nuxi@latest init -t github:kirchDev/duxt-starter`. Do not conflate the two; a development site makes a bad starter. ## Commands @@ -72,13 +74,13 @@ What that leaves as candidate value: the **ergonomics** (a compact `sources` lis | `pnpm install` | Install deps and wire husky hooks via the `prepare` script | | `pnpm lint` | `oxlint . --deny-warnings` | | `pnpm format` | `oxfmt --check .` (note: `format` is the check, not fix) | -| `pnpm typecheck` | `tsc --noEmit` over the meta scripts | -| `pnpm typecheck:app`| `nuxt typecheck` over the layer, run through `www/` | -| `pnpm test` | `vitest run` over the layer's pure logic | -| `pnpm build:app` | `nuxt build` in `www/` — the gate's SSR check | -| `pnpm build:www` | `nuxt build` in `www/` with the Workers preset, then `check:routes` over what it wrote | -| `pnpm publish:www` | `wrangler deploy`s whatever `www/.output` already holds | -| `pnpm deploy:www` | Builds `www/` for Workers and `wrangler deploy`s it | +| `pnpm typecheck` | `tsc --noEmit` over the meta scripts and both packages' `scripts/` | +| `pnpm typecheck:app`| `turbo run typecheck --filter=www` — `nuxt typecheck` over the layer, run through `apps/www` | +| `pnpm test` | `turbo run test` — the layer's pure logic in `packages/duxt`, the site's checks and workflows in `apps/www` | +| `pnpm build:app` | `turbo run build --filter=www` — `nuxt build` in `apps/www`, the gate's SSR check | +| `pnpm build:www` | `nuxt build` in `apps/www` with the Workers preset, then `check:routes` over what it wrote | +| `pnpm publish:www` | `wrangler deploy`s whatever `apps/www/.output` already holds | +| `pnpm deploy:www` | Builds `apps/www` for Workers and `wrangler deploy`s it | | `pnpm og:cache` | Prints the rendered-OG-image cache key, or `--report`s what a build reused | | `pnpm check` | Runs `lint` + `format` + both typechecks + `test` + `check:policy` + `check:previews` + `build:app` + `check:a11y` + `check:seo` + `check:images` + `check:keyboard` + `check:overflow` — the CI gate | | `pnpm check:policy` | Proves the two agent policy files ban the same commands | @@ -95,17 +97,19 @@ What that leaves as candidate value: the **ergonomics** (a compact `sources` lis | `pnpm taze` | Interactive dependency upgrade check | | `pnpm taze:w` | Write upgrade results | -Tests cover the layer's pure logic — the source resolver, the config merge, the icon lookup, the build validator, the redirect map, the 404's nearest-page scoring — in `tests/`, run by vitest. `tests/contrast.test.ts` is the odd one out and deliberate: it parses the palette out of `duxt.css` and measures every foreground against the background it is paired with, because that is the one accessibility rule `check:a11y` cannot answer (jsdom has no computed colour) and the one this theme actually broke. Component rendering is not covered: it needs a Nuxt environment, and the failures this repo actually had were SSR failures, which is why `check` builds the site instead. `check:a11y` then runs axe-core over that build: jsdom has no layout, so `color-contrast` and `target-size` are reported as skipped rather than passed, and the structural rules — landmarks, names, heading order, ARIA — are what it enforces. `check:images` reads one more thing off the same build, and exists because of what that gap let through: `ProseImg`'s zoom had been dead since the day it was written — Vue casts an absent `boolean | string` prop to `false`, so no image was ever zoomable — and nobody could see it, because every `![…]` in the reference sits inside a fenced code block and the build therefore rendered no image at all. `www/demo/docs/3.images.md` is the fixture that renders one of each shape; the check reads its `srcset`, `sizes`, pass-through formats and zoom button back off the rendered page, then opens the viewer in Chromium to prove that it enlarges the image and keeps its close control off the pixels. **A component the build never renders is a component nothing checks** — that is the general rule, and the fixture is how this one pays it. `check:keyboard` uses that browser one step further on: #89 was a dialog that rendered perfectly and could not be used, because its input was a plain `` and reka's listbox therefore wired no arrow handling and no `aria-activedescendant` to it — results reachable by mouse alone, with every gate green. No parse can see that, because the defect is in what a KEY does; axe under jsdom has no focus model and the component does not mount outside a Nuxt environment. So `playwright-core` drives the built site — and **no browser is ever downloaded**: it runs whatever Chromium-family browser the machine already has (a runner image's Chrome, a contributor's own, a Playwright cache from any project), which is what makes it affordable in the gate and why `check:a11y` stays on jsdom instead of moving here. It walks the dialog's entry list, which every site draws with no query typed, and asserts the input keeps focus while the list takes the highlight, that arrowing only ever lands on a real `role="option"` and never on a heading or caption, and that Enter opens and Escape closes. A typed query is exercised on top and **reported rather than required**: search results are where the `aria-hidden` provenance captions are, but a site whose search returns nothing draws none — so that leg says out loud when it was given no rows rather than making this gate depend on another bug's fix. `check:overflow` is another browser check and the reason finding one lives in `scripts/browser.ts` rather than in any caller: #91 was `/getting-started` overflowing the document by **56px at 320px**, and `/adr` and `/demo` did the same — the title row was `nowrap`, `DuxtCopyPage` is `shrink-0` by choice, and an `

` keeps `min-width: auto`, so *Introduction* as one unbreakable word at `text-4xl` pinned the row at 199.2px of min-content and the documentation's own entry point scrolled sideways. **A width nothing measures is a width nothing protects**: axe under jsdom has no layout at all, the static half of `check:images` measures only declared image sizes, and `check:keyboard` looks at one desktop viewport. So six pages — one of each header and body kind, each carrying the reason it is on the list — are loaded at 320px and 412px and asserted to have `scrollWidth - clientWidth === 0`. It asserts a **second** thing deliberately, because the obvious cure is worse than the disease: the heading must not be clipped inside its own box and the copy control must lie fully within the viewport, so `truncate` on the title or `overflow-hidden` on the row cannot buy a green gate. Content that is legitimately wider than a phone needs no exemption — a code fence, a wide table and a diagram each sit in their own `overflow-x: auto` scroller and so never reach the document's scroll width, which is why `/adr` is one of the six rather than an exception to them. The fix itself was to let the row wrap below `sm` with `min-w-0` + `wrap-break-word` on the heading as the backstop; `sm:flex-nowrap` is not decoration, because `flex-wrap` breaks lines on an item's *max*-content and wrapping unconditionally would demote the control below any ordinary long title on a desktop. CI runs whatever `check` chains on PR; adding a check to the `check` script is enough, no workflow change needed. The one exception is `check:adapters`, which is driven by `.github/workflows/adapters.yml` instead — it needs a build per adapter and, for PostgreSQL, a server, so it parallelises as a matrix rather than lengthening the gate. +Tests cover the layer's pure logic — the source resolver, the config merge, the icon lookup, the build validator, the redirect map, the 404's nearest-page scoring — in `tests/`, run by vitest; the site's own checks, workflows, process guard and documentation tree are tested in `apps/www/tests`, and `pnpm test` runs both through `turbo run test`. `tests/contrast.test.ts` is the odd one out and deliberate: it parses the palette out of `duxt.css` and measures every foreground against the background it is paired with, because that is the one accessibility rule `check:a11y` cannot answer (jsdom has no computed colour) and the one this theme actually broke. Component rendering is not covered: it needs a Nuxt environment, and the failures this repo actually had were SSR failures, which is why `check` builds the site instead. `check:a11y` then runs axe-core over that build: jsdom has no layout, so `color-contrast` and `target-size` are reported as skipped rather than passed, and the structural rules — landmarks, names, heading order, ARIA — are what it enforces. `check:images` reads one more thing off the same build, and exists because of what that gap let through: `ProseImg`'s zoom had been dead since the day it was written — Vue casts an absent `boolean | string` prop to `false`, so no image was ever zoomable — and nobody could see it, because every `![…]` in the reference sits inside a fenced code block and the build therefore rendered no image at all. `apps/www/demo/docs/3.images.md` is the fixture that renders one of each shape; the check reads its `srcset`, `sizes`, pass-through formats and zoom button back off the rendered page, then opens the viewer in Chromium to prove that it enlarges the image and keeps its close control off the pixels. **A component the build never renders is a component nothing checks** — that is the general rule, and the fixture is how this one pays it. `check:keyboard` uses that browser one step further on: #89 was a dialog that rendered perfectly and could not be used, because its input was a plain `` and reka's listbox therefore wired no arrow handling and no `aria-activedescendant` to it — results reachable by mouse alone, with every gate green. No parse can see that, because the defect is in what a KEY does; axe under jsdom has no focus model and the component does not mount outside a Nuxt environment. So `playwright-core` drives the built site — and **no browser is ever downloaded**: it runs whatever Chromium-family browser the machine already has (a runner image's Chrome, a contributor's own, a Playwright cache from any project), which is what makes it affordable in the gate and why `check:a11y` stays on jsdom instead of moving here. It walks the dialog's entry list, which every site draws with no query typed, and asserts the input keeps focus while the list takes the highlight, that arrowing only ever lands on a real `role="option"` and never on a heading or caption, and that Enter opens and Escape closes. A typed query is exercised on top and **reported rather than required**: search results are where the `aria-hidden` provenance captions are, but a site whose search returns nothing draws none — so that leg says out loud when it was given no rows rather than making this gate depend on another bug's fix. `check:overflow` is another browser check and the reason finding one lives in `apps/www/scripts/browser.ts` rather than in any caller: #91 was `/getting-started` overflowing the document by **56px at 320px**, and `/adr` and `/demo` did the same — the title row was `nowrap`, `DuxtCopyPage` is `shrink-0` by choice, and an `

` keeps `min-width: auto`, so *Introduction* as one unbreakable word at `text-4xl` pinned the row at 199.2px of min-content and the documentation's own entry point scrolled sideways. **A width nothing measures is a width nothing protects**: axe under jsdom has no layout at all, the static half of `check:images` measures only declared image sizes, and `check:keyboard` looks at one desktop viewport. So six pages — one of each header and body kind, each carrying the reason it is on the list — are loaded at 320px and 412px and asserted to have `scrollWidth - clientWidth === 0`. It asserts a **second** thing deliberately, because the obvious cure is worse than the disease: the heading must not be clipped inside its own box and the copy control must lie fully within the viewport, so `truncate` on the title or `overflow-hidden` on the row cannot buy a green gate. Content that is legitimately wider than a phone needs no exemption — a code fence, a wide table and a diagram each sit in their own `overflow-x: auto` scroller and so never reach the document's scroll width, which is why `/adr` is one of the six rather than an exception to them. The fix itself was to let the row wrap below `sm` with `min-w-0` + `wrap-break-word` on the heading as the backstop; `sm:flex-nowrap` is not decoration, because `flex-wrap` breaks lines on an item's *max*-content and wrapping unconditionally would demote the control below any ordinary long title on a desktop. CI runs whatever `check` chains on PR; adding a check to the `check` script is enough, no workflow change needed. The one exception is `check:adapters`, which is driven by `.github/workflows/adapters.yml` instead — it needs a build per adapter and, for PostgreSQL, a server, so it parallelises as a matrix rather than lengthening the gate. ## Architecture / conventions - **Node 24, pnpm 12, TypeScript 6.** Pinned via `.nvmrc`, `engines`, and `packageManager`. `pnpm-workspace.yaml` enforces `minimumReleaseAge=4320` (3-day cooldown), isolated node-linker. Don't loosen these without reason. **TypeScript stays on 6 deliberately**: 7 is the native port, whose `exports` map no longer exposes the compiler internals Volar builds on, so `vue-tsc` cannot run on it — and without `vue-tsc` the layer has no typecheck at all. Move to 7 when Volar does, not before. Package-manager enforcement carries no key on purpose: pnpm 11 replaced `packageManagerStrict`/`packageManagerStrictVersion` with `pmOnFail`, whose default `download` already errors on a foreign package manager and fetches the pinned pnpm version — every other value only weakens it, so leave it unset (the rationale sits as a comment in the file). - **A heavy browser dependency is loaded on demand, never bundled.** `mermaid` (~0.5 MB) and CodeMirror 6 (the request-body editor in the API reference) are both `import()`ed inside the component that draws them, so a page without a diagram or an endpoint downloads neither. That is the bar a new one has to clear — which is also why the body editor is not Monaco: an editor whose workers and megabytes every consumer site carries, for a box that holds ten lines of JSON, is the wrong trade for a package. - **oxc, not eslint/prettier.** Linting via `oxlint`, formatting via `oxfmt`. Configs live in `.oxlintrc.json` / `.oxfmtrc.json`. `oxlint` uses `unicorn` + `oxc` plugins; rules deliberately minimal. -- **TypeScript, no build step.** The meta scripts and the three tool configs are `.ts` — Node 24 strips types natively, so `scripts/check-policy-parity.ts`, `commitlint.config.ts`, `lint-staged.config.ts` and `taze.config.ts` stay directly executable and each tool loads its own `.ts` config unaided. `tsconfig.json` is `noEmit` + `strict` + `erasableSyntaxOnly`, so only strippable syntax (no enums, no parameter properties) can be written; `pnpm typecheck` is the gate for those. The layer itself is checked separately by `pnpm typecheck:app` (`nuxt typecheck` in `www/`), because `tsc` cannot see a Nuxt config's module options — those exist only in generated types. `oxlint` + `oxfmt` cover `.vue`, so no ESLint is coming. +- **Turborepo runs the package tasks, and its cache is local only.** ADR-0011. The root scripts stay a `&&` chain of `pnpm ` calls — the central CI body splits `check` on exactly that shape — and each package task delegates to `turbo run --filter=…`. No remote cache, no account, no token. A task is cached only where its declared inputs determine its result: `test` is, with `docs/`, the site's `app.config.ts`, the root `scripts/` and the workflows named as inputs where a suite reads them; `typecheck`, `build` and every check over a build resolve `latest` against a remote and are `cache: false`. `envMode` is `loose`, because the checks read `DUXT_*`, `NITRO_PRESET` and the runner's own variables. `turbo` stays out of the permission allow list, as the policy below requires of anything that runs arbitrary code. +- **The layer's dependencies are hoisted to the workspace root.** `publicHoistPattern` in `pnpm-workspace.yaml` lists every direct dependency of `packages/duxt` that the site resolved while the root was the layer — 35 of the 41. `apps/www` builds the layer's code from its own directory and resolves bare imports upwards, and those packages used to sit in the root `node_modules` by accident of the old layout. Without the hoist, two things broke that the move never meant to change: `nuxt typecheck` lost the packages its generated declarations import by name, so every Content query typed as `never`; and the Workers build, which bundles dependencies rather than externalising them, could no longer resolve `shiki` and died on its `onig.wasm`. The six that did not resolve before stay unhoisted. **A dependency added to the layer that the site has to reach belongs in that list too.** +- **TypeScript, no build step.** The meta scripts and the three tool configs are `.ts` — Node 24 strips types natively, so `scripts/check-policy-parity.ts`, `commitlint.config.ts`, `lint-staged.config.ts` and `taze.config.ts` stay directly executable and each tool loads its own `.ts` config unaided. The root `tsconfig.json` is `noEmit` + `strict` + `erasableSyntaxOnly` over those and over both packages' `scripts/`, so only strippable syntax (no enums, no parameter properties) can be written; `pnpm typecheck` is the gate for those. The layer itself is checked separately by `pnpm typecheck:app` (`nuxt typecheck` in `apps/www/`), because `tsc` cannot see a Nuxt config's module options — those exist only in generated types. `oxlint` + `oxfmt` cover `.vue`, so no ESLint is coming. - **Husky hooks** (`.husky/pre-commit`, `.husky/commit-msg`) run `lint-staged` and `commitlint`. `lint-staged.config.ts` excludes `README.md`, `CLAUDE.md`, and `AGENTS.md` (free-form prose) and `pnpm-lock.yaml`. `oxlint --fix --deny-warnings` then `oxfmt` on JS/TS; `oxfmt` only on JSON/YAML/MD. - **Conventional Commits enforced** via `@commitlint/config-conventional`. Don't `--no-verify` unless explicitly asked. -- **release-please** drives the versioning. Files: `release-please-config.json`, `.release-please-manifest.json`, `.github/workflows/release-please.yml`. `release-type: node` (this is a published package, so `package.json` gets bumped too), `include-v-in-tag: true`, starting from `0.0.0`. Publishing to npm is a job added to `release-please.yml`, gated on `needs.release-please.outputs.release-created`. +- **release-please** drives the versioning, in manifest mode with **one entry per published package**. Files: `release-please-config.json`, `.release-please-manifest.json`, `.github/workflows/release-please.yml`. `release-type: node`, and every package tags `@vX.Y.Z` — `include-component-in-tag: true` with `tag-separator: "@"`, the component being the package name without its scope — so `duxt@v0.5.0` stands beside `duxt-typesense@v0.1.0`, while `package.json` and npm keep the bare number. A release publishes only the packages it bumped; **no package is ever released without changes**, which is why there is no shared version. The plain `v0.1.0`…`v0.4.0` tags stay, and the top-level `last-release-sha` bridges from `v0.4.0` to the new tag pattern once. **The layer's changelog stays at the repository root** (`changelog-path: "/CHANGELOG.md"`, which release-please reads as root-relative): `apps/www` publishes the changelog of every version it serves, read at that version's own checkout, and every tag cut before the move has the file there. A provider package keeps its changelog in its own directory. **Tag-based versioning in the layer reads component tags** — see `tagComponent` in `build/sources/sources-resolve.ts` — and `apps/www`'s own source names `tagComponent: 'duxt'`, so `latest` follows the layer's releases rather than a provider's. - **Workflows** use `actions/checkout@v6`, `actions/setup-node@v6`, `pnpm/action-setup@v6`, `github/codeql-action/{init,analyze}@v4`. Keep these pinned to major versions; Dependabot bumps them monthly. - **CodeQL** scans `actions` + `javascript-typescript` with `security-extended,security-and-quality` queries, gated by path filters so non-code changes don't trigger it. - **Dependabot** groups all minor/patch updates per ecosystem into a single PR (`npm-minor-patch`, `actions-minor-patch`). Majors come as separate PRs. @@ -148,18 +152,18 @@ codex execpolicy check --pretty --rules .codex/rules/default.rules -- git push - ## Workflows are calls, not copies -Every file in `.github/workflows/` is a **stub**: a trigger and a `uses:` pointing at a body in [`kirchDev/workflows`](https://github.com/kirchDev/workflows). This repo carries the calls, not 727 lines of workflow — and a fix made centrally reaches it on its next Dependabot bump instead of never. **Two carry their own bodies, and both say why in their headers**: `deploy.yml`, because there is no central body for a Cloudflare deploy, and `prerender-bench.yml`, because "build one repository's site eighteen times and compare the prerender phase" is unlikely ever to have a second caller. A third needs the same argument made in writing before it is written. +Every file in `.github/workflows/` is a **stub**: a trigger and a `uses:` pointing at a body in [`kirchDev/workflows`](https://github.com/kirchDev/workflows). This repo carries the calls, not 727 lines of workflow — and a fix made centrally reaches it on its next Dependabot bump instead of never. **Three carry their own jobs, and each says why in its header**: `deploy.yml`, because there is no central body for a Cloudflare deploy; `prerender-bench.yml`, because "build one repository's site eighteen times and compare the prerender phase" is unlikely ever to have a second caller; and the publish jobs in `release-please.yml`, because the central `_publish-npm.yml` publishes the repository root and `_release-please.yml` forwards only the root package's outputs — neither fits a workspace whose packages live under `packages/`. That third one is a gap in `kirchDev/workflows` rather than a permanent exception: a working-directory input and the per-path outputs there would let it become a call again. A fourth needs the same argument made in writing before it is written. What follows: - **Do not paste a workflow body back in.** If a stub almost fits, the answer is an input on the body or an own job beside the call — see that repository's `docs/1.guides/2.add-a-body.md`. - **The pins are commit SHAs with the version as a trailing comment.** Dependabot raises the bumps; the `github-actions` ecosystem is already configured in `.github/dependabot.yml`. -- **Publishing** is an own job in `release-please.yml`, gated on `needs.release-please.outputs.release-created` — not a forked workflow. +- **Publishing** is own jobs in `release-please.yml` — not a forked workflow — and **the registry decides, not an output**: a workspace package under `packages/` is published when its version has a `@v` tag and npm does not have that version yet, and a `dev-main` prerelease goes out only for the packages the push to `main` changed. A package release-please did not bump therefore never publishes again, and a publish that failed halfway is picked up by the next push. - **Checks come from `package.json`.** `ci.yml` runs whatever the `check` script chains, so adding a check needs no workflow change at all. -## Deployment — `www/` on Cloudflare Workers +## Deployment — `apps/www/` on Cloudflare Workers -`www/` is deployed as **one Worker with static assets**, built in GitHub Actions and uploaded with wrangler. There is no Pages git integration and no build on Cloudflare's side: `.github/workflows/deploy.yml` deploys on every push to `main` and again when a release is published, building with `NITRO_PRESET=cloudflare-module` and then `wrangler deploy`ing the result. One place builds, and it is the one whose logs we keep. **It is two jobs, not one** — `build` and `publish`, for the reason below. +`apps/www/` is deployed as **one Worker with static assets**, built in GitHub Actions and uploaded with wrangler. There is no Pages git integration and no build on Cloudflare's side: `.github/workflows/deploy.yml` deploys on every push to `main` and again when a release is published, building with `NITRO_PRESET=cloudflare-module` and then `wrangler deploy`ing the result. One place builds, and it is the one whose logs we keep. **It is two jobs, not one** — `build` and `publish`, for the reason below. **SSR on the edge, with everything prerendered that can be.** Not a choice between static and SSR: `routeRules: { '/**': { prerender: true } }` writes every doc page and every OG image into `.output/public`, which the assets binding serves without ever invoking the Worker. **Six of the routes still running** are the reason the site is not simply `nuxt generate`d: @@ -173,28 +177,28 @@ What follows: A static build kills all six, which are the layer's whole pitch. That is the trade, and it was taken deliberately. **Those six are not everything the Worker answers**, and reading the list as exhaustive is how this drifted the first time: `robots.txt`, the two `/mcp` helpers beside the server itself (`/mcp/deeplink`, `/mcp/badge.svg`) and the sitemap's `style.xsl` and `nuxt-content-urls.json` run too. The table below classifies all of them, and everything else the build registers is named there as well. > [!IMPORTANT] -> **That list is a summary of `scripts/check-routes.ts`, which is the authoritative one.** It used to be written out three times — here, in `www/nuxt.config.ts` and in `www/wrangler.jsonc` — the three named different sets, and `llms-full.txt` and `rss.xml` fell through every gap between them. `DEPLOYMENT_ROUTES` there answers asset-or-Worker, D1-or-not, the fallback and the reason per route, and `pnpm check:routes` walks a Cloudflare build's `.output/public` and fails if the artifact disagrees. **It also reads Nitro's handler manifest out of the built bundle and requires every registered route to be either a row in that table or a named exemption with a reason** — the file comparison alone can only check the routes that already have a row, which is exactly the blindness that let `llms-full.txt` and `rss.xml` go unaccounted for. Reading the manifest fails open: a bundle it cannot find one in is reported, never passed. It is the last step of `build:cf`, so every Workers build runs it — CI's build job included, which means a build whose artifact disagrees never reaches the publish job. It is **not** in `pnpm check`, which builds the Node server. Change the classification there first; a route that moves side is a change to that table, not to prose. +> **That list is a summary of `apps/www/scripts/check-routes.ts`, which is the authoritative one.** It used to be written out three times — here, in `apps/www/nuxt.config.ts` and in `apps/www/wrangler.jsonc` — the three named different sets, and `llms-full.txt` and `rss.xml` fell through every gap between them. `DEPLOYMENT_ROUTES` there answers asset-or-Worker, D1-or-not, the fallback and the reason per route, and `pnpm check:routes` walks a Cloudflare build's `.output/public` and fails if the artifact disagrees. **It also reads Nitro's handler manifest out of the built bundle and requires every registered route to be either a row in that table or a named exemption with a reason** — the file comparison alone can only check the routes that already have a row, which is exactly the blindness that let `llms-full.txt` and `rss.xml` go unaccounted for. Reading the manifest fails open: a bundle it cannot find one in is reported, never passed. It is the last step of `build:cf`, so every Workers build runs it — CI's build job included, which means a build whose artifact disagrees never reaches the publish job. It is **not** in `pnpm check`, which builds the Node server. Change the classification there first; a route that moves side is a change to that table, not to prose. -Three Workers facts follow, and all three are guarded by `const cloudflare = NITRO_PRESET.startsWith('cloudflare')` in `www/nuxt.config.ts` — `pnpm build:app`, the gate's SSR check, still builds an ordinary Node server, because every one of them would be wrong locally: +Three Workers facts follow, and all three are guarded by `const cloudflare = NITRO_PRESET.startsWith('cloudflare')` in `apps/www/nuxt.config.ts` — `pnpm build:app`, the gate's SSR check, still builds an ordinary Node server, because every one of them would be wrong locally: - **Content needs D1.** A Worker has no filesystem and no `node:sqlite`, so `content.database = { type: 'd1', bindingName: 'DB' }` and Content restores its dump into D1 on the first request after a deploy. Five of the six above read it — every one but `/demo/echo` calls `queryCollection()` at request time — and the table names one more D1 reader that list does not: `/__sitemap__/nuxt-content-urls.json`, which queries every page collection and which nothing on a deployed site asks for, because the sitemaps themselves are prerendered. The prerendered pages never touch the binding. `experimental.sqliteConnector` in the layer covers the local case and means nothing here. - **OG images are build-time only.** `@resvg/resvg-js` is a native Node binding and cannot run on workerd at all, so `ogImage.zeroRuntime` strips the renderer out of the bundle and leaves the images the prerender pass wrote. Every OG image here is a function of a page, and every page is prerendered, so nothing is lost. If a dynamically rendered image is ever needed, the answer is the Takumi/WASM renderer, not the native one. -- **And they are kept between builds.** Deploy #4 rendered 1,053 of them in a 158-second prerender pass, almost all redrawing images no commit had touched. nuxt-og-image can keep them — `ogImage.buildCache`, keyed per image by the page's own options, the template's source and the module's version — and does nothing with that until the directory outlives the runner, which is the cache step in `deploy.yml`. `og-image-cache.ts` in the layer supplies both halves: the **directory**, stamped with a digest of every rendering input that key cannot see (the fonts, the renderer options, the renderer packages) and emptied when one moves, so a stale image is never served; and the **CI key**, namespaced by the renderer versions alone so a `satori` bump misses the restore rather than downloading images it is about to discard. The command cannot compute the fuller digest, because a site's renderer options live in a `nuxt.config.ts` that would claim a Nuxt process if a command loaded it — so the level that decides whether an image may be *served* is the build's, and the key only decides whether a tarball is worth *downloading*. `pnpm og:cache --report` prints what a build reused, rendered and timed out on, and the deploy writes it into the run summary. **Measured on three local Workers builds of one commit**: cold, 1,132 routes prerendered in 190s with 237 images rendered; warm, the same 1,132 routes in **117s with nothing rendered at all** — a 38% cut to the prerender phase, 281 images in the output both times and zero timeouts in either. A third build with one colour changed in the template re-rendered all 237, which is the invalidation that matters most and the one nothing else would have caught. It rests on a property #45 measured independently from the other side: the 281 OG images come out **byte-identical** between builds, so satori renders reproducibly and a cached image is the same image. +- **And they are kept between builds.** Deploy #4 rendered 1,053 of them in a 158-second prerender pass, almost all redrawing images no commit had touched. nuxt-og-image can keep them — `ogImage.buildCache`, keyed per image by the page's own options, the template's source and the module's version — and does nothing with that until the directory outlives the runner, which is the cache step in `deploy.yml`. `build/og-image/og-image-cache.ts` in the layer supplies both halves: the **directory**, stamped with a digest of every rendering input that key cannot see (the fonts, the renderer options, the renderer packages) and emptied when one moves, so a stale image is never served; and the **CI key**, namespaced by the renderer versions alone so a `satori` bump misses the restore rather than downloading images it is about to discard. The command cannot compute the fuller digest, because a site's renderer options live in a `nuxt.config.ts` that would claim a Nuxt process if a command loaded it — so the level that decides whether an image may be *served* is the build's, and the key only decides whether a tarball is worth *downloading*. `pnpm og:cache --report` prints what a build reused, rendered and timed out on, and the deploy writes it into the run summary. **Measured on three local Workers builds of one commit**: cold, 1,132 routes prerendered in 190s with 237 images rendered; warm, the same 1,132 routes in **117s with nothing rendered at all** — a 38% cut to the prerender phase, 281 images in the output both times and zero timeouts in either. A third build with one colour changed in the template re-rendered all 237, which is the invalidation that matters most and the one nothing else would have caught. It rests on a property #45 measured independently from the other side: the 281 OG images come out **byte-identical** between builds, so satori renders reproducibly and a cached image is the same image. - **`nodejs_compat` is not optional.** Content's Nitro half, the MCP SDK and Nitro's own runtime all reach for node builtins; without the flag the Worker fails at the first import. -- **`/mcp` needs the `agents` package.** `@nuxtjs/mcp-toolkit` picks a provider by preset, and its Cloudflare one imports `agents/mcp` — Cloudflare's MCP Handler API, a stateless handler, so no Durable Object and no binding. It is an optional peer dependency, so nothing installs it for you: without it the Nitro build dies with `Cannot resolve "agents/mcp" … and externals are not allowed`. It sits in `www/`, not in the layer — a consumer deploying to Node must not carry it — and a consumer deploying duxt to Workers has to add it for the same reason. -- **The site's origin has to be stated.** `i18n.baseUrl` in `www/nuxt.config.ts` is `https://duxt.app`, and the layer's module turns it into `site.url` — the sitemap, the canonicals, robots.txt and the absolute OG URLs all read it. It does not degrade when missing: the sitemap fails the prerender outright with "You must provide a site URL". -- **The OG renders time out under the crawl, and that is not fully solved.** Every page renders an OG image through satori while the crawler walks the site, and at Nitro's default concurrency hundreds contend for one process until they exceed the renderer's 15-second budget: one build produced 335 `createImage timeout` lines and therefore 335 pages with no image — silently, because a missing OG image fails nothing. `prerender.concurrency: 8` brought that to 140, and `ogImage.security.renderTimeout` is raised to 60s as the second lever. **The combination has not yet been measured on a green build.** The deploy now counts them for you — the run summary carries the number and a non-zero one raises a warning annotation — but it should be zero, and caching the rendered images does not make it so: a cache hit skips a render, so a warm build times out less by rendering less, and the first cold build after any invalidation is exactly as exposed as before. **There is now an instrument rather than an argument.** `.github/workflows/prerender-bench.yml` builds the site six times at each concurrency — three cold, three warm — takes the prerender phase from Nitro's own `Prerendered N routes in X seconds` rather than from a stopwatch around the whole build, and applies the rule #44 settled: a candidate replaces 8 only on a warm median at least 10% lower with zero timeouts, complete OG output and no new prerender errors, and retaining 8 is a valid outcome named in advance. It is `workflow_dispatch` only, because eighteen full builds of this site is about an hour of runner time; `DUXT_PRERENDER_CONCURRENCY` is the lever it sets and the only thing that sets it. The rule lives in `scripts/prerender-bench.ts` and is pinned by `tests/prerender-bench.test.ts`, so the conclusion can be recomputed rather than remembered. **It has not been run yet, which is why the sentence above still stands** — the harness is the answer to "how would we know", not to "what is the number". It also **cannot** be run before the promotion PR merges: GitHub only makes a `workflow_dispatch` workflow triggerable once it exists on the **default** branch, so dispatching it from `dev` answers `HTTP 404: … not found on the default branch`. The `ref` it runs against stays free, so once it is on `main` it can still measure any branch. +- **`/mcp` needs the `agents` package.** `@nuxtjs/mcp-toolkit` picks a provider by preset, and its Cloudflare one imports `agents/mcp` — Cloudflare's MCP Handler API, a stateless handler, so no Durable Object and no binding. It is an optional peer dependency, so nothing installs it for you: without it the Nitro build dies with `Cannot resolve "agents/mcp" … and externals are not allowed`. It sits in `apps/www/`, not in the layer — a consumer deploying to Node must not carry it — and a consumer deploying duxt to Workers has to add it for the same reason. +- **The site's origin has to be stated.** `i18n.baseUrl` in `apps/www/nuxt.config.ts` is `https://duxt.app`, and the layer's module turns it into `site.url` — the sitemap, the canonicals, robots.txt and the absolute OG URLs all read it. It does not degrade when missing: the sitemap fails the prerender outright with "You must provide a site URL". +- **The OG renders time out under the crawl, and that is not fully solved.** Every page renders an OG image through satori while the crawler walks the site, and at Nitro's default concurrency hundreds contend for one process until they exceed the renderer's 15-second budget: one build produced 335 `createImage timeout` lines and therefore 335 pages with no image — silently, because a missing OG image fails nothing. `prerender.concurrency: 8` brought that to 140, and `ogImage.security.renderTimeout` is raised to 60s as the second lever. **The combination has not yet been measured on a green build.** The deploy now counts them for you — the run summary carries the number and a non-zero one raises a warning annotation — but it should be zero, and caching the rendered images does not make it so: a cache hit skips a render, so a warm build times out less by rendering less, and the first cold build after any invalidation is exactly as exposed as before. **There is now an instrument rather than an argument.** `.github/workflows/prerender-bench.yml` builds the site six times at each concurrency — three cold, three warm — takes the prerender phase from Nitro's own `Prerendered N routes in X seconds` rather than from a stopwatch around the whole build, and applies the rule #44 settled: a candidate replaces 8 only on a warm median at least 10% lower with zero timeouts, complete OG output and no new prerender errors, and retaining 8 is a valid outcome named in advance. It is `workflow_dispatch` only, because eighteen full builds of this site is about an hour of runner time; `DUXT_PRERENDER_CONCURRENCY` is the lever it sets and the only thing that sets it. The rule lives in `apps/www/scripts/prerender-bench.ts` and is pinned by `apps/www/tests/prerender-bench.test.ts`, so the conclusion can be recomputed rather than remembered. **It has not been run yet, which is why the sentence above still stands** — the harness is the answer to "how would we know", not to "what is the number". It also **cannot** be run before the promotion PR merges: GitHub only makes a `workflow_dispatch` workflow triggerable once it exists on the **default** branch, so dispatching it from `dev` answers `HTTP 404: … not found on the default branch`. The `ref` it runs against stays free, so once it is on `main` it can still measure any branch. - **The route rule alone prerenders nothing.** `routeRules` says a page *may* be prerendered; it seeds no crawl. Left at that, the build rendered the 17 Content SQL dumps and not one page — a build that looks fine and ships a fully dynamic site. `nitro.prerender.crawlLinks` with `routes: ['/']` is what actually walks the sidebar. **It walks pages and nothing else.** Nitro queues a discovered link only when its extension is `""` or `.json`, so the `.md` twin beside every page, the `llms.txt` every page's head link points at, and `rss.xml` are skipped however prominently they are linked — which is why all three are Worker routes above, and it is a property of Nitro rather than of this config. The one thing that does get past it is `prerenderRoutes` in `DuxtHeader`, and it is there because a version segment like `v0.1.0` reads to the crawler as a file with extension `.0`. - **`failOnError` is off, and the crawler is now this repo's link checker.** Nuxt exits the build on the first prerender error, and crawling every link finds every dead one: `/demo/openapi/shipments` and its two operations are linked by the versioned demo section and served by nothing, 42 times across the locales. Those pages fall through to the Worker, which answers them as it would anyway. **The links are a real defect and want fixing where they are generated** — the build prints each one, so the list stays visible rather than going quiet. - **The origin has to be pinned twice, and the second one is not a duplicate.** `@nuxtjs/i18n` copies its `baseUrl` into `runtimeConfig.public.i18n` with `defu`, and something in the SEO chain seeds that key first, so the module option never reaches the runtime. The runtime then holds an empty string, falls back to the request's own origin, and every page rendered at build time is rendered against `localhost:3000` — nuxt-site-config pushes that over `site.url`, and the prerendered HTML ships ``. `runtimeConfig.public.i18n.baseUrl` set explicitly is what fixes it. A served site never shows this, because the fallback resolves to the real host; it took the first prerendered build to surface. - **Half of every deploy's upload is the same files again, and that is measured rather than suspected.** wrangler uploads what changed; two unrelated pushes uploaded 2,829 of 5,709 assets and 2,876 of 5,774 — within 0.2 points of each other, which is the signature of a floor and not of a diff. Four clean local builds of one commit put a number on it: **808 of 1,414 assets get a new content hash with no source change at all** — every `index.html`, every `_payload.json`, the eight sitemaps and the two build-manifest files. Three values do it: `payload.prerenderedAt` (`Date.now()` per rendered route, written into the payload and into the page's inlined `__NUXT_DATA__`), Nuxt's `buildId` (`randomUUID()`, in three places in every page) and the `new Date()` inside `@nuxtjs/sitemap`'s credits comment. **Everything else is already deterministic** — the 281 OG images, the 37 Content dumps and 227 of 228 `_nuxt` chunks come out byte-identical, so satori renders reproducibly and the build id does not cascade into chunk hashes. Mask the three and two builds still disagree on 19 files, because `payload.data` keys and a sitemap's `hreflang` alternates are written in async-completion order. **Nothing is being changed about any of it, deliberately**: the upload step is 33–39 s of a six-to-seven-minute job, `sitemap.credits: false` is the only supported lever and buys 8 files, pinning `buildId` to the commit is wrong *here* because the release trigger rebuilds the same commit and that build may legitimately differ, and `prerenderedAt` is hard-coded in Nuxt's renderer — read only for truthiness, but overriding it means a layer server plugin rewriting a core payload field in every site that extends duxt. The cost that is worth watching is not the seconds: it is that no edge or browser cache entry for any page survives a deploy that did not touch that page. The full measurement is on #45. > [!IMPORTANT] -> **The hostname is not in `www/wrangler.jsonc`, deliberately.** DNS record and Workers route are owned end to end by the OpenTofu estate, not by wrangler: wrangler creates and updates routes but **deletes nothing** that disappears from the file, so a retired hostname keeps answering forever. `workers_dev = false` for the same reason a second front door is a bypass. A new hostname is an infrastructure change that has to be **applied**, not merely merged — and nothing here fails if it is missing: the deploy succeeds and the name stays dark. +> **The hostname is not in `apps/www/wrangler.jsonc`, deliberately.** DNS record and Workers route are owned end to end by the OpenTofu estate, not by wrangler: wrangler creates and updates routes but **deletes nothing** that disappears from the file, so a retired hostname keeps answering forever. `workers_dev = false` for the same reason a second front door is a bypass. A new hostname is an infrastructure change that has to be **applied**, not merely merged — and nothing here fails if it is missing: the deploy succeeds and the name stays dark. -**The D1 database itself belongs to tofu, not to wrangler** — the same seam the hostname runs along, and for the same reason: no wrangler config creates a D1 database, so one made with `wrangler d1 create` is a resource no state names and nothing ever deletes. `www/wrangler.jsonc` declares only the binding and repeats the id, which is an account-scoped identifier rather than a secret; wrangler refuses to deploy without it. The deploy token in Bitwarden needs `Workers Scripts: Edit` **and `D1: Edit`**. It needs no `Workers Routes: Edit`, because the route is tofu's. +**The D1 database itself belongs to tofu, not to wrangler** — the same seam the hostname runs along, and for the same reason: no wrangler config creates a D1 database, so one made with `wrangler d1 create` is a resource no state names and nothing ever deletes. `apps/www/wrangler.jsonc` declares only the binding and repeats the id, which is an account-scoped identifier rather than a secret; wrangler refuses to deploy without it. The deploy token in Bitwarden needs `Workers Scripts: Edit` **and `D1: Edit`**. It needs no `Workers Routes: Edit`, because the route is tofu's. -**Deploying on every push to `main`, plus every published release.** `www/` reads `docs/` off the checkout — `origin.ref` in its `app.config.ts` names the repository for the edit links and downloads nothing — so the site publishes the documentation of the commit it is built from, and a documentation fix should not wait for a release. A published release triggers a second, deliberately redundant build after its tag exists: `latest` is resolved from that tag rather than racing release-please. The consequence, stated rather than hidden: the version badge comes from `package.json`, which release-please bumps, so between a promotion and its release the site shows the last released number while documenting what is already on `main`. `main` only moves when the promotion PR merges, so the window is short — but it is real. +**Deploying on every push to `main`, plus every published release.** `apps/www/` reads `docs/` off the checkout — `origin.ref` in its `app.config.ts` names the repository for the edit links and downloads nothing — so the site publishes the documentation of the commit it is built from, and a documentation fix should not wait for a release. A published release triggers a second, deliberately redundant build after its tag exists: `latest` is resolved from that tag rather than racing release-please. The consequence, stated rather than hidden: the version badge comes from `package.json`, which release-please bumps, so between a promotion and its release the site shows the last released number while documenting what is already on `main`. `main` only moves when the promotion PR merges, so the window is short — but it is real. **A build is disposable, a publication is not — which is why the deploy is two jobs.** The workflow used to be one job under one `concurrency: deploy-www, cancel-in-progress: false` group, and that serialized whole *runs*: a build for the newest commit could not start while an older publication was still uploading, so a burst of pushes delivered the newest state one full build-and-upload late, every time. Cancelling the combined job instead is not the fix either — it cannot tell a throwaway build from an upload halfway into Cloudflare. So the two halves carry opposite policies: @@ -205,7 +209,7 @@ Three Workers facts follow, and all three are guarded by `const cloudflare = NIT **Both jobs build `main`, not the ref they were triggered from.** A release event points at the tag, and building that would roll the site back whenever `main` has moved past it — the release build exists to refresh `latest`, not to republish the tag. A `workflow_dispatch` points at whichever branch the dropdown was left on, and honouring it would make the Run button a two-click route from any branch into production. Only a `push` builds its own commit. -**Freshness is reported, not gated.** The deployment summary (`scripts/deploy-summary.ts`) carries a *Time to publish* row — from the committer date of the published commit to the moment the upload finished. There is no threshold and no non-zero exit: by the time it runs the Worker is live, and a slow queue is a fact to look at rather than an error to raise. The clock starts on the committer date rather than the push because only a `push` payload carries a push time, and a number meaning a different thing per trigger is worse than one that means the same thing every time. +**Freshness is reported, not gated.** The deployment summary (`apps/www/scripts/deploy-summary.ts`) carries a *Time to publish* row — from the committer date of the published commit to the moment the upload finished. There is no threshold and no non-zero exit: by the time it runs the Worker is live, and a slow queue is a fact to look at rather than an error to raise. The clock starts on the committer date rather than the push because only a `push` payload carries a push time, and a number meaning a different thing per trigger is worse than one that means the same thing every time. **The Cloudflare build is not part of `pnpm check`.** `check` builds the Node server, which is the SSR gate; a second full Nuxt build would roughly double CI for a target only `main` ever reaches. So a Workers-only breakage surfaces in the deploy rather than in the pull request. That is the accepted trade — `pnpm --filter www preview:cf` runs the built Worker on miniflare locally when a change looks like it might land on that side. @@ -234,7 +238,7 @@ It calls a central body that picks its own target: with a `stage` branch it prom ## When working here -- **A checkout under a `.git` directory cannot build this repo, and the failure names the wrong thing.** Two upstream tools treat `.git` as a place source cannot live, and both match on the ABSOLUTE path of every file: nitropack's imports resolver seeds `imports.exclude` with `/[/\\]\.git[/\\]/` whenever the option is still empty, so unimport injects no auto-import into any server file and the build dies in the prerender pass as `ERROR defineMcpTool is not defined` — a real symbol, in a real file, that nothing has broken; and Vite's `server.fs.deny` defaults to `['**/.git/**', …]`, so vitest cannot load `tests/shortcuts.test.ts` either (`Cannot find module`, 0 tests collected — it is the only jsdom suite, which is why it is the only one that fails). Traced on #87 by building the same commit from a worktree under `.git/` with nitro's exclusion suppressed: green. **Neither is duxt's to fix** — overriding either means shipping a weakened upstream default to every site that extends this layer — so `scripts/git-dir-guard.ts` refuses instead, from `www/nuxt.config.ts` and from `vitest.config.ts`, naming the reason. It matters because nobody picks this location by hand: agent worktrees land under `.git/tituskirch-skills/work/…`, so it is on the path every parallel implement or review run takes. **Create the worktree outside the repository** (`git worktree add ../duxt-`); `.claude/worktrees/…` is fine, `.git/…` is not. +- **A checkout under a `.git` directory cannot build this repo, and the failure names the wrong thing.** Two upstream tools treat `.git` as a place source cannot live, and both match on the ABSOLUTE path of every file: nitropack's imports resolver seeds `imports.exclude` with `/[/\\]\.git[/\\]/` whenever the option is still empty, so unimport injects no auto-import into any server file and the build dies in the prerender pass as `ERROR defineMcpTool is not defined` — a real symbol, in a real file, that nothing has broken; and Vite's `server.fs.deny` defaults to `['**/.git/**', …]`, so vitest cannot load `tests/shortcuts.test.ts` either (`Cannot find module`, 0 tests collected — it is the only jsdom suite, which is why it is the only one that fails). Traced on #87 by building the same commit from a worktree under `.git/` with nitro's exclusion suppressed: green. **Neither is duxt's to fix** — overriding either means shipping a weakened upstream default to every site that extends this layer — so `scripts/git-dir-guard.ts` refuses instead, from `apps/www/nuxt.config.ts` and from both packages' `vitest.config.ts`, naming the reason. It matters because nobody picks this location by hand: agent worktrees land under `.git/tituskirch-skills/work/…`, so it is on the path every parallel implement or review run takes. **Create the worktree outside the repository** (`git worktree add ../duxt-`); `.claude/worktrees/…` is fine, `.git/…` is not. - `forgemap` (sibling repo at `../forgemap`) is the de-facto reference implementation of the kirchDev meta conventions. When unsure about a config choice, check what forgemap does. -- The package is published as `@kirchdev/duxt` with `publishConfig.access: public`. It is **not** `"private": true` — do not add that back. +- The layer is published as `@kirchdev/duxt` with `publishConfig.access: public` from `packages/duxt/package.json`. That package is **not** `"private": true` — do not add that back. The workspace root's `package.json` **is** private, deliberately: it is not a package, and nothing publishes it. `prepack` copies the root `README.md` and `LICENSE` into the layer for the tarball and `postpack` removes them, so the published package still carries both. - Once the layer exists, its public surface is what a consumer can override: component, page and `app.config` names would need to stay stable, and a rename becomes a breaking change (`feat!:`). Until then there is no surface to protect. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index afd5ec7b..e9efcabd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -37,21 +37,35 @@ Separate worktrees have separate ownership. After a crash, retry once the owning process has exited: the operating system releases the ownership transaction automatically. Nuxt also reclaims its own -`www/node_modules/.cache/nuxt/.nuxt/nuxt.lock` when its recorded PID no longer -exists. Never remove a live process's lock, `.data`, SQLite database or WAL files. -If a Nuxt lock cannot be read, confirm the owning process is gone before removing -that lock manually. A lock's age alone is not proof that its owner has stopped. +`apps/www/node_modules/.cache/nuxt/.nuxt/nuxt.lock` when its recorded PID no +longer exists. Never remove a live process's lock, `.data`, SQLite database or +WAL files. If a Nuxt lock cannot be read, confirm the owning process is gone +before removing that lock manually. A lock's age alone is not proof that its +owner has stopped. + +## Where things live + +This is a pnpm workspace driven by Turborepo: + +- `packages/duxt` — the published layer, `@kirchdev/duxt`. +- `apps/www` — the site that develops it, and the checks that read its build. +- The root — workspace and meta configuration, plus `docs/`, which is what + `apps/www` publishes. + +Run everything from the root. The root scripts delegate package tasks to +`turbo run`, which caches only what its inputs fully determine. ## Running the suite -| Command | What it does | -| :------------------- | :--------------------------------------------------------- | -| `pnpm lint` | oxlint across the repo. | -| `pnpm format` | oxfmt check across JS / TS / JSON / YAML / MD. | -| `pnpm typecheck` | `tsc --noEmit` over the meta scripts. | -| `pnpm typecheck:app` | `nuxt typecheck` over the layer, via `www/`. | -| `pnpm check` | Runs `lint`, `format`, both typechecks and `check:policy`. | -| `pnpm check:fix` | Auto-fix lint + format issues. | +| Command | What it does | +| :------------------- | :---------------------------------------------------------- | +| `pnpm lint` | oxlint across the repo. | +| `pnpm format` | oxfmt check across JS / TS / JSON / YAML / MD. | +| `pnpm typecheck` | `tsc --noEmit` over the meta and check scripts. | +| `pnpm typecheck:app` | `nuxt typecheck` over the layer, via `apps/www`. | +| `pnpm test` | Every package's vitest suite, through `turbo run test`. | +| `pnpm check` | The CI gate — the full chain is in the root `package.json`. | +| `pnpm check:fix` | Auto-fix lint + format issues. | The same commands run in CI — keep them green before you push. diff --git a/README.md b/README.md index f31e0044..f0fee205 100644 --- a/README.md +++ b/README.md @@ -125,7 +125,7 @@ Everything lives under the `duxt` key of `app.config.ts`. The keys most sites to Every text field takes a literal, an i18n key, or a per-locale record — a single-language site never sees the other two. > [!TIP] -> The full surface — `navigation`, `sections`, `landing`, `feed`, `footer` and the rest — is typed, and the types are the documentation: [`app/types/duxt.d.ts`](app/types/duxt.d.ts) carries a comment per key explaining what it costs and when it is read. +> The full surface — `navigation`, `sections`, `landing`, `feed`, `footer` and the rest — is typed, and the types are the documentation: [`packages/duxt/app/types/duxt.d.ts`](packages/duxt/app/types/duxt.d.ts) carries a comment per key explaining what it costs and when it is read. ## 🧪 Development @@ -136,7 +136,7 @@ pnpm install # wires the husky hooks pnpm check # lint + format + typecheck + tests + policy parity + build + a11y ``` -The repo root **is** the layer — `nuxt.config.ts`, `content.config.ts`, `app/`, `modules/` and `server/` live there, and `package.json` points at them. `www/` beside it is the site that consumes the layer, and the development target: it deliberately carries the awkward cases — two repositories, four refs, one version of each lifecycle. It is not a template; the exemplary starting point lives in [`kirchDev/duxt-starter`](https://github.com/kirchDev/duxt-starter). +A pnpm workspace driven by Turborepo. The layer is [`packages/duxt`](packages/duxt) — `nuxt.config.ts`, `content.config.ts`, `app/`, `modules/`, `server/` and the build-time modules under `build/`. [`apps/www`](apps/www) is the site that consumes it, and the development target: it deliberately carries the awkward cases — two repositories, four refs, one version of each lifecycle. The root holds only the workspace and meta configuration, plus `docs/`, which `apps/www` publishes. It is not a template; the exemplary starting point lives in [`kirchDev/duxt-starter`](https://github.com/kirchDev/duxt-starter). ## 🎨 Assets & branding @@ -148,7 +148,7 @@ The mark is the package name with its first letter bracketed — `[d]uxt` — be Every asset, the colour values, why the icon's brackets are redrawn and the font licensing are in [Conventions → Branding](https://duxt.app/conventions/branding). > [!IMPORTANT] -> The layer ships **no** branding. `duxt.logo` is unset by default, so `DuxtBrand` falls back to the consumer's own `duxt.title` beside a generic icon: a site extending duxt shows its own name in the header and footer and its own icon in the tab, never this one. These assets belong to this repository and to `www/`, not to the published package. +> The layer ships **no** branding. `duxt.logo` is unset by default, so `DuxtBrand` falls back to the consumer's own `duxt.title` beside a generic icon: a site extending duxt shows its own name in the header and footer and its own icon in the tab, never this one. These assets belong to this repository and to `apps/www`, not to the published package. ## 🤝 Contributing diff --git a/www/.env.example b/apps/www/.env.example similarity index 100% rename from www/.env.example rename to apps/www/.env.example diff --git a/www/app/app.config.ts b/apps/www/app/app.config.ts similarity index 99% rename from www/app/app.config.ts rename to apps/www/app/app.config.ts index b18eea91..21388c3a 100644 --- a/www/app/app.config.ts +++ b/apps/www/app/app.config.ts @@ -172,7 +172,7 @@ export default defineAppConfig({ // `v3.x` tree stays at `/demo`; the other editions keep the same overview // under their version prefix, just like the generated API does below. { - path: 'www/demo/docs', + path: 'apps/www/demo/docs', slug: 'demo', name: DEMO_NAME, version: 'main', @@ -180,14 +180,14 @@ export default defineAppConfig({ origin: { repo: 'kirchDev/duxt', ref: 'main' } }, { - path: 'www/demo/docs', + path: 'apps/www/demo/docs', slug: 'demo', name: DEMO_NAME, version: 'v3.x', origin: { repo: 'kirchDev/duxt', ref: 'main' } }, { - path: 'www/demo/docs', + path: 'apps/www/demo/docs', slug: 'demo', name: DEMO_NAME, version: 'v2.x', @@ -195,7 +195,7 @@ export default defineAppConfig({ origin: { repo: 'kirchDev/duxt', ref: 'main' } }, { - path: 'www/demo/docs', + path: 'apps/www/demo/docs', slug: 'demo', name: DEMO_NAME, version: 'v1.x', @@ -223,7 +223,7 @@ export default defineAppConfig({ // second Markdown collection there. Their declarations own the four // edition-specific artefacts, the API reference's and the changelogs'. { - path: 'www/demo/docs', + path: 'apps/www/demo/docs', slug: 'demo', name: DEMO_NAME, content: false, @@ -259,23 +259,23 @@ export default defineAppConfig({ // the row moves around inside it. { type: 'openapi', - path: 'www/demo/v3.yaml', + path: 'apps/www/demo/v3.yaml', label: 'OpenAPI', slug: 'openapi', icon: 'vscode-icons:file-type-swagger', versions: [ { version: 'main', - path: 'www/demo/main.yaml', + path: 'apps/www/demo/main.yaml', status: 'upcoming' }, - { version: 'v3.x', path: 'www/demo/v3.yaml', default: true }, + { version: 'v3.x', path: 'apps/www/demo/v3.yaml', default: true }, { version: 'v2.x', - path: 'www/demo/v2.yaml', + path: 'apps/www/demo/v2.yaml', status: 'deprecated' }, - { version: 'v1.x', path: 'www/demo/v1.yaml', status: 'eol' } + { version: 'v1.x', path: 'apps/www/demo/v1.yaml', status: 'eol' } ] }, // THE SAME API, DESCRIBED THE OTHER WAY ROUND — a Bruno collection @@ -298,7 +298,7 @@ export default defineAppConfig({ // instead of it — only the archive is pinned to the version on screen. { type: 'bruno', - path: 'www/demo/collection', + path: 'apps/www/demo/collection', label: 'Bruno', slug: 'bruno', icon: 'vscode-icons:file-type-bruno', @@ -310,7 +310,7 @@ export default defineAppConfig({ // The fixture shows every change category in the split layout. { type: 'changelog', - path: 'www/demo/CHANGELOG.md', + path: 'apps/www/demo/CHANGELOG.md', label: 'Changelog', slug: 'changelog', navigation: 'sections', @@ -329,7 +329,7 @@ export default defineAppConfig({ // Keep the full-file rendering available as a second demo. { type: 'changelog', - path: 'www/demo/CHANGELOG.md', + path: 'apps/www/demo/CHANGELOG.md', label: 'Changelog (flat)', slug: 'changelog-flat', navigation: false, @@ -759,7 +759,7 @@ export default defineAppConfig({ * 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 + * LAYER's English messages. A key in a `apps/www/i18n/` file would be printed * to a model verbatim; a record resolves to its `en-GB` entry there. */ headline: { @@ -1389,7 +1389,7 @@ Creates and manages an Acme widget. // // `/echo` is the same shape at a tenth the height, with one server, // one bearer field and two body fields, answered by a Nitro route in - // `www/server/`. A reader presses Send and gets a 201 back. + // `apps/www/server/`. A reader presses Send and gets a 201 back. // THE WHOLE WIDTH, and the prose above it rather than beside it: the // client is a control built for a column of its own, and half a band // is not one. `full` also splits it — form in one card, "this request diff --git a/www/app/assets/css/brand.css b/apps/www/app/assets/css/brand.css similarity index 100% rename from www/app/assets/css/brand.css rename to apps/www/app/assets/css/brand.css diff --git a/www/demo/CHANGELOG.md b/apps/www/demo/CHANGELOG.md similarity index 100% rename from www/demo/CHANGELOG.md rename to apps/www/demo/CHANGELOG.md diff --git a/www/demo/collection/1.ping.bru b/apps/www/demo/collection/1.ping.bru similarity index 100% rename from www/demo/collection/1.ping.bru rename to apps/www/demo/collection/1.ping.bru diff --git a/www/demo/collection/2.signed.bru b/apps/www/demo/collection/2.signed.bru similarity index 100% rename from www/demo/collection/2.signed.bru rename to apps/www/demo/collection/2.signed.bru diff --git a/www/demo/collection/bruno.json b/apps/www/demo/collection/bruno.json similarity index 100% rename from www/demo/collection/bruno.json rename to apps/www/demo/collection/bruno.json diff --git a/www/demo/collection/collection.bru b/apps/www/demo/collection/collection.bru similarity index 100% rename from www/demo/collection/collection.bru rename to apps/www/demo/collection/collection.bru diff --git a/www/demo/collection/consignments/1.create.bru b/apps/www/demo/collection/consignments/1.create.bru similarity index 100% rename from www/demo/collection/consignments/1.create.bru rename to apps/www/demo/collection/consignments/1.create.bru diff --git a/www/demo/collection/consignments/2.list.bru b/apps/www/demo/collection/consignments/2.list.bru similarity index 100% rename from www/demo/collection/consignments/2.list.bru rename to apps/www/demo/collection/consignments/2.list.bru diff --git a/www/demo/collection/consignments/3.get.bru b/apps/www/demo/collection/consignments/3.get.bru similarity index 100% rename from www/demo/collection/consignments/3.get.bru rename to apps/www/demo/collection/consignments/3.get.bru diff --git a/www/demo/collection/consignments/folder.bru b/apps/www/demo/collection/consignments/folder.bru similarity index 100% rename from www/demo/collection/consignments/folder.bru rename to apps/www/demo/collection/consignments/folder.bru diff --git a/www/demo/collection/environments/local.bru b/apps/www/demo/collection/environments/local.bru similarity index 100% rename from www/demo/collection/environments/local.bru rename to apps/www/demo/collection/environments/local.bru diff --git a/www/demo/collection/tracking/1.events.bru b/apps/www/demo/collection/tracking/1.events.bru similarity index 100% rename from www/demo/collection/tracking/1.events.bru rename to apps/www/demo/collection/tracking/1.events.bru diff --git a/www/demo/collection/tracking/folder.bru b/apps/www/demo/collection/tracking/folder.bru similarity index 100% rename from www/demo/collection/tracking/folder.bru rename to apps/www/demo/collection/tracking/folder.bru diff --git a/www/demo/docs/1.versions.md b/apps/www/demo/docs/1.versions.md similarity index 100% rename from www/demo/docs/1.versions.md rename to apps/www/demo/docs/1.versions.md diff --git a/www/demo/docs/2.source.md b/apps/www/demo/docs/2.source.md similarity index 100% rename from www/demo/docs/2.source.md rename to apps/www/demo/docs/2.source.md diff --git a/www/demo/docs/3.images.md b/apps/www/demo/docs/3.images.md similarity index 100% rename from www/demo/docs/3.images.md rename to apps/www/demo/docs/3.images.md diff --git a/www/demo/docs/index.md b/apps/www/demo/docs/index.md similarity index 100% rename from www/demo/docs/index.md rename to apps/www/demo/docs/index.md diff --git a/www/demo/main.yaml b/apps/www/demo/main.yaml similarity index 100% rename from www/demo/main.yaml rename to apps/www/demo/main.yaml diff --git a/www/demo/v1.yaml b/apps/www/demo/v1.yaml similarity index 100% rename from www/demo/v1.yaml rename to apps/www/demo/v1.yaml diff --git a/www/demo/v2.yaml b/apps/www/demo/v2.yaml similarity index 100% rename from www/demo/v2.yaml rename to apps/www/demo/v2.yaml diff --git a/www/demo/v3.yaml b/apps/www/demo/v3.yaml similarity index 99% rename from www/demo/v3.yaml rename to apps/www/demo/v3.yaml index b9f71bf6..a2084967 100644 --- a/www/demo/v3.yaml +++ b/apps/www/demo/v3.yaml @@ -6,7 +6,7 @@ # than the easy ones: every parameter location, several media types, `oneOf` # with a discriminator, an `allOf` that folds and one that cannot, an `anyOf`, # a self-referential schema, a deprecated operation, three authentication -# schemes, response links, a callback and a webhook. The same argument `www/` +# schemes, response links, a callback and a webhook. The same argument `apps/www/` # makes everywhere else — a development site wants edge cases, and a starter # wants none of them. # diff --git a/www/modules/search-records.ts b/apps/www/modules/search-records.ts similarity index 100% rename from www/modules/search-records.ts rename to apps/www/modules/search-records.ts diff --git a/www/nuxt.config.ts b/apps/www/nuxt.config.ts similarity index 96% rename from www/nuxt.config.ts rename to apps/www/nuxt.config.ts index 875ceb52..d819a531 100644 --- a/www/nuxt.config.ts +++ b/apps/www/nuxt.config.ts @@ -3,9 +3,9 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { defineNuxtConfig } from 'nuxt/config'; import { fileURLToPath } from 'node:url'; -import { refuseGitDirectory } from '../scripts/git-dir-guard.ts'; -import { claimNuxtProcess } from '../scripts/nuxt-process-guard.ts'; -import { prerenderConcurrency } from '../scripts/prerender-bench.ts'; +import { refuseGitDirectory } from '../../scripts/git-dir-guard.ts'; +import { claimNuxtProcess } from './scripts/nuxt-process-guard.ts'; +import { prerenderConcurrency } from './scripts/prerender-bench.ts'; import { duxtOgImageBuildCache, duxtOgImageFingerprint, @@ -29,7 +29,9 @@ claimNuxtProcess(siteDir, process.argv.slice(2).join(' ') || 'Nuxt'); /** * The version this site documents, read rather than typed. * - * duxt's own `package.json`, one directory up — the file release-please bumps. + * The layer's own `package.json` in `packages/duxt` — the file release-please + * bumps when it releases `@kirchdev/duxt`. Not the workspace root's, which is + * private and carries no version at all. * 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. @@ -46,7 +48,10 @@ claimNuxtProcess(siteDir, process.argv.slice(2).join(' ') || 'Nuxt'); const version = (() => { try { const pkg = JSON.parse( - readFileSync(new URL('../package.json', import.meta.url), 'utf8') + readFileSync( + new URL('../../packages/duxt/package.json', import.meta.url), + 'utf8' + ) ) as { version?: string }; return pkg.version ? `v${pkg.version}` : undefined; @@ -150,7 +155,7 @@ const adapterDatabase = () => { * to the build's, and libsql creates the database but not the directory * above it. `file:.data/content/libsql.db` therefore looked right and died * with `Unable to open connection … : 14` (SQLITE_CANTOPEN) the moment the - * server was started from anywhere but `www/`. The temp directory is the + * server was started from anywhere but `apps/www/`. The temp directory is the * one place guaranteed to exist and be writable on both. */ return { @@ -456,7 +461,7 @@ export default defineNuxtConfig({ * validator takes no per-rule options from here, so it is all or nothing. * It runs under `nuxi dev` only — a build never carries it. * - * In `www/` and not in the layer: a consumer that extends duxt keeps the + * In `apps/www/` and not in the layer: a consumer that extends duxt keeps the * validator for its own head tags. */ unhead: { vite: { validate: false } }, diff --git a/www/package.json b/apps/www/package.json similarity index 59% rename from www/package.json rename to apps/www/package.json index 59d927da..cfac4b45 100644 --- a/www/package.json +++ b/apps/www/package.json @@ -9,10 +9,17 @@ "dev": "nuxt dev", "build": "nuxt build", "build:cf": "NITRO_PRESET=cloudflare-module nuxt build && pnpm run check:routes", - "check:routes": "node ../scripts/check-routes.ts", + "check:a11y": "node scripts/check-a11y.ts", + "check:adapters": "node scripts/check-adapters.ts", + "check:images": "node scripts/check-images.ts", + "check:keyboard": "node scripts/check-keyboard.ts", + "check:overflow": "node scripts/check-overflow.ts", + "check:routes": "node scripts/check-routes.ts", + "check:seo": "node scripts/check-seo.ts", "deploy": "pnpm run build:cf && pnpm run publish:cf", "publish:cf": "wrangler deploy", "preview:cf": "pnpm run build:cf && wrangler dev", + "test": "vitest run", "typecheck": "nuxt typecheck" }, "dependencies": { @@ -23,10 +30,17 @@ }, "devDependencies": { "@libsql/client": "^0.18.0", + "@types/jsdom": "^30.0.0", + "@types/node": "^26.4.0", + "axe-core": "^4.13.0", + "jsdom": "^30.0.1", "nuxtseo-layer-devtools": "^5.3.14", "pg": "^8.23.0", + "playwright-core": "^1.63.0", "typescript": "^6.0.3", + "vitest": "^4.1.11", "vue-tsc": "^3.3.11", - "wrangler": "^4.114.0" + "wrangler": "^4.114.0", + "yaml": "^2.9.0" } } diff --git a/www/public/apple-touch-icon.png b/apps/www/public/apple-touch-icon.png similarity index 100% rename from www/public/apple-touch-icon.png rename to apps/www/public/apple-touch-icon.png diff --git a/www/public/demo/badge.png b/apps/www/public/demo/badge.png similarity index 100% rename from www/public/demo/badge.png rename to apps/www/public/demo/badge.png diff --git a/www/public/demo/ornament.png b/apps/www/public/demo/ornament.png similarity index 100% rename from www/public/demo/ornament.png rename to apps/www/public/demo/ornament.png diff --git a/www/public/demo/pipeline.svg b/apps/www/public/demo/pipeline.svg similarity index 100% rename from www/public/demo/pipeline.svg rename to apps/www/public/demo/pipeline.svg diff --git a/www/public/demo/screenshot-dark.png b/apps/www/public/demo/screenshot-dark.png similarity index 100% rename from www/public/demo/screenshot-dark.png rename to apps/www/public/demo/screenshot-dark.png diff --git a/www/public/demo/screenshot.png b/apps/www/public/demo/screenshot.png similarity index 100% rename from www/public/demo/screenshot.png rename to apps/www/public/demo/screenshot.png diff --git a/www/public/demo/spinner.gif b/apps/www/public/demo/spinner.gif similarity index 100% rename from www/public/demo/spinner.gif rename to apps/www/public/demo/spinner.gif diff --git a/www/public/favicon.svg b/apps/www/public/favicon.svg similarity index 100% rename from www/public/favicon.svg rename to apps/www/public/favicon.svg diff --git a/www/public/wordmark-dark.svg b/apps/www/public/wordmark-dark.svg similarity index 100% rename from www/public/wordmark-dark.svg rename to apps/www/public/wordmark-dark.svg diff --git a/www/public/wordmark.svg b/apps/www/public/wordmark.svg similarity index 100% rename from www/public/wordmark.svg rename to apps/www/public/wordmark.svg diff --git a/scripts/browser.ts b/apps/www/scripts/browser.ts similarity index 100% rename from scripts/browser.ts rename to apps/www/scripts/browser.ts diff --git a/scripts/built-server.ts b/apps/www/scripts/built-server.ts similarity index 98% rename from scripts/built-server.ts rename to apps/www/scripts/built-server.ts index 74ef1be8..f9b9d3d0 100644 --- a/scripts/built-server.ts +++ b/apps/www/scripts/built-server.ts @@ -1,7 +1,7 @@ /** * The built `www` server, started for a check and stopped after it. * - * Six checks run against `www/.output/server/index.mjs`, and each used to spawn + * Six checks run against `apps/www/.output/server/index.mjs`, and each used to spawn * it, collect its stderr, wait for it and kill it on its own — two different * ways of waiting between them, and only two of the six waiting for the right * thing. @@ -24,7 +24,6 @@ import { fileURLToPath } from 'node:url'; const entry = join( dirname(fileURLToPath(import.meta.url)), '..', - 'www', '.output', 'server', 'index.mjs' diff --git a/scripts/check-a11y.ts b/apps/www/scripts/check-a11y.ts similarity index 100% rename from scripts/check-a11y.ts rename to apps/www/scripts/check-a11y.ts diff --git a/scripts/check-adapters.ts b/apps/www/scripts/check-adapters.ts similarity index 99% rename from scripts/check-adapters.ts rename to apps/www/scripts/check-adapters.ts index dfaba2cd..82fb4df7 100644 --- a/scripts/check-adapters.ts +++ b/apps/www/scripts/check-adapters.ts @@ -26,7 +26,7 @@ * page did not get far enough for the rest of this to mean anything. * * IT DOES NOT BUILD. Like `check:a11y` and `check:seo` it runs the server that - * is already in `www/.output`, so the adapter under test is whatever that build + * is already in `apps/www/.output`, so the adapter under test is whatever that build * was given. Build first: * * DUXT_CONTENT_ADAPTER=libsql pnpm build:app diff --git a/scripts/check-images.ts b/apps/www/scripts/check-images.ts similarity index 99% rename from scripts/check-images.ts rename to apps/www/scripts/check-images.ts index 42511c25..af3f30d4 100644 --- a/scripts/check-images.ts +++ b/apps/www/scripts/check-images.ts @@ -10,7 +10,7 @@ * nothing checks. The zoom had been broken since the day it was written: Vue * casts an ABSENT prop whose type includes `Boolean` to `false`, so * `zoom?: boolean | string` made every image non-zoomable unless a page wrote - * `zoom="true"`, and there was no page to notice. `www/demo/docs/3.images.md` + * `zoom="true"`, and there was no page to notice. `apps/www/demo/docs/3.images.md` * is the fixture that renders one of each shape; this is what reads the result. * * WHAT IT ASSERTS IS THE ISSUE'S OWN ACCEPTANCE. A large original has to expose diff --git a/scripts/check-keyboard.ts b/apps/www/scripts/check-keyboard.ts similarity index 100% rename from scripts/check-keyboard.ts rename to apps/www/scripts/check-keyboard.ts diff --git a/scripts/check-overflow.ts b/apps/www/scripts/check-overflow.ts similarity index 100% rename from scripts/check-overflow.ts rename to apps/www/scripts/check-overflow.ts diff --git a/scripts/check-routes.ts b/apps/www/scripts/check-routes.ts similarity index 98% rename from scripts/check-routes.ts rename to apps/www/scripts/check-routes.ts index d1715797..2f8553a8 100644 --- a/scripts/check-routes.ts +++ b/apps/www/scripts/check-routes.ts @@ -8,8 +8,8 @@ * invokes the Worker at all. Which routes end up on which side of that line * decides what depends on D1, what a cache may hold, and what can only change * on a rebuild — and it was, until this file, described twice in prose that had - * drifted apart. `www/nuxt.config.ts` named three runtime paths and - * `www/wrangler.jsonc` named a different three; between them they left + * drifted apart. `apps/www/nuxt.config.ts` named three runtime paths and + * `apps/www/wrangler.jsonc` named a different three; between them they left * `llms-full.txt` and `rss.xml` unaccounted for. * * So the classification is data here, and the two configs point at it rather @@ -601,7 +601,7 @@ function readHandlerRoutes(directory: string): string[] { } function main(): void { - const output = fileURLToPath(new URL('../www/.output/', import.meta.url)); + const output = fileURLToPath(new URL('../.output/', import.meta.url)); let preset: string | undefined; try { @@ -612,7 +612,7 @@ function main(): void { ).preset; } catch { throw new Error( - 'No build artifact at www/.output. Run `pnpm --filter www build:cf` first — ' + + 'No build artifact at apps/www/.output. Run `pnpm --filter www build:cf` first — ' + 'this check reads the Cloudflare build, not the config.' ); } @@ -622,7 +622,7 @@ function main(): void { // proving nothing: that build prerenders no page at all. if (!preset?.startsWith('cloudflare')) { throw new Error( - `www/.output was built with the \`${preset}\` preset. This check reads a Cloudflare ` + + `apps/www/.output was built with the \`${preset}\` preset. This check reads a Cloudflare ` + 'build — run `pnpm --filter www build:cf` first.' ); } diff --git a/scripts/check-seo.ts b/apps/www/scripts/check-seo.ts similarity index 100% rename from scripts/check-seo.ts rename to apps/www/scripts/check-seo.ts diff --git a/scripts/deploy-summary.ts b/apps/www/scripts/deploy-summary.ts similarity index 100% rename from scripts/deploy-summary.ts rename to apps/www/scripts/deploy-summary.ts diff --git a/scripts/nuxt-process-guard.ts b/apps/www/scripts/nuxt-process-guard.ts similarity index 100% rename from scripts/nuxt-process-guard.ts rename to apps/www/scripts/nuxt-process-guard.ts diff --git a/scripts/prerender-bench.ts b/apps/www/scripts/prerender-bench.ts similarity index 99% rename from scripts/prerender-bench.ts rename to apps/www/scripts/prerender-bench.ts index 946aea1c..c35c0508 100644 --- a/scripts/prerender-bench.ts +++ b/apps/www/scripts/prerender-bench.ts @@ -5,7 +5,7 @@ * numbers from several of them are allowed to conclude. * * WHY A HARNESS AND NOT A NUMBER. `prerender.concurrency: 8` in - * `www/nuxt.config.ts` is the response to a build that produced 335 + * `apps/www/nuxt.config.ts` is the response to a build that produced 335 * `createImage timeout` lines — 335 pages that shipped with no OG image at all, * silently, because a missing OG image fails nothing. Eight brought that to * 140. Nobody has since measured whether it is the right number, and a local @@ -52,7 +52,7 @@ import { existsSync, readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; -/** What `www/nuxt.config.ts` prerenders with when nothing overrides it. */ +/** What `apps/www/nuxt.config.ts` prerenders with when nothing overrides it. */ export const DEFAULT_PRERENDER_CONCURRENCY = 8; /** diff --git a/scripts/search-index-bench.ts b/apps/www/scripts/search-index-bench.ts similarity index 99% rename from scripts/search-index-bench.ts rename to apps/www/scripts/search-index-bench.ts index fc38a24f..3e49b6b6 100644 --- a/scripts/search-index-bench.ts +++ b/apps/www/scripts/search-index-bench.ts @@ -9,7 +9,7 @@ * every source is the better shape, and named the thing the answer rests on: * "~100 KB for 2,500 pages including wasm, JS and the chunks actually fetched * is Pagefind's own figure from its XKCD demo, not ours. Measure it over - * `www/`'s real content before anything else — everything below is opinion + * `apps/www/`'s real content before anything else — everything below is opinion * until that number exists." The number now exists, it is in * `docs/99.adr/0010-keep-contents-per-collection-search-as-the-default.md`, and * this file is what it was computed by, so the next candidate is measured the @@ -138,7 +138,7 @@ function mean(files: IndexFile[]): number { * * The fragments are charged at the index's MEAN rather than by naming files, * because which ten come back is a property of the question. The mean over - * `www/` was 419 bytes and the 95th percentile 814, so a result list is a + * `apps/www/` was 419 bytes and the 95th percentile 814, so a result list is a * rounding error against the wasm either way — which is itself the finding. */ export function pagefindQueryFootprint( diff --git a/www/server/routes/demo/echo.ts b/apps/www/server/routes/demo/echo.ts similarity index 93% rename from www/server/routes/demo/echo.ts rename to apps/www/server/routes/demo/echo.ts index dac1afa0..35ff9bc4 100644 --- a/www/server/routes/demo/echo.ts +++ b/apps/www/server/routes/demo/echo.ts @@ -1,7 +1,7 @@ /** * The one endpoint this site actually answers. * - * `www/demo/` describes an invented API and says so — no host behind it + * `apps/www/demo/` describes an invented API and says so — no host behind it * replies, which is right for a reference page and wrong for the try-it client, * where a reader presses Send and gets a network error. * @@ -16,7 +16,7 @@ * show the authorisation field doing something, and not enough to pretend this * is an account system. * - * It lives in `www/` rather than in the layer: duxt ships no API, and a + * It lives in `apps/www/` rather than in the layer: duxt ships no API, and a * documentation layer that mounted a writable route into every site extending * it would be shipping one. */ diff --git a/www/server/routes/demo/echo/[...path].ts b/apps/www/server/routes/demo/echo/[...path].ts similarity index 100% rename from www/server/routes/demo/echo/[...path].ts rename to apps/www/server/routes/demo/echo/[...path].ts diff --git a/tests/check-images.test.ts b/apps/www/tests/check-images.test.ts similarity index 100% rename from tests/check-images.test.ts rename to apps/www/tests/check-images.test.ts diff --git a/tests/check-routes.test.ts b/apps/www/tests/check-routes.test.ts similarity index 100% rename from tests/check-routes.test.ts rename to apps/www/tests/check-routes.test.ts diff --git a/tests/check-seo.test.ts b/apps/www/tests/check-seo.test.ts similarity index 95% rename from tests/check-seo.test.ts rename to apps/www/tests/check-seo.test.ts index 2987d940..84f463a0 100644 --- a/tests/check-seo.test.ts +++ b/apps/www/tests/check-seo.test.ts @@ -13,8 +13,9 @@ async function runCheck(mode: string) { if (!address || typeof address === 'string') throw new Error('No test port'); const port = address.port; await new Promise((done) => reservation.close(() => done())); + // The site's own shape: `scripts/` beside the `.output` a build writes. await mkdir(join(root, 'scripts')); - await mkdir(join(root, 'www/.output/server'), { recursive: true }); + await mkdir(join(root, '.output/server'), { recursive: true }); await symlink(resolve('node_modules'), join(root, 'node_modules'), 'dir'); await copyFile( resolve('scripts/check-seo.ts'), @@ -26,11 +27,11 @@ async function runCheck(mode: string) { ); await copyFile( resolve('tests/fixtures/seo-server.mjs'), - join(root, 'www/.output/server/index.mjs') + join(root, '.output/server/index.mjs') ); const foreign = mode === 'occupied' - ? spawn(process.execPath, [join(root, 'www/.output/server/index.mjs')], { + ? spawn(process.execPath, [join(root, '.output/server/index.mjs')], { env: { ...process.env, SEO_FIXTURE_MODE: 'valid', diff --git a/tests/deploy-summary.test.ts b/apps/www/tests/deploy-summary.test.ts similarity index 100% rename from tests/deploy-summary.test.ts rename to apps/www/tests/deploy-summary.test.ts diff --git a/tests/deploy-workflow.test.ts b/apps/www/tests/deploy-workflow.test.ts similarity index 98% rename from tests/deploy-workflow.test.ts rename to apps/www/tests/deploy-workflow.test.ts index 2bb3d6a4..d4fcdff8 100644 --- a/tests/deploy-workflow.test.ts +++ b/apps/www/tests/deploy-workflow.test.ts @@ -53,7 +53,9 @@ interface Workflow { const workflow = parse( readFileSync( - fileURLToPath(new URL('../.github/workflows/deploy.yml', import.meta.url)), + fileURLToPath( + new URL('../../../.github/workflows/deploy.yml', import.meta.url) + ), 'utf8' ) ) as Workflow; diff --git a/tests/docs-locales.test.ts b/apps/www/tests/docs-locales.test.ts similarity index 87% rename from tests/docs-locales.test.ts rename to apps/www/tests/docs-locales.test.ts index 267ac30c..b4559045 100644 --- a/tests/docs-locales.test.ts +++ b/apps/www/tests/docs-locales.test.ts @@ -2,7 +2,8 @@ import { readdirSync } from 'node:fs'; import { join, relative } from 'node:path'; import { describe, expect, it } from 'vitest'; -const docs = join(import.meta.dirname, '..', 'docs'); +// The repository's own documentation, at the workspace root. +const docs = join(import.meta.dirname, '..', '..', '..', 'docs'); const locales = ['de', 'es', 'fr', 'pt']; function markdownFiles(root: string, directory = root): string[] { diff --git a/tests/fixtures/nuxt-ownership/process.ts b/apps/www/tests/fixtures/nuxt-ownership/process.ts similarity index 100% rename from tests/fixtures/nuxt-ownership/process.ts rename to apps/www/tests/fixtures/nuxt-ownership/process.ts diff --git a/tests/fixtures/seo-server.mjs b/apps/www/tests/fixtures/seo-server.mjs similarity index 100% rename from tests/fixtures/seo-server.mjs rename to apps/www/tests/fixtures/seo-server.mjs diff --git a/tests/git-dir-guard.test.ts b/apps/www/tests/git-dir-guard.test.ts similarity index 98% rename from tests/git-dir-guard.test.ts rename to apps/www/tests/git-dir-guard.test.ts index 1379726d..45c76d7e 100644 --- a/tests/git-dir-guard.test.ts +++ b/apps/www/tests/git-dir-guard.test.ts @@ -5,7 +5,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { refuseGitDirectory, underGitDirectory -} from '../scripts/git-dir-guard.ts'; +} from '../../../scripts/git-dir-guard.ts'; const roots: string[] = []; diff --git a/tests/nuxt-process-guard.test.ts b/apps/www/tests/nuxt-process-guard.test.ts similarity index 93% rename from tests/nuxt-process-guard.test.ts rename to apps/www/tests/nuxt-process-guard.test.ts index 1f8450b2..a7ec312d 100644 --- a/tests/nuxt-process-guard.test.ts +++ b/apps/www/tests/nuxt-process-guard.test.ts @@ -20,7 +20,7 @@ const fixture = fileURLToPath( ); const project = fileURLToPath(new URL('..', import.meta.url)); const nuxt = fileURLToPath( - new URL('../www/node_modules/nuxt/bin/nuxt.mjs', import.meta.url) + new URL('../node_modules/nuxt/bin/nuxt.mjs', import.meta.url) ); function start(root: string, command: string) { @@ -149,14 +149,14 @@ it('does not expire a live legacy Nuxt lock based on its age', async () => { it('keeps serving when a .env change restarts Nuxt dev', async () => { const port = await reservePort(); /** - * INSIDE `www/`, by a bare name. nuxi watches only its own working directory - * and compares the changed file's NAME with `--dotenv`, so a file handed over - * as an absolute path elsewhere never restarts anything — which is what this - * test used to do, and why it passed without a restart ever happening. - * `.env.*` is ignored by git; the site's own `.env` is left alone. + * INSIDE `apps/www/`, by a bare name. nuxi watches only its own working + * directory and compares the changed file's NAME with `--dotenv`, so a file + * handed over as an absolute path elsewhere never restarts anything — which + * is what this test used to do, and why it passed without a restart ever + * happening. `.env.*` is ignored by git; the site's own `.env` is left alone. */ const name = `.env.duxt-reload-test-${process.pid}`; - const env = join(project, 'www', name); + const env = join(project, name); writeFileSync(env, ''); const probe = `http://reload-probe-${process.pid}.invalid`; @@ -164,7 +164,7 @@ it('keeps serving when a .env change restarts Nuxt dev', async () => { process.execPath, [nuxt, 'dev', '--port', String(port), '--dotenv', name], { - cwd: join(project, 'www'), + cwd: project, detached: true, env: { ...process.env, diff --git a/tests/prerender-bench.test.ts b/apps/www/tests/prerender-bench.test.ts similarity index 99% rename from tests/prerender-bench.test.ts rename to apps/www/tests/prerender-bench.test.ts index 8988af0f..65054514 100644 --- a/tests/prerender-bench.test.ts +++ b/apps/www/tests/prerender-bench.test.ts @@ -481,7 +481,10 @@ describe('the benchmark workflow', () => { const workflow = parse( readFileSync( fileURLToPath( - new URL('../.github/workflows/prerender-bench.yml', import.meta.url) + new URL( + '../../../.github/workflows/prerender-bench.yml', + import.meta.url + ) ), 'utf8' ) diff --git a/tests/search-index-bench.test.ts b/apps/www/tests/search-index-bench.test.ts similarity index 96% rename from tests/search-index-bench.test.ts rename to apps/www/tests/search-index-bench.test.ts index 2086d341..56105d18 100644 --- a/tests/search-index-bench.test.ts +++ b/apps/www/tests/search-index-bench.test.ts @@ -9,7 +9,7 @@ import { * The arithmetic behind issue #33, which is the only part of that evaluation a * test can reach. * - * What #33 asked for is a MEASUREMENT over `www/`'s real content, and the + * What #33 asked for is a MEASUREMENT over `apps/www/`'s real content, and the * numbers in `docs/99.adr/0010-*` were taken by building a Pagefind index from * 11,937 real records. Nothing here re-takes them: a test that built an index * would need the 57 MB platform binary in CI for a candidate the ADR rejected. @@ -24,7 +24,7 @@ import { /** * A Pagefind index as `getFiles()` actually returns one, cut down to two - * languages and the sizes measured on `www/`. + * languages and the sizes measured on `apps/www/`. * * REAL SIZES, because the whole point of the fixture is that the rule was read * against the shape Pagefind emits rather than an imagined one: the entry and @@ -94,7 +94,7 @@ describe('pagefindQueryFootprint', () => { }); /** - * A finished build of `www/`, cut to the files search actually reaches for. + * A finished build of `apps/www/`, cut to the files search actually reaches for. * * The engine is Content's client database — a WASM build of SQLite and the * worker that drives it — and the payload is one whole `sql_dump.txt` per @@ -154,7 +154,7 @@ describe('contentQueryFootprint', () => { * ADR's "no" is a value this function returns, not a sentence somebody typed. */ -/** Pagefind 1.5.2 over `www/`'s 11,937 records, as measured for #33. */ +/** Pagefind 1.5.2 over `apps/www/`'s 11,937 records, as measured for #33. */ const pagefind = { coldBytes: 149_919, warmBytes: 13_186, diff --git a/tests/seo-fixture.test.ts b/apps/www/tests/seo-fixture.test.ts similarity index 100% rename from tests/seo-fixture.test.ts rename to apps/www/tests/seo-fixture.test.ts diff --git a/www/tsconfig.json b/apps/www/tsconfig.json similarity index 100% rename from www/tsconfig.json rename to apps/www/tsconfig.json diff --git a/apps/www/vitest.config.ts b/apps/www/vitest.config.ts new file mode 100644 index 00000000..0fbddae9 --- /dev/null +++ b/apps/www/vitest.config.ts @@ -0,0 +1,22 @@ +import { fileURLToPath } from 'node:url'; +import { defineConfig } from 'vitest/config'; +import { refuseGitDirectory } from '../../scripts/git-dir-guard.ts'; + +/** + * The site's own tests: the checks that read a build of this site, the deploy + * and benchmark workflows, the process guard its Nuxt config claims, and the + * documentation tree it publishes. The layer's pure logic is tested in + * `packages/duxt`. + * + * Refused under a `.git` directory for the reason `packages/duxt/vitest.config.ts` + * states: Vite's `server.fs.deny` would otherwise report every test file as a + * missing module. + */ +refuseGitDirectory(fileURLToPath(new URL('.', import.meta.url)), 'vitest'); + +export default defineConfig({ + test: { + include: ['tests/**/*.test.ts'], + environment: 'node' + } +}); diff --git a/www/wrangler.jsonc b/apps/www/wrangler.jsonc similarity index 95% rename from www/wrangler.jsonc rename to apps/www/wrangler.jsonc index b012482c..6379381d 100644 --- a/www/wrangler.jsonc +++ b/apps/www/wrangler.jsonc @@ -1,13 +1,13 @@ { // $schema is the one line wrangler itself reads for editor completion; every // other key here is a deployment fact. - "$schema": "../node_modules/wrangler/config-schema.json", + "$schema": "./node_modules/wrangler/config-schema.json", "name": "duxt-www", // Nitro's `cloudflare-module` preset writes an ES-module Worker, and the // built assets sit beside it. Both paths resolve against THIS FILE, which is - // why it lives in `www/` rather than at the repository root — `wrangler + // why it lives in `apps/www/` rather than at the repository root — `wrangler // deploy` is run from here. "main": "./.output/server/index.mjs", @@ -15,7 +15,7 @@ // Nitro emits is served straight from Cloudflare's edge storage; the Worker // is only reached for what is left. WHAT IS LEFT IS LISTED IN // `scripts/check-routes.ts` AND NOT REPEATED HERE — this comment and the one - // in `www/nuxt.config.ts` each carried their own list, the two disagreed, and + // in `apps/www/nuxt.config.ts` each carried their own list, the two disagreed, and // that is the ambiguity the table replaced. `pnpm check:routes` verifies it // against `.output/public` as the last step of `build:cf`, so an artifact that // disagrees never reaches the publish job. diff --git a/docs/6.conventions/1.branding.md b/docs/6.conventions/1.branding.md index d99a5172..b50f391b 100644 --- a/docs/6.conventions/1.branding.md +++ b/docs/6.conventions/1.branding.md @@ -28,8 +28,8 @@ installed anywhere. | Asset | Path | Used by | | :------------------ | :--------------------------------------------------------- | :------------------------------- | | Wordmark | `.github/assets/wordmark-light.svg`, `wordmark-dark.svg` | The README hero, via `` | -| Site icons | `www/public/favicon.svg`, `apple-touch-icon.png` (180×180) | The browser tab | -| In-site wordmark | `www/public/wordmark.svg`, `wordmark-dark.svg` | `duxt.logo` in `www/` | +| Site icons | `apps/www/public/favicon.svg`, `apple-touch-icon.png` (180×180) | The browser tab | +| In-site wordmark | `apps/www/public/wordmark.svg`, `wordmark-dark.svg` | `duxt.logo` in `apps/www/` | The README wordmark narrows the bracket cells by 120 font units: Plex Mono gives `[` a 600-unit cell for 311 units of ink, and at full monospace width the @@ -76,7 +76,7 @@ Nothing has to be paid, attributed or relicensed — the credit above is courtes `duxt.logo` is unset by default, and `DuxtBrand` falls back to the consumer's own `duxt.title` beside a generic icon. A site extending duxt shows its own name in the header and footer and its own icon in the tab, never this one. Every asset -above belongs to this repository and to `www/` — none of them is in the published +above belongs to this repository and to `apps/www/` — none of them is in the published package. :: diff --git a/docs/de/6.conventions/1.branding.md b/docs/de/6.conventions/1.branding.md index 35585fc2..0089972d 100644 --- a/docs/de/6.conventions/1.branding.md +++ b/docs/de/6.conventions/1.branding.md @@ -29,8 +29,8 @@ Schrift irgendwo installiert ist. | Asset | Pfad | Genutzt von | | :----------------------- | :----------------------------------------------------------- | :----------------------------------- | | Wortmarke | `.github/assets/wordmark-light.svg`, `wordmark-dark.svg` | Der README-Hero, über `` | -| 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/` | +| Website-Icons | `apps/www/public/favicon.svg`, `apple-touch-icon.png` (180×180) | Der Browser-Tab | +| Wortmarke auf der Seite | `apps/www/public/wordmark.svg`, `wordmark-dark.svg` | `duxt.logo` in `apps/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 @@ -81,7 +81,7 @@ Nennung oben ist Höflichkeit. `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. +`apps/www/` — keines liegt im veröffentlichten Paket. :: Wie du deine eigene setzt, steht in diff --git a/docs/es/6.conventions/1.branding.md b/docs/es/6.conventions/1.branding.md index c3ceb0bf..f047df15 100644 --- a/docs/es/6.conventions/1.branding.md +++ b/docs/es/6.conventions/1.branding.md @@ -29,8 +29,8 @@ tipografía esté instalada en ninguna parte. | 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/` | +| Iconos del sitio | `apps/www/public/favicon.svg`, `apple-touch-icon.png` (180×180) | La pestaña del navegador | +| Logotipo en el sitio | `apps/www/public/wordmark.svg`, `wordmark-dark.svg` | `duxt.logo` en `apps/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 @@ -80,7 +80,7 @@ cortesía. `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/` +nunca este. Todos los recursos de arriba pertenecen a este repositorio y a `apps/www/` — ninguno está en el paquete publicado. :: diff --git a/docs/fr/6.conventions/1.branding.md b/docs/fr/6.conventions/1.branding.md index 2a90aad8..fed48860 100644 --- a/docs/fr/6.conventions/1.branding.md +++ b/docs/fr/6.conventions/1.branding.md @@ -29,8 +29,8 @@ police. | 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/` | +| Icônes du site | `apps/www/public/favicon.svg`, `apple-touch-icon.png` (180×180) | L'onglet du navigateur | +| Logo dans le site | `apps/www/public/wordmark.svg`, `wordmark-dark.svg` | `duxt.logo` dans `apps/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 @@ -79,7 +79,7 @@ est une courtoisie. `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/` — +jamais celle-ci. Chaque ressource ci-dessus appartient à ce dépôt et à `apps/www/` — aucune n'est dans le paquet publié. :: diff --git a/docs/pt/6.conventions/1.branding.md b/docs/pt/6.conventions/1.branding.md index cc3f9d68..1914ff0f 100644 --- a/docs/pt/6.conventions/1.branding.md +++ b/docs/pt/6.conventions/1.branding.md @@ -29,8 +29,8 @@ estar instalada em lado nenhum. | 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/` | +| Ícones do site | `apps/www/public/favicon.svg`, `apple-touch-icon.png` (180×180) | O separador do navegador | +| Logótipo no site | `apps/www/public/wordmark.svg`, `wordmark-dark.svg` | `duxt.logo` em `apps/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 @@ -79,7 +79,7 @@ ser pago, atribuído ou relicenciado — a menção acima é cortesia. `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. +`apps/www/` — nenhum está no pacote publicado. :: Definir a tua é [Dá identidade ao teu site](/guides/brand-your-site). diff --git a/package.json b/package.json index affd3e56..b4afe39e 100644 --- a/package.json +++ b/package.json @@ -1,97 +1,15 @@ { "$schema": "https://www.schemastore.org/package.json", - "name": "@kirchdev/duxt", - "version": "0.4.0", - "description": "duxt is a Nuxt layer: extend it and your docs/ folder becomes a site, with theme, search, API reference and llms.txt included. Point it at other repositories, or at tags of the same one, and each becomes a version.", - "keywords": [ - "content-v3", - "docs", - "documentation", - "duxt", - "kirchdev", - "nuxt", - "nuxt-content", - "nuxt-layer", - "nuxt-module", - "versioned-docs" - ], - "homepage": "https://github.com/kirchDev/duxt#readme", - "bugs": { - "url": "https://github.com/kirchDev/duxt/issues" - }, + "name": "duxt-workspace", + "private": true, + "description": "The duxt monorepo: the @kirchdev/duxt layer in packages/duxt, the site that develops it in apps/www, and the meta layer around both.", "license": "MIT", "author": "Titus Kirch ", "repository": { "type": "git", "url": "git+https://github.com/kirchDev/duxt.git" }, - "bin": { - "duxt": "./bin/duxt.mjs", - "duxt-cache-key": "./bin/duxt-cache-key.mjs", - "duxt-og-cache": "./bin/duxt-og-cache.mjs" - }, - "files": [ - "app", - "bruno-model.ts", - "bruno-parse.ts", - "bruno-zip.ts", - "cli.ts", - "components.json", - "content-cache.ts", - "content.config.ts", - "duxt-app-config.ts", - "frontmatter.ts", - "git-contributors.ts", - "highlight-langs.ts", - "i18n", - "mdc.config.ts", - "modules", - "nuxt.config.ts", - "og-image-cache.ts", - "openapi-model.ts", - "openapi-parse.ts", - "public", - "report.ts", - "repository-root.ts", - "search-records.ts", - "section-input.ts", - "section-markdown.ts", - "section-reports.ts", - "sections-bruno.ts", - "sections-changelog.ts", - "sections-openapi.ts", - "sections-resolve.ts", - "sections.ts", - "server", - "sources-cache.ts", - "sources-git.ts", - "sources-resolve.ts", - "sources.ts", - "tfplugindocs.ts", - "validate-report.ts" - ], "type": "module", - "main": "./nuxt.config.ts", - "exports": { - ".": "./nuxt.config.ts", - "./bruno-model": "./bruno-model.ts", - "./bruno-parse": "./bruno-parse.ts", - "./og-image-cache": "./og-image-cache.ts", - "./openapi-model": "./openapi-model.ts", - "./openapi-parse": "./openapi-parse.ts", - "./search-records": "./search-records.ts", - "./sections": "./sections.ts", - "./sections-bruno": "./sections-bruno.ts", - "./sections-openapi": "./sections-openapi.ts", - "./sections-resolve": "./sections-resolve.ts", - "./sources": "./sources.ts", - "./sources-cache": "./sources-cache.ts", - "./sources-git": "./sources-git.ts", - "./sources-resolve": "./sources-resolve.ts" - }, - "publishConfig": { - "access": "public" - }, "scripts": { "lint": "oxlint . --deny-warnings", "lint:fix": "oxlint . --fix --deny-warnings", @@ -100,93 +18,42 @@ "typecheck": "tsc --noEmit", "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 && pnpm check:images && pnpm check:keyboard && pnpm check:overflow", "check:fix": "pnpm lint:fix && pnpm format:fix", - "previews": "node scripts/build-devtools-previews.ts", - "check:previews": "node scripts/check-previews.ts", + "previews": "turbo run previews --filter=@kirchdev/duxt", + "check:previews": "turbo run check:previews --filter=@kirchdev/duxt", "check:policy": "node scripts/check-policy-parity.ts", "skills:update": "pnpm dlx skills@latest update -p -y", "taze": "taze", "taze:w": "taze -w", "prepare": "husky", - "typecheck:app": "pnpm --filter www typecheck", - "test": "vitest run", - "test:watch": "vitest", + "typecheck:app": "turbo run typecheck --filter=www", + "test": "turbo run test", "report": "pnpm --filter www exec duxt report", - "cache:key": "node ./bin/duxt-cache-key.mjs --root www", - "og:cache": "node ./bin/duxt-og-cache.mjs --root www", - "build:app": "pnpm --filter www build", + "cache:key": "node packages/duxt/bin/duxt-cache-key.mjs --root apps/www", + "og:cache": "node packages/duxt/bin/duxt-og-cache.mjs --root apps/www", + "build:app": "turbo run build --filter=www", "build:www": "pnpm --filter www run build:cf", "deploy:www": "pnpm --filter www run deploy", "publish:www": "pnpm --filter www run publish:cf", - "check:a11y": "node scripts/check-a11y.ts", - "check:seo": "node scripts/check-seo.ts", - "check:images": "node scripts/check-images.ts", - "check:keyboard": "node scripts/check-keyboard.ts", - "check:overflow": "node scripts/check-overflow.ts", - "check:adapters": "node scripts/check-adapters.ts", - "check:routes": "node scripts/check-routes.ts" - }, - "dependencies": { - "@codemirror/autocomplete": "^6.20.3", - "@codemirror/commands": "^6.11.0", - "@codemirror/lang-json": "^6.0.2", - "@codemirror/language": "^6.12.4", - "@codemirror/lint": "^6.9.7", - "@codemirror/state": "^6.7.4", - "@codemirror/view": "^6.43.11", - "@iconify-json/flag": "^1.2.12", - "@iconify-json/lucide": "^1.2.127", - "@iconify-json/simple-icons": "^1.2.94", - "@lezer/highlight": "^1.2.3", - "@lucide/vue": "^1.37.0", - "@nuxt/content": "^3.16.0", - "@nuxt/icon": "^2.5.1", - "@nuxt/image": "^2.1.0", - "@nuxtjs/color-mode": "^4.0.1", - "@nuxtjs/i18n": "^10.6.0", - "@nuxtjs/mcp-toolkit": "^0.19.0", - "@nuxtjs/seo": "^5.3.14", - "@nuxtjs/sitemap": "^8.5.0", - "@resvg/resvg-js": "^2.6.2", - "@shikijs/transformers": "^4.4.3", - "@tailwindcss/vite": "^4.3.3", - "@vueuse/core": "^14.4.0", - "citty": "^0.2.2", - "class-variance-authority": "^0.7.1", - "clsx": "^2.1.1", - "fuse.js": "^7.5.0", - "jiti": "^2.7.0", - "mermaid": "^11.17.2", - "reka-ui": "^2.10.4", - "satori": "^0.33.4", - "shadcn-nuxt": "^2.8.2", - "shiki": "^4.4.3", - "tailwind-merge": "^3.6.0", - "tailwindcss": "^4.3.3", - "tw-animate-css": "^1.4.0", - "vue-sonner": "^2.0.9", - "yaml": "^2.9.0", - "zod": "^4.5.4" + "check:a11y": "turbo run check:a11y --filter=www", + "check:seo": "turbo run check:seo --filter=www", + "check:images": "turbo run check:images --filter=www", + "check:keyboard": "turbo run check:keyboard --filter=www", + "check:overflow": "turbo run check:overflow --filter=www", + "check:adapters": "turbo run check:adapters --filter=www", + "check:routes": "turbo run check:routes --filter=www" }, "devDependencies": { "@commitlint/cli": "^21.2.2", "@commitlint/config-conventional": "^21.2.2", "@commitlint/types": "^21.2.0", - "@types/jsdom": "^30.0.0", "@types/node": "^26.4.0", - "axe-core": "^4.13.0", "husky": "^9.1.7", - "jsdom": "^30.0.1", "lint-staged": "^17.4.1", - "nuxt": "^4.5.2", "oxfmt": "0.65.0", "oxlint": "1.80.0", - "playwright-core": "^1.63.0", "taze": "^21.1.0", - "typescript": "^6.0.3", - "vitest": "^4.1.11" - }, - "peerDependencies": { - "nuxt": "^4.0.0" + "turbo": "^2.10.12", + "typescript": "^6.0.3" }, "engines": { "node": ">=24" diff --git a/app/app.config.ts b/packages/duxt/app/app.config.ts similarity index 100% rename from app/app.config.ts rename to packages/duxt/app/app.config.ts diff --git a/app/app.vue b/packages/duxt/app/app.vue similarity index 100% rename from app/app.vue rename to packages/duxt/app/app.vue diff --git a/app/assets/css/duxt.css b/packages/duxt/app/assets/css/duxt.css similarity index 100% rename from app/assets/css/duxt.css rename to packages/duxt/app/assets/css/duxt.css diff --git a/app/assets/css/typeset.css b/packages/duxt/app/assets/css/typeset.css similarity index 100% rename from app/assets/css/typeset.css rename to packages/duxt/app/assets/css/typeset.css diff --git a/app/components/DuxtAnnouncements.vue b/packages/duxt/app/components/DuxtAnnouncements.vue similarity index 100% rename from app/components/DuxtAnnouncements.vue rename to packages/duxt/app/components/DuxtAnnouncements.vue diff --git a/app/components/DuxtBrand.vue b/packages/duxt/app/components/DuxtBrand.vue similarity index 95% rename from app/components/DuxtBrand.vue rename to packages/duxt/app/components/DuxtBrand.vue index 05c3f7f5..fdcdb0b0 100644 --- a/app/components/DuxtBrand.vue +++ b/packages/duxt/app/components/DuxtBrand.vue @@ -8,7 +8,7 @@ * * THE LAYER STAYS UNBRANDED. `logo` is unset by default and the fallback is a * generic book icon beside `title` — a site extending duxt must show its own - * name, never this one. duxt's own `www/` sets `logo` like any other consumer, + * name, never this one. duxt's own `apps/www/` sets `logo` like any other consumer, * which is also what keeps the option honest: the development site exercises * the same path a stranger takes. */ diff --git a/app/components/DuxtBreadcrumb.vue b/packages/duxt/app/components/DuxtBreadcrumb.vue similarity index 100% rename from app/components/DuxtBreadcrumb.vue rename to packages/duxt/app/components/DuxtBreadcrumb.vue diff --git a/app/components/DuxtBrunoEntries.vue b/packages/duxt/app/components/DuxtBrunoEntries.vue similarity index 96% rename from app/components/DuxtBrunoEntries.vue rename to packages/duxt/app/components/DuxtBrunoEntries.vue index c8d97187..91618711 100644 --- a/app/components/DuxtBrunoEntries.vue +++ b/packages/duxt/app/components/DuxtBrunoEntries.vue @@ -1,5 +1,5 @@