ReviewOS

stacks/stx

SSG ignores partialsDir/componentsDir, and a failed @include renders an error banner into the page instead of failing the build

#1921
Closed glennmichael123 opened this 9 days ago ·
9 days ago

Two separate defects that compound: the SSG ignores partialsDir, and a failed @include doesn't fail the build. Together they let an app ship broken static HTML for weeks while buildApp() reports total success.

Found in a 124-template app whose entire pre-rendered output — header, footer, hero, CTA, pricing, every layout include — was replaced by error text. dist/index.html was a 6KB shell; after the fix, 95KB.

1. generateStaticSite never reads partialsDir

$ grep -n "partialsDir" packages/stx/src/ssg.ts
$        # no output

Compare its siblings, which the SSG does forward:

ssg.ts:1065   const middlewareDir = (stxConfig as any)?.routeMiddleware?.dir || 'middleware'
ssg.ts:1076   generateLayoutTypes((stxConfig as any)?.layoutsDir, stateDir(process.cwd()))

ssg.ts:979 loads the user config specifically for this purpose — the comment says "Load user's stx.config.ts for proper layoutsDir, componentsDir, etc." — and partialsDir is the one that doesn't make it through. SSGConfig has no such field, so buildApp() can't pass it either:

// build.ts
ssgResult = await generateStaticSite({
  pagesDir: path.join(config.root || '.', config.pagesDir || config.build?.pagesDir || 'pages'),
  publicDir: …, outputDir: …, generate404: …, minify: …, cleanOutput: …,
  ...options.ssg
})

So includes.ts:467 always falls back:

let partialsDir = options.partialsDir || path.join(path.dirname(filePath), 'partials')

Net effect: in a static build, @include always resolves against the default partials directory, whatever the config says. An app that keeps partials anywhere else has every include fail. Verified by setting partialsDir both project-relative and resources-relative in the config the loader actually reads — no change to the resolved path either way.

componentsDir has the same gap, and the comment at ssg.ts:979 names it explicitly as a reason for loading the config — but it appears nowhere else in the file:

$ grep -n "componentsDir" packages/stx/src/ssg.ts
979:  // Load user's stx.config.ts for proper layoutsDir, componentsDir, etc.

So of the three directory keys that comment is justifying, only layoutsDir is actually consumed.

2. A failed @include doesn't fail the build

This is the part that made it invisible for weeks. includes.ts:889 turns the failure into createDetailedErrorMessage(...) and returns it as the include's rendered output, so the error is spliced into the page where the markup should be:

<div … >╭─────────…─────────╮ File: default.stx  Message: Error loading include file
Bench/ConfirmHost: ENOENT: no such file or directory, open
'…/resources/partials/Bench/ConfirmHost.stx' …

ANSI escape codes and all, in shipped HTML. Meanwhile:

  Total: 37 pages
  Success: 37
  Failed: 0

15 failed includes on the home page alone, and the build's exit code is 0. Nothing in lint, typecheck, or the build output can see it — the only way to find it is to read the emitted HTML, which is exactly what nobody does when the build says success.

I'd argue an unresolvable include is not a recoverable condition in a build. In dev, rendering the error in place is genuinely useful; in SSG it produces a file you would never knowingly deploy. Suggestion: count include failures per page and fail the run (or at minimum report them in the summary and exit non-zero), with an opt-out for anyone who wants the current behaviour. Even just a line in the summary would have made this a five-minute fix instead of a three-week silent outage.

Repro

app/
  stx.config.ts        # partialsDir: 'components'
  resources/
    components/Thing.stx
    layouts/default.stx   # 
/var/www/reviewos-reviewos/releases/3e057f9/resources/views/layouts/app.stx:3041:29: include error: Error loading include file Thing: ENOENT: no such file or directory, open '/var/www/reviewos-reviewos/releases/3e057f9/resources/views/partials/Thing.stx'

╭──────────────────────────────────────────────────────────────╮
│  ERROR  Include Error                                    │
╰──────────────────────────────────────────────────────────────╯

File: app.stx (line 3041, col 29)
Message: Error loading include file Thing: ENOENT: no such file or directory, open '/var/www/reviewos-reviewos/releases/3e057f9/resources/views/partials/Thing.stx'

Context:
  3039:   resources/
  3040:     components/Thing.stx
> 3041:     layouts/default.stx   # 
/var/www/reviewos-reviewos/releases/3e057f9/resources/views/layouts/app.stx:3042:864: include error: Error loading include file Thing: ENOENT: no such file or directory, open '/var/www/reviewos-reviewos/releases/3e057f9/resources/views/partials/Thing.stx'

╭──────────────────────────────────────────────────────────────╮
│  ERROR  Include Error                                    │
╰──────────────────────────────────────────────────────────────╯

File: app.stx (line 3042, col 864)
Message: Error loading include file Thing: ENOENT: no such file or directory, open '/var/www/reviewos-reviewos/releases/3e057f9/resources/views/partials/Thing.stx'

Context:
  3040:     components/Thing.stx
  3041:     layouts/default.stx   # 
/var/www/reviewos-reviewos/releases/3e057f9/resources/views/layouts/app.stx:3069:63: include error: Error loading include file Thing: ENOENT: no such file or directory, open '/var/www/reviewos-reviewos/releases/3e057f9/resources/views/partials/Thing.stx'

╭──────────────────────────────────────────────────────────────╮
│  ERROR  Include Error                                    │
╰──────────────────────────────────────────────────────────────╯

File: app.stx (line 3069, col 63)
Message: Error loading include file Thing: ENOENT: no such file or directory, open '/var/www/reviewos-reviewos/releases/3e057f9/resources/views/partials/Thing.stx'

Context:
  3067:           title="+1"
  3068:         >
> 3069:           
  3070:           
  3071:         

> 3042:     views/index.stx       # @extends('layouts/default')

bun run build → reports success; dist/index.html contains the ENOENT banner instead of Thing's markup. Point partialsDir anywhere you like; the resolved path stays <viewsRoot>/partials.

Workaround

Symlink the default location at the real one:

ln -s components resources/partials

Which works, but is a filesystem workaround for a config key that reads as though it's supported — and silently re-breaks the entire static build the moment someone tidies it away.

3043: 3044:  3042: views/index.stx # @extends('layouts/default')

bun run build → reports success; dist/index.html contains the ENOENT banner instead of Thing's markup. Point partialsDir anywhere you like; the resolved path stays <viewsRoot>/partials.

Workaround

Symlink the default location at the real one:

ln -s components resources/partials

Which works, but is a filesystem workaround for a config key that reads as though it's supported — and silently re-breaks the entire static build the moment someone tidies it away.

3043:  views/index.stx # @extends('layouts/default')

bun run build → reports success; dist/index.html contains the ENOENT banner instead of Thing's markup. Point partialsDir anywhere you like; the resolved path stays <viewsRoot>/partials.

Workaround

Symlink the default location at the real one:

ln -s components resources/partials

Which works, but is a filesystem workaround for a config key that reads as though it's supported — and silently re-breaks the entire static build the moment someone tidies it away.

9 days ago

Fixed in 4b594c1 — the second defect as reported, the first partly, and I want to be precise about which is which because they turned out not to be the same bug.

The one that mattered: a failed include no longer ships

Reproduced exactly. Success: 1, Failed: 0, exit 0, ANSI banner in dist/index.html.

An unresolvable include is now a build failure, and the page is not written and not cached — caching it would carry the broken HTML into later builds, and the missing file isn't in dependencies (it doesn't exist), so creating it would never invalidate the entry.

Thrown rather than counted by hand, so it lands in the same per-page failure path as every other error: one place that increments failedCount, records the route, fires onError, and makes the CLI exit 1. ssg.failOnIncludeError: false restores the old behaviour byte-for-byte.

The message names the directory it resolved against, which you identified as the actual diagnosis:

resources/layouts/default.stx: Error loading include file Bench/ConfirmHost: ENOENT: ...
    resolved against partialsDir: /…/resources/components
Set ssg.failOnIncludeError to false to build anyway.

Carried out of includes.ts by an onIncludeError callback rather than by string-matching the banner. All four failure paths now go through one helper, so a fifth can't be added that reports nothing — which is how this stayed silent in the first place. Dev is untouched: the in-place render you called genuinely useful is exactly as it was.

The one I could not reproduce: "SSG never reads partialsDir"

The grep is accurate but the conclusion doesn't follow. renderPage does { ...stxConfig, ... } — it spreads the whole loaded config into the render options, so partialsDir and componentsDir do reach includes.ts without ever being named in ssg.ts. And resolveStxDirectories has already prefixed them with root.

I built your repro verbatim — root: 'resources', partialsDir: 'components', /var/www/reviewos-reviewos/releases/3e057f9/resources/components/IssueThread.stx:285:1129: include error: Error loading include file Thing: ENOENT: no such file or directory, open '/var/www/reviewos-reviewos/releases/3e057f9/resources/views/partials/Thing.stx' ╭──────────────────────────────────────────────────────────────╮ │  ERROR  Include Error │ ╰──────────────────────────────────────────────────────────────╯ File: IssueThread.stx (line 285, col 1129) Message: Error loading include file Thing: ENOENT: no such file or directory, open '/var/www/reviewos-reviewos/releases/3e057f9/resources/views/partials/Thing.stx' Context:  283:

Fixed in 4b594c1 — the second defect as reported, the first partly, and I want to be precise about which is which because they turned out not to be the same bug.

The one that mattered: a failed include no longer ships

Reproduced exactly. Success: 1, Failed: 0, exit 0, ANSI banner in dist/index.html.

An unresolvable include is now a build failure, and the page is not written and not cached — caching it would carry the broken HTML into later builds, and the missing file isn't in dependencies (it doesn't exist), so creating it would never invalidate the entry.

Thrown rather than counted by hand, so it lands in the same per-page failure path as every other error: one place that increments failedCount, records the route, fires onError, and makes the CLI exit 1. ssg.failOnIncludeError: false restores the old behaviour byte-for-byte.

The message names the directory it resolved against, which you identified as the actual diagnosis:

resources/layouts/default.stx: Error loading include file Bench/ConfirmHost: ENOENT: ...
  284:     resolved against partialsDir: /…/resources/components
> 285: Set ssg.failOnIncludeError to false to build anyway.

Carried out of includes.ts by an onIncludeError callback rather than by string-matching the banner. All four failure paths now go through one helper, so a fifth can't be added that reports nothing — which is how this stayed silent in the first place. Dev is untouched: the in-place render you called genuinely useful is exactly as it was.

The one I could not reproduce: "SSG never reads partialsDir"

The grep is accurate but the conclusion doesn't follow. renderPage does { ...stxConfig, ... } — it spreads the whole loaded config into the render options, so partialsDir and componentsDir do reach includes.ts without ever being named in ssg.ts. And resolveStxDirectories has already prefixed them with root.

I built your repro verbatim — root: 'resources', partialsDir: 'components', /var/www/reviewos-reviewos/releases/3e057f9/resources/components/IssueThread.stx:335:28: include error: Error loading include file Thing: ENOENT: no such file or directory, open '/var/www/reviewos-reviewos/releases/3e057f9/resources/views/partials/Thing.stx' ╭──────────────────────────────────────────────────────────────╮ │  ERROR  Include Error │ ╰──────────────────────────────────────────────────────────────╯ File: IssueThread.stx (line 335, col 28) Message: Error loading include file Thing: ENOENT: no such file or directory, open '/var/www/reviewos-reviewos/releases/3e057f9/resources/views/partials/Thing.stx' Context:  333: } 334: > 335: .reaction-emoji { font-size: 13.5px; line-height: 1; } 336: .reaction-count { font-variant-numeric: tabular-nums; } 337: .reaction-more { padding: 2px 10px; } in the layout reached via @extends, plus a nested Bench/ConfirmHost path — through both generateStaticSite() and buildApp(). The include resolved to resources/components/Thing.stx and the markup rendered. So something more specific is happening in your app, and the error path you quoted is the clue: resources/partials/… is the default partialsDir under root, meaning your configured value never landed in the loaded config at all — not that the SSG dropped it downstream.

What I did fix here is the real gap that grep exposed: SSGConfig had no partialsDir/componentsDir, so while the config file's values flowed through, a caller's did not. generateStaticSite({ partialsDir: 'elsewhere' }) silently did nothing, unlike pagesDir, publicDir and outputDir. Both keys are now first-class, with an explicit argument beating the config file.

And with the first fix in, if your app's value still isn't landing, the build now stops and prints the resolved path — which turns the three-week silent outage into the ten-second fix you described. If it still misbehaves after this, send me the config file and I'll chase the loader rather than the SSG.

Verification

Six tests. Sabotage-checked three ways: removing the throw fails 3, stopping the callback from firing fails the same 3, and dropping the partialsDir merge fails the caller-override test specifically.

Full suite 11825 pass. The 4 failures on that run (craft binary resolution ×2, X-STX-Runtime ×2) reproduce on a clean checkout of HEAD and are unrelated.

ln -s components resources/partials will keep working, but it should now be unnecessary — and if it ever gets tidied away, the build fails loudly instead of silently re-breaking every page.

286: 287:
 in the layout reached via @extends, plus a nested Bench/ConfirmHost path — through both generateStaticSite() and buildApp(). The include resolved to resources/components/Thing.stx and the markup rendered. So something more specific is happening in your app, and the error path you quoted is the clue: resources/partials/… is the default partialsDir under root, meaning your configured value never landed in the loaded config at all — not that the SSG dropped it downstream.

What I did fix here is the real gap that grep exposed: SSGConfig had no partialsDir/componentsDir, so while the config file's values flowed through, a caller's did not. generateStaticSite({ partialsDir: 'elsewhere' }) silently did nothing, unlike pagesDir, publicDir and outputDir. Both keys are now first-class, with an explicit argument beating the config file.

And with the first fix in, if your app's value still isn't landing, the build now stops and prints the resolved path — which turns the three-week silent outage into the ten-second fix you described. If it still misbehaves after this, send me the config file and I'll chase the loader rather than the SSG.

Verification

Six tests. Sabotage-checked three ways: removing the throw fails 3, stopping the callback from firing fails the same 3, and dropping the partialsDir merge fails the caller-override test specifically.

Full suite 11825 pass. The 4 failures on that run (craft binary resolution ×2, X-STX-Runtime ×2) reproduce on a clean checkout of HEAD and are unrelated.

ln -s components resources/partials will keep working, but it should now be unnecessary — and if it ever gets tidied away, the build fails loudly instead of silently re-breaking every page.

Sign in to comment on this issue.