The static site builder treats every .stx file under pagesDir as a page, with no
exclusion for layouts, partials or components. Apps that nest those inside the pages
directory — which the config actively encourages — publish their layout shells as public
routes and list them in the sitemap.
Code
packages/stx/src/site-builder/build.ts:
49: const pagesDir = options.pagesDir ?? 'pages'
58: const glob = new Glob(`${pagesDir}/**/*.stx`)
59: const entrypoints = await Array.fromAsync(glob.scan('.'))Every match becomes a Bun.build entrypoint (63-68). Every emitted .html is then
collected at line 82 (listFiles(outDir).filter(f => f.endsWith('.html'))) and fed to the
sitemap generator.
Why it bites
layoutsDir is routinely a subdirectory of pagesDir, because that is the natural
layout. A real config:
root: 'resources',
pagesDir: 'views',
layoutsDir: 'views/layouts',views/**/*.stx therefore matches views/layouts/*.stx. Confirmed output from that app:
$ ls dist/layouts/
default.html
marketing.htmlBoth are layout shells — @yield('content') with no content — served at /layouts/default
and /layouts/marketing, and eligible for the sitemap. A crawler that follows the sitemap
indexes two empty pages, and anyone who requests the URL gets a broken half-rendered
document.
Nothing in the config flags the overlap; it is a silently valid arrangement that silently publishes.
Suggested direction
The builder already knows layoutsDir, partialsDir and componentsDir from the same
options object it reads pagesDir from. Excluding them from the scan is the direct fix:
const excluded = [options.layoutsDir, options.partialsDir, options.componentsDir]
.filter(Boolean).map(d => path.resolve(d))
const entrypoints = (await Array.fromAsync(glob.scan('.')))
.filter(f => !excluded.some(d => path.resolve(f).startsWith(d + path.sep)))Two smaller points worth deciding alongside it:
- A leading-underscore or
_-prefixed convention would let apps opt individual files out without moving them, which is the usual escape hatch. - Whatever the exclusion rule ends up being, the sitemap collection at line 82 should use the same list rather than re-globbing the output directory — otherwise the two can drift and a file excluded from building can still be advertised.
A test with layoutsDir nested inside pagesDir asserting the layout is not among the
entrypoints would pin this; the current tests presumably use the default sibling layout,
where the bug is invisible.