Skip to content

Restructure into a monorepo (Turborepo) for the provider layers #86

Description

@TitusKirch

Before you start

  • I searched existing issues and didn't find a duplicate

What problem are you trying to solve?

#74 and #75 each record the same decision: the official provider ships as an independently published companion layer in its own repository (@kirchdev/duxt-typesense, @kirchdev/duxt-meilisearch). Neither repository exists, and in this estate a new open-source repo is not one gh repo create away — it is provisioned from the infrastructure repo's OpenTofu (Discord forum, CodeQL/GHAS, branch protection) and then carries the full meta layer of its own: workflow stubs, release-please, Dependabot, CodeQL, LICENSE, README, .tituskirch-skills.json. That is the standing cost of two thin adapters over exactly one build-time hook.

Meanwhile the contract both consume — duxt:search:records, a frozen readonly DuxtSearchRecord[], landed by #16 as 059cd6a — lives here, in search-records.ts. Split across three repositories, every change to it becomes a cross-repo release dance: bump the layer, publish, widen each provider's peer range, publish. Nothing along that path proves the contract still holds for its consumers, because pnpm check here exercises the hook against no consumer at all.

Three consequences follow, and they are three different problems:

  • The meta layer is duplicated per provider — every one of them maintained, bumped and reviewed separately, for packages whose whole content is one hook subscription.
  • A contract change cannot be verified against its consumers in any single gate. The hook's own guardrail already says a second hook needs a consumer that cannot be written without it; today the first two such consumers would live where this repo's checks cannot see them.
  • www/ cannot dogfood a provider without depending on a published version of it, so the development site — the thing that catches SSR failures here — never exercises the extension surface.

Proposed solution

Host the provider layers in this repository as workspace packages, still published independently. Two halves, and only the first is required to unblock #74 and #75:

1. Workspace and release (required). pnpm-workspace.yaml already lists . and www; it gains packages/*. release-please moves to per-package manifest mode (include-component-in-tag: true, one entry per package) so @kirchdev/duxt-typesense@0.1.0 and @kirchdev/duxt@0.2.0 tag and publish on their own cadence, and the publish job in release-please.yml becomes a matrix over whatever was released.

2. Turborepo (the open half). With four packages each running lint, typecheck, test and build, a task graph with caching stops being ceremony. pnpm check would call turbo run check, preserving this repo's rule that CI runs whatever check chains.

The root stays the layer. The cheapest shape puts packages/* beside today's root package rather than moving the layer into packages/duxt. CLAUDE.md's standing warning applies here with full force — "Nothing layer-relative resolves the way it reads": the layer() helper resolving against import.meta.url, the collection cwd computed as join(process.cwd(), 'docs'), the @duxt alias and the 37-entry files allowlist would every one of them need re-verifying if the layer moved. Not moving it costs nothing and avoids all of that.

Open — to be settled by this issue, not assumed:

  • Does Turborepo earn its place, or do pnpm workspaces alone suffice? The measured expensive phase in this repo is www/'s prerender (117–190 s, already cached per image through og-image-cache.ts) — which turbo's cache does not touch. Answer it with a number: pnpm check wall-clock before and after, at four packages. The first half above lands either way.
  • turbo against the permission policy. CLAUDE.md already names pnpm exec turbo run as an example of a rule that must never be allow-listed, because it runs arbitrary code. Adoption means the command stays in the prompting middle state, and .codex/rules/default.rules has to agree — pnpm check:policy enforces that parity.
  • Whether www/ takes a provider as a devDependency to dogfood it, and whether that pulls a Typesense or Meilisearch SDK into the deploy. It must not reach @kirchdev/duxt's own dependencies.
  • Dependabot — one ecosystem entry per package directory, grouped as the existing ones are.

Alternatives considered

  • Two new repositories, as Add a Typesense provider layer #74 and Add a Meilisearch provider layer #75 record. Rejected as the default here, not as impossible: it is the shape both issues assumed, and it keeps each provider's releases and issues fully separate. The price is the full meta layer twice over plus OpenTofu provisioning per repo, for adapters that exist only to consume a hook this repo owns — and a contract no single gate can verify end to end.
  • pnpm workspaces without Turborepo. Unblocks Add a Typesense provider layer #74 and Add a Meilisearch provider layer #75 on its own and is strictly less to maintain. This is the fallback if the number above does not justify turbo, and this issue is cut so that half can land alone.
  • Nx instead of Turborepo. More capable, considerably more opinionated, and built around a generator model this repo has no use for.
  • Optional exports of @kirchdev/duxt (a ./typesense subpath). Explicitly rejected by both Add a Typesense provider layer #74 and Add a Meilisearch provider layer #75, and it would put the SDKs in the layer's own dependency graph — precisely what both decisions exist to prevent.
  • Leave the providers unofficial. Then consumers rebuild the source, version, locale and generated-section logic that the hook exists to hand them.

Additional context

Decided (refinement pass, 2026-09-15):

  • Turborepo is adopted. Supersedes the open point "Does Turborepo earn its place" and the pnpm-workspaces-only fallback above; no before/after number is required. turbo stays out of the permission allow list, as CLAUDE.md already requires.
  • The layer moves, and the root stops being a package. @kirchdev/duxt moves to packages/duxt, the development site to apps/www, and the repository root keeps only workspace and meta configuration (turbo.json, pnpm-workspace.yaml, lint/format, husky, .github, agent files) plus docs/. Supersedes "The root stays the layer" above.
  • The root-folder cleanup is part of this issue. The flat build-time modules (bruno-*.ts, openapi-*.ts, sources-*.ts, sections-*.ts, search-records.ts and the rest) sit in no package root: they move into topic folders inside packages/duxt (for example build/bruno/, build/sources/); exact names are the implementer's. The files allowlist shrinks accordingly. Every layer-relative path — layer(), content.config.ts's process.cwd(), repository-root.ts, the @duxt alias, the exports map — is re-verified by pnpm check, a Workers build with check:routes, and an npm pack of the layer installed into a scratch consumer.
  • Structure only. No provider package is scaffolded here; packages/duxt-typesense and packages/duxt-meilisearch land with Add a Typesense provider layer #74 and Add a Meilisearch provider layer #75, and the open points on www/ dogfooding a provider and per-package Dependabot entries move with them.
  • Independent versions, and a package without changes is never released. Each published package is its own release-please unit with its own version and changelog; a release publishes only the packages it bumped. No empty release of any package, ever — which is why a shared version was rejected: it republishes unchanged packages under a new number.
  • Every package tags <name>@vX.Y.Z, the layer included — "tag-separator": "@" with component tags, so duxt@v0.4.0 beside duxt-typesense@v0.1.0. The version itself stays a bare number in package.json and on npm. The existing v0.1.0…v0.3.4 tags stay as they are; set last-release-sha once so release-please finds v0.3.4 as the layer's previous release despite the new pattern.
  • Also part of this issue: duxt's tag-based versioning learns prefixed tags. Today parseTag in sources-git.ts, the checks in sources-resolve.ts and app/utils/version-choices.ts accept only v?X.Y.Z and silently skip anything else, so after the first duxt@v… tag www/'s latest would stay on v0.3.4 without any build failing. Wanted: <name>@vX.Y.Z tags are recognised by latest, the release selection and the switcher's sorting; a source can restrict itself to one component's tags, since a monorepo has several; the label and the URL segment show v0.4.0, never duxt@v0.4.0; plain vX.Y.Z tags keep working unchanged. It is a consumer feature as much as this repo's need — every release-please monorepo tags this way — and it must be merged before the first release after the move, not after.
  • Providers peer-depend on @kirchdev/duxt with a wide range, >=0.4.0 <1 (the lower bound being whatever the provider first needs), never ^0.x: under 1.0 a caret would force every provider to release on every layer minor. The monorepo gate is what proves compatibility; the lower bound rises only when the hook contract changes incompatibly, as a feat! on the provider.
  • Turborepo's cache is local only. No remote cache — no Vercel account, no token secret. Whether CI caches .turbo through actions/cache is the implementer's call, backed by a measurement.
  • ADR 0011 records the outcome — the monorepo layout, independent versions with <name>@vX.Y.Z tags, and that it supersedes the "own repository" decision in Add a Typesense provider layer #74 and Add a Meilisearch provider layer #75. The "beside 0001–0009" line above is stale: 0010 exists.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions