Skip to content

refactor(architecture): move every domain into its own Nuxt layer - #330

Merged
TheMeinerLP merged 27 commits into
mainfrom
feat/layer-architecture
Sep 7, 2026
Merged

refactor(architecture): move every domain into its own Nuxt layer#330
TheMeinerLP merged 27 commits into
mainfrom
feat/layer-architecture

Conversation

@TheMeinerLP

Copy link
Copy Markdown
Collaborator

Every domain in this repository was spread across three to seven top-level
directories. Changing "team" meant touching components/features/team/,
components/features/home/team/, three composables, two utils, a type file and
a Nitro route. Files of the same kind sat together; files that change
together did not.

The dependency direction was never the problem — all content composables
already went through one ContentRepository interface, and the tree held a
single cross-domain import. Only the layout contradicted the architecture. This
branch moves the sources to match it, and pins the result with tests so it
cannot erode again.

What changed

Ten Nuxt layers under layers/: base, content-core, blog,
community-poi, team, home, sponsoring, opencollective, navigation,
footer. components/ now holds only the OG-image template.

root (pages, layouts, app.vue)   knows every layer, orchestrates
  -> domains                     do NOT know each other
    -> content-core              knows base only
      -> base                    knows nothing

94 of the 189 changed files are pure renames. Two components were deliberately
rewritten and nothing else changed behaviour.

Two pre-existing mistakes this surfaced

  • TeamMemberCard.vue lived under features/home/team/ while home never
    rendered it and its only consumer was TeamRankSection in team. It was the
    tree's sole cross-feature import — a filing mistake, not a dependency. Moving
    it deleted the import rather than exempting it.
  • FeaturedTeamMembers.vue reached into team three times. The page now
    resolves the members and passes finished presentation data down, so the blog
    layer names nothing from another domain.

ALLOWED_CROSS_LAYER is empty, and that is the property worth defending.

Enforcement

Nuxt enforces none of this — layers auto-import each other freely — so two new
suites are the enforcement, not a description of it:

  • tests/architecture/module-boundaries.spec.ts — dependency direction,
    @nuxt/content confinement, the deep-import ban across all three alias
    spellings, and the public-API rule.
  • tests/architecture/layer-name-collisions.spec.ts — layer names produce no
    auto-import prefix, so two layers defining one component name resolve
    silently by priority. This catches that.

Both were written before anything moved, pass against the untouched tree,
and each carries a self-test, because a check that is vacuous today and
silently stays vacuous is worse than none.

A red assertion in either means the architecture broke, not that the test needs
relaxing.

Verification

  • 177 tests across 55 files (161 before)
  • nuxi build exit 0 — mandatory throughout: the suite reads sources as text
    and never resolves a module graph, so it is structurally blind to broken
    relative imports. Three were caught only by Rollup.
  • pnpm quality passes: ESLint 317 against a baseline of 319, TypeScript at
    baseline. quality-baseline.json untouched.

Known follow-ups

  • Cross-layer coupling through auto-imports is checked in one hard-coded file
    only.
    Nuxt provides composables with no import statement, so no path matcher
    sees them. Closing this properly needs its own design.
  • footer (1 file), opencollective (3) and sponsoring (4) do not really
    earn a boundary. Merging layers later is cheap.
  • layers/content-core/utils/content/repository.ts now owns every domain's CMS
    document type, so a schema change in any domain edits one shared file.
  • A value export added to content-core/index.ts that transitively imports
    collections.ts breaks the client build (Rollup impound). Documented in
    AGENTS.md, enforced by nothing.

Design and plan: docs/superpowers/specs/2026-09-07-layer-architecture-design.md,
docs/superpowers/plans/2026-09-07-layer-architecture.md.

Every domain is currently spread across three to seven top-level
directories; changing "team" touches nine of them. The dependency
direction is already sound — all content composables go through one
ContentRepository interface, and the tree holds a single cross-domain
import — so the layout is what contradicts the architecture, not the
code.

The design moves the sources into ten Nuxt layers and pins the
boundaries with two new architecture tests written before anything
moves.

Three constraints were verified against Nuxt 4.4.8 rather than assumed:
a layer may hold an app/ directory without flipping the root srcDir,
the flat layer layout auto-imports correctly, and layer names produce
no auto-import prefix — the last of which makes silent component
collisions possible and is why a collision test ships in step 1.

