refactor(architecture): move every domain into its own Nuxt layer - #330
Merged
Conversation
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.
Deploying with
|
| 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
force-pushed
the
feat/layer-architecture
branch
from
September 7, 2026 15:14
a964844 to
9f2d1af
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 anda 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
ContentRepositoryinterface, and the tree held asingle 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.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.vuelived underfeatures/home/team/whilehomeneverrendered it and its only consumer was
TeamRankSectioninteam. It was thetree's sole cross-feature import — a filing mistake, not a dependency. Moving
it deleted the import rather than exempting it.
FeaturedTeamMembers.vuereached intoteamthree times. The page nowresolves the members and passes finished presentation data down, so the blog
layer names nothing from another domain.
ALLOWED_CROSS_LAYERis 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/contentconfinement, the deep-import ban across all three aliasspellings, and the public-API rule.
tests/architecture/layer-name-collisions.spec.ts— layer names produce noauto-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
nuxi buildexit 0 — mandatory throughout: the suite reads sources as textand never resolves a module graph, so it is structurally blind to broken
relative imports. Three were caught only by Rollup.
pnpm qualitypasses: ESLint 317 against a baseline of 319, TypeScript atbaseline.
quality-baseline.jsonuntouched.Known follow-ups
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) andsponsoring(4) do not reallyearn a boundary. Merging layers later is cheap.
layers/content-core/utils/content/repository.tsnow owns every domain's CMSdocument type, so a schema change in any domain edits one shared file.
content-core/index.tsthat transitively importscollections.tsbreaks the client build (Rollup impound). Documented inAGENTS.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.