ReviewOS

stacks/stx

Site builder globs pagesDir/**/*.stx with no exclusion: a layoutsDir nested inside pagesDir publishes layout shells as public pages and sitemap entries

#1821
Closed glennmichael123 opened this 11 days ago ·
11 days ago

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.html

Both 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.

11 days ago

Fixed in 4e12377d5b. Layouts, partials and components under pagesDir are excluded by default.

Two details beyond the report:

  • The exclusion is on DIRECTORY segments, so views/components.stx — a page about components — still publishes. Matching on the path substring would have been a new silent failure in the opposite direction.
  • It logs a count. Adding another silent rule to fix a silent rule seemed like the wrong trade; the build now says how many files it skipped and why.

Also picked up the file-router's leading-underscore convention, which that glob ignored too — views/_draft.stx was being published on the same path.

There's an excludeDirs escape hatch ( publishes everything) since someone may genuinely want a flat directory.

Sign in to comment on this issue.