Also records two corrections the migration makes: TeamMemberCard.vue is
misfiled under home/ with its only consumer in team/, and
FeaturedTeamMembers.vue reaches into another domain where the page can
pass the data down instead.
Thirteen tasks, each ending in a commit whose full test suite passes, so
a regression is always attributable to exactly one of them. Order runs
small to large — footer's single file exercises the whole pattern,
including the tag rename, before community-poi's seventeen do.

Task 1 writes both boundary suites against the untouched tree. They pass
with zero layers and start enforcing at the first one, which is why each
carries a self-test: a check that is vacuous today and silently stays
vacuous is worse than none.

Reading the sources while planning turned up one thing the spec had
wrong. FeaturedTeamMembers names three things from the team domain, not
one — useTeamRoster, teamAvatarUrl and toRoleString — so passing
TeamMember[] down would have left two of them in place plus a type
import across the boundary. Task 12 gives the component its own view
type and finished presentation data instead.
The migration ahead moves every domain into its own Nuxt layer. Nuxt
enforces no boundary between layers — they auto-import each other's
composables freely — so the rule has to be a test or it is nothing.

Both new suites pass against the untouched tree and start enforcing the
moment the first layer appears. Each carries a self-test, because a
check that is vacuous today and silently stays vacuous is worse than no
check at all.

The collision suite exists because layer names produce no auto-import
prefix: two layers defining the same component name resolve silently by
priority, and the loser is unreachable with no error anywhere.
…ctly

The collision suite's self-test re-implemented the grouping logic
inline instead of calling collisions(), and the boundary suite's
self-test hard-coded a copy of the #layers/ regex instead of calling
referencedLayers(). Either way, a regression in the real matcher could
leave its self-test green, which is the exact failure mode these guards
exist to rule out.

Split collisions() into a pure collisionsIn() and referencedLayers()
into a pure referencesIn(), so both self-tests call the same code the
real checks run. The boundary self-test now also covers all three
patterns the matcher recognises (#layers/, ~~/layers/, ../../), not
only the first.

Also reordered a doc comment in unused-components.spec.ts that ended up
describing the wrong function after isLayerComponent() was inserted
above it.
First layer, and the one with nothing underneath it - chosen to settle
the mechanics (nuxt.config.ts, index.ts, rewritten imports) on six files
before applying them to seventeen.

The buttons/, icons/ and typography/ subdirectories are flattened: they
existed only to shape the auto-import prefix, and a layer produces no
prefix at all.

Chip.vue was imported under the local alias UiChip in five files; since
the layer produces no prefix, its auto-imported tag is <Chip>, not
<UiChip>, so those five templates are renamed rather than repointed at
a deep import into the layer's internals (which module-boundaries.spec.ts
forbids). All other importers had their now-redundant path import
deleted and kept their unchanged tag.

tests/architecture/unused-components.spec.ts and prop-reactivity.spec.ts
are updated for the move: the "renamed import" self-test example moves
off Chip.vue (now auto-imported bare) onto Footer.vue, and a new
assertion checks that a real layer component gets no directory prefix.
Holds the ContentRepository interface, its @nuxt/content adapter, the
Prose overrides and page-level SEO. From here on it is the only layer
permitted to name @nuxt/content, which module-boundaries.spec.ts now
enforces — until this commit the boundary was a comment and nothing
checked it.

content.config.ts stays at the root: its collections are generated
across locales and splitting them per layer is a separate migration.
…coverage

module-boundaries.spec.ts's "reaches into no other layer past its index"
rule matched only the #layers/ alias, never ~/layers/ or ~~/layers/, so
server/api/__sitemap__/team.ts deep-importing
~/layers/content-core/utils/content/locales stayed invisible. Extend the
rule to all three alias spellings, pin each with a self-test, and widen
its scan to root consumer code (not just layer-internal files), since a
root file reaching past a layer's index is the same defect as one layer
doing it to another.

Tried routing the sitemap import through #layers/content-core instead;
nuxi build proved Nitro's impound plugin refuses it (the barrel pulls in
@nuxt/content via useContentRepository). Kept the deep import, documented
why at the call site, and registered it as the one named exception in
ALLOWED_DEEP_IMPORTS rather than leaving it unchecked.

Separately, 23 test files hard-wired a directory list that predates
layers/ and had quietly stopped covering code that moved there. Added
'layers' to the 21 whose list means "all our components / composables /
utils"; left the 2 correctly scoped to pages/layouts, which never live
in a layer by this architecture's own rule.
Smallest domain, moved first: it exercises the whole pattern including
the tag rename from <FeaturesFooter> to <Footer>, which every remaining
layer repeats, at a cost of one file if the pattern is wrong.
The sponsors collection document type stayed content-coupled: it names
@nuxt/content, which only content-core may do, and it is the return type
of the ContentRepository interface content-core already owns. It now
lives in content-core's repository.ts and is re-exported from the
layer's public API; the sponsoring layer imports it from there and
derives its own plain SponsorEntry shape, keeping the dependency
pointed the allowed way (sponsoring -> content-core, never the reverse).
The CMS-derived BlogArticle/BlogAuthorProfile types stay in content-core
beside the ContentRepository methods that return them, since only
content-core may name @nuxt/content; layers/blog/types.ts re-exports them
from content-core's public API instead of the deleted types/blog.ts.

FeaturedTeamMembers.vue comes along unchanged; its call into the team
domain is resolved in a later commit, once that layer exists.
"only content-core names @nuxt/content" searched for the substring
anywhere in a file, so it flagged comments describing the boundary as
readily as a real dependency. Narrow it to an actual import/require
form (CONTENT_MODULE_IMPORT), including subpath imports, and pin both
directions with a self-test.

Restores the three useBlogContent.ts comments this broke back to their
original wording, and un-euphemises the same defect in
sponsoring/types.ts and blog/types.ts, both of which said "the content
module" instead of naming @nuxt/content to satisfy the old check.
Brings TeamMemberCard.vue with it. It sat under features/home/team/
while home never rendered it and its only consumer was TeamRankSection
here — the single cross-feature path import in the tree, and a filing
mistake rather than a dependency. Moving it deletes that import instead
of exempting it.

The CMS-derived TeamDocument type moves into content-core's
repository.ts beside SponsorsDocument/BlogArticle, following the same
precedent as blog and sponsoring: only content-core may name
@nuxt/content, and layers/team/types.ts imports the type from
content-core's public index and derives TeamMember/TeamRank from it.

server/api/__sitemap__/team.ts stays at the repository root rather than
moving into layers/team/server/. Verified empirically that Nuxt does
merge a layer's server/ directory — moving the file there still
registered the Nitro route in nitro-routes.d.ts — but doing so trips
two other module-boundary rules with no legitimate exemption path: the
route's queryCollection() import names @nuxt/content directly (allowed
at the repository root, forbidden inside any non-content-core layer),
and its deep import into content-core/utils/content/locales has an
exemption only for root-consumer files, not for files inside a layer's
own tree. Rather than growing the exemption list or weakening either
check, the route stays at root and now derives its local
TeamDocument/TeamMember shapes straight from @nuxt/content, matching
the exception it already carries for the locales import.
server/api/__sitemap__/team.ts had grown local copies of TeamDocument
and TeamMember rather than importing them, on the assumption that any
reach into layers/team needed the same runtime-import workaround as
the @nuxt/content and locales imports beside it. That reasoning does
not apply to a type-only import: `import type` is erased at compile
time and adds no runtime module edge for Nitro's impound plugin to
trip over, which is exactly why the pre-task `~/types/team` import
worked here. Import both types from '#layers/team' (the bare index,
not a deep path) instead of keeping a second, driftable definition.
Moves the carousel, server concept, server addresses and FAQ section
components and composables from components/features/home and
composables/ into layers/home, following the established layer shape.

useFaqContent comes here rather than into a faq layer of its own: FAQ
is a mechanism shared by two pages, not a domain that owns one.

The CMS-derived ServerConceptDocument/ServerConnectDocument/
HomeCarouselDocument types move into content-core's repository.ts
(the only layer allowed to name @nuxt/content) instead of being carried
over verbatim from types/home.ts; layers/home/types.ts re-exports them
and derives the plain ServerConceptPoint/HomeCarouselSlide shapes.

tests/architecture/unused-components.spec.ts gains a fifth detection
path: a component imported by a same-directory relative path and handed
to <component :is="..."> under its own identifier (Carousel.vue's item
components) is now resolved against the consuming file's real
directory instead of matched as text, since a bare relative specifier
carries none of the path the existing "imported by path" pattern
matches on. Its FaqSection.vue example (now flattened into the layer,
losing its multi-segment directory prefix) is replaced with
CommunityPoiBluemap.vue, which still demonstrates the prefixed-tag case.
…erences

Detection path 5 in unused-components.spec.ts marked a component
referenced the moment any relative .vue specifier resolved to it,
without checking that the imported binding was ever used. A stale
import would have kept a dead component alive undetected, and since
nearly all intra-layer composition is now same-directory relative
imports, this was a standing blind spot across every layer.

Capture the local binding alongside the specifier and only count a
match once that identifier appears in the file outside the import
statement itself. Also drop the .vue extension coercion: only
specifiers that already end in .vue are considered, instead of
guessing a nonexistent path for others.
… layer

Last domain to move, and the largest. components/ now holds only the
OgImage template, which nuxt-og-image resolves by name.

The CMS-derived document type stays in content-core as
CommunityPoiDocument, next to the repository methods that return it;
community-poi/types.ts imports it and derives the plain shapes its
composable and components work with via indexed access. home's carousel
composable used to reach into the old ~/types/community-poi for the
status sort order — since that would now be a domain-to-domain import,
it keeps its own small copy instead.

The unused-components self-test for a tag carrying Nuxt's directory
prefix (path 2) is rebuilt on a synthetic corpus: after this move,
nothing under components/ carries a directory prefix for a real file to
anchor it on.
layers/community-poi/types.ts and layers/home/composables/useHomeContent.ts
each carried their own copy of the featured-POI status ordering, typed
with different safety properties: community-poi's version made adding a
status a compile error, home's Record<string, number> let the same
addition compile clean and silently sort to the end. Two domains needing
one ordering is the signal it belongs in content-core, not duplicated.

COMMUNITY_POI_STATUS_ORDER now lives beside CommunityPoiDocument in
content-core, typed against the document's own status field so the two
cannot drift apart; both domains import the same value.

That import is the first genuine value import (not type, not an
auto-imported composable) of content-core's barrel index anywhere in the
codebase, which exposed a latent build break: the barrel re-exported
locales/Locale via collections.ts, which also imports @nuxt/content's
defineCollection for its build-time factories, and pulling that into the
client bundle is refused by Rollup's impound plugin. Repointed those two
exports at utils/content/locales.ts instead, which exists for exactly
this reason.
…nto team

FeaturedTeamMembers named three things from the team domain, not one:
useTeamRoster, teamAvatarUrl and toRoleString. Passing TeamMember[] down
would have fixed only the first and kept the type import besides, so the
component now declares its own view type and receives finished
presentation data.

A blog article showing team members is a real product fact; the coupling
was not. The page is the root and may know both layers.

The v-if moves to the resolved array: a slug matching no member was
filtered out downstream, so the old condition could render an empty
bordered section.

Leaves the boundary test's exception map empty, which is the state worth
defending - the first entry is what makes the second one easy.
The boundary guard for FeaturedTeamMembers.vue matched raw file text, so
it fired on an explanatory comment naming useTeamRoster/teamAvatarUrl/
toRoleString/TeamMember as easily as on real usage - the same defect
Task 8 already fixed once for the @nuxt/content check, which was
narrowed to real import forms with a self-test pinning that a comment
must not be flagged.

These three names are auto-imported, so there is no import clause to
anchor on the way the content check does. Strip line, block and HTML
comments from the text before matching instead, uniformly covering all
four names including TeamMember, which has no call form to anchor on.

Adds a self-test in the same shape as the @nuxt/content one, asserting
both directions: a comment-only mention is not flagged, real usage is.
Restores the FeaturedTeamMembers.vue doc comment to name TeamMember
directly, since the reason it was reworded no longer applies.
AGENTS.md listed components/blog, components/ui and components/sections.
None of the three existed before this migration either -- the document
had drifted far enough to mislead anyone, human or agent, who trusted it.

Records the dependency rule and, more importantly, where it is enforced:
Nuxt allows every import this forbids, so the architecture tests are the
only thing holding it. Also documents two build-time traps found during
the migration: a value export from a layer's index.ts that reaches
@nuxt/content breaks the client build via the impound plugin, and Nitro
does not resolve #layers/<name> value imports, which is why the team
sitemap route stays at the repository root with a registered exception.

Corrects every remaining components/base, components/features and
components/content path in README.md and the .claude/skills/ knowledge
base to its real layers/<domain>/ location, since those files are loaded
as the reference for future sessions and a stale path there actively
misleads. Marks the layer-architecture spec implemented.
…oduced

pnpm quality was silently red across Tasks 1-12: baseline is 319 ESLint
errors / 21 TypeScript errors, the branch had drifted to 349 / 22. Traced
every point of the delta back to its cause rather than reformatting
whatever eslint flagged:

- The two new architecture suites (module-boundaries.spec.ts,
  layer-name-collisions.spec.ts) accounted for 19 raw errors, mostly
  max-len from the long explanatory comments; rewrapped by hand where
  --fix could not, without shortening the explanations.
- event-listeners.spec.ts, unused-components.spec.ts and
  breadcrumb-urls.spec.ts each picked up a stray formatting error from
  the migration's edits to them; autofixed.
- pages/blog/[...slug].vue's new featuredMembers computed (Task 12) had
  both a real type error (TeamMember.slug is optional; FeaturedMember.slug
  is not) and a max-len line; fixed the narrowing and rewrapped.
- Flattening components into layers/<domain>/components/ drops the
  directory-derived name prefix Nuxt used to give nested components,
  which silently turned five single-word names (Chip, Top1, Footer,
  Carousel, Sponsoring) into new vue/multi-word-component-names
  violations. Renaming them would ripple into every call site for a
  cosmetic reason, so eslint.config.mjs scopes a named, explained
  exception to layers/*/components/ instead.

Every other file eslint still flags in this diff's working set (pages/
team/[slug].vue, pages/index.vue, layouts/default.vue, five more max-len
lines in the blog slug page, one no-explicit-any in
content-core/repository.ts) carries a violation that already existed at
its old path before the migration moved or merged it there — confirmed by
diffing each flagged line against the pre-migration blob. Left those
alone: this fixes what the migration broke, not what it inherited.

pnpm quality now reports 317/36/21 against baseline 319/36/21 - an
improvement, left unrecorded per instruction not to touch
quality-baseline.json.
- client-env-access: scan layers/ too, not just the pre-migration root
  dirs, which now hold three files total
- module-boundaries: match any number of leading ../ (not just two) so
  a layer's root files can't reach a sibling layer undetected; guard
  the base/content-core check against non-layer directory names the
  widened pattern can now also match (e.g. content-core's own utils/)
- module-boundaries: also flag @nuxt/content's auto-imported query
  functions (queryCollection and its three siblings) outside
  content-core, since #imports bypasses the existing from-clause check
- layer-name-collisions: compare layers against root components/,
  composables/ and utils/ too, since Nuxt resolves root files before
  ~~/layers and a root file silently shadows a layer's component

Each fix was verified by reproducing the exact violation it now
catches, confirming the suite fails and names the offender, then
reverting.
pages/blog/[...slug].vue called useTeamRoster() unconditionally, so
every article issued the team content query and shipped the whole
roster in its SSR payload, even though FeaturedTeamMembers only ever
rendered for articles with teamMembers frontmatter (none currently
have any).

useTeamRoster() now takes an optional reactive `enabled` flag; the
page passes a computed based on blog.value?.teamMembers?.length so the
fetch is skipped for the common case. The team page's own
useTeamRoster() call is unaffected (enabled defaults to true), and the
v-if on featuredMembers.length is unchanged.

Verified with nuxi dev: an article with teamMembers temporarily added
still renders the section, and the SSR payload for one without carries
no roster data.
Line-length and array-element-newline fixes for the collision and
module-boundary checks added in the previous commit, so the quality
gate's ESLint count does not rise above baseline.
…ponsorSchemaEntry

Footer.vue collides with the <footer> element by name — exactly the
hazard vue/multi-word-component-names exists to catch — so unlike the
other four single-word components that keep their cosmetic ESLint
exception (Chip, Top1, Carousel, Sponsoring), this one is renamed
instead and dropped from the exception list. Its one call site
(layouts/default.vue) is updated.

useSponsorSchema.ts declared its own SponsorEntry interface alongside
the content-derived one in layers/sponsoring/types.ts, so auto-import
would offer the wrong type at any new call site. Renamed to
SponsorSchemaEntry to disambiguate; neither of its two call sites
named the type directly, so nothing else changes.
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Sep 7, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
launchpad 9f2d1af Commit Preview URL

Branch Preview URL
Sep 07 2026, 03:17 PM

@TheMeinerLP
TheMeinerLP force-pushed the feat/layer-architecture branch from a964844 to 9f2d1af Compare September 7, 2026 15:14
@TheMeinerLP
TheMeinerLP merged commit c922ba8 into main Sep 7, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant