Summary
stx.config / config/ui.ts is consumed by two independent loaders that disagree about what a relative directory key means. loadStxConfig() runs resolveStxRoot() and then prefixes componentsDir / layoutsDir / partialsDir with root a second time; bun-plugin-stx's serve() reads the same file through bunfig and uses the literal strings. Because root is inferred from the filesystem when it is not set, a perfectly ordinary Stacks layout resolves partialsDir: 'resources/partials' to resources/resources/partials on the build path and to resources/partials on the serve path. The failure is silent at 200 OK — the include just fails and the error text is rendered into the page.
Impact (real app)
analyticshq (config/ui.ts:9-31) carries a 23-line comment block titled "THIS FILE IS READ BY TWO LOADERS THAT DO NOT AGREE", and config/ui.ts:37 pins root: '.' for one reason only:
Because Loader A prefixes and Loader B does not, a relative directory value can only be correct in both when
rootis'.'. That is why root is pinned below and every path is written project-root-relative.
Before it was pinned, root was inferred as resources from the mere existence of resources/layouts, and every template dir resolved to resources/resources/*. The damage is still visible in the committed build output — dist/index.html (and 5 sibling pages) ship this as page content:
include error: Error loading include file site-nav: ENOENT: no such file or directory,
open '/…/resources/resources/partials/site-nav.stx'That was 54 failed @include('site-nav'|'site-footer') calls across the marketing pages. Every remaining key in config/ui.ts now carries a note about which of the two loaders it is actually for (Dev/prod-INERT, Render-inert on the live paths, LIVE on every top-level render).
Evidence in stx
Loader A — packages/stx/src/config.ts
:407resolveStxRoot(configRoot, configPagesDir, cwd):423-425auto-detects{ root: 'resources', pagesDir: 'views' }wheneverresources/viewsandresources/layoutsboth exist — i.e. on every Stacks app that has not setroot:532-534writes that inferred root back onto the config:535-547prefixespartialsDir,componentsDir,layoutsDirwith it — including values that already contain the root
Loader B — packages/bun-plugin/src/serve.ts
:845-853loadConfig({ name: 'stx', alias: ['ui'], cwd: process.cwd(), defaultConfig: defaultStxConfig, … })— bunfig's raw object;resolveStxRootis never called:890-893const componentsDir = options.componentsDir ?? stxConfig.componentsDir ?? defaultStxConfig.componentsDir(and the same for layouts/partials/public) — the literal string
warnMissingDirs() (config.ts:449-466) is a good mitigation but only fires on Loader A, and only when the doubled path does not happen to exist.
Prior art
Vite's resolveConfig() returns a ResolvedConfig whose root is already absolute and whose directory options are resolved against it exactly once; every plugin receives that object, and no second consumer re-prefixes. Nuxt normalizes nuxt.options.dir / srcDir inside loadNuxtConfig before any module runs. Neither framework has an inference step that can silently double a path.
Ask
- Expose a single
resolveStxConfig()whose output is the only thing any consumer sees — dev server, prod server, SSG, store loader, composable loader, crosswind — withrootabsolute and every*Dirnormalized to an absolute path. - Make
bun-plugin-stx'sserve()call that instead of bunfig directly, so there is one resolution pass rather than two. - Until (1) lands: make the prefixing at
config.ts:535-547idempotent — do not prefix a value that already starts withroot. - Make the
rootinference atconfig.ts:423-425non-silent: log the inferred value once, since it changes the meaning of every other directory key in the file.
Related: #1792