Skip to content

Commit a49aaad

Browse files
fix: typecheck + test stability across plugin, signals, and visuals
Five fixes the test job needed: 1. packages/stx/src/index.ts — alias `BuildResult` re-export from site-builder to `SiteBuildResult` since build-optimizer also exports `BuildResult` (TS2300 duplicate identifier). 2. packages/stx/src/utils.ts — `resolveTemplatePath`'s final guard now also accepts paths inside the configured `root`/`layoutsDir`/ `componentsDir`/`partialsDir`/`pagesDir`. Tests and tools that point those at directories outside cwd (e.g. spa-fragment-real.test.ts, monorepo workspace setups) were getting their resolved templates rejected by the cwd-only guard. 3. packages/stx/src/site-builder/seo.ts — `injectSeo` now writes the per-page `<title>` and strips any prior one. Without this every built page kept stx's default `<title>stx Project</title>` even though og:title/twitter:title got rewritten correctly. 4. packages/bun-plugin/src/index.ts — `onResolve({ filter: /^\// })` was marking the entrypoint absolute path as external when there was no importer, causing `Bun.build` to silently produce zero outputs under `bun test`. Skip when `args.importer` is falsy so only actual imports get externalized. 5. packages/desktop/test/clipboard-strip.test.ts — afterEach was doing `delete globalThis.Blob`, wiping Bun's built-in for every later test in the run (the build-optimizer optimizeTemplate suite hit "Blob is not defined" because of this). Save originals in beforeEach and restore in afterEach instead. Full test suite goes from 61 fails → 0 fails (8695 pass). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 13704ae commit a49aaad

5 files changed

Lines changed: 71 additions & 18 deletions

File tree

packages/bun-plugin/src/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,12 @@ export function stxPlugin(userOptions?: StxOptions): BunPlugin {
172172
// Bare absolute paths only — don't intercept Windows drive paths
173173
// or schemeless URLs (//cdn.example.com/...).
174174
if (args.path.startsWith('//')) return null
175+
// Don't intercept entrypoints — when entrypoints are passed as
176+
// absolute paths (common in tests and tooling), they have no
177+
// importer. Marking those external would tell Bun to skip them
178+
// entirely and emit no output. We only want to externalize
179+
// *imports* originating from inside another module.
180+
if (!args.importer) return null
175181
return { external: true, path: args.path }
176182
})
177183

packages/desktop/test/clipboard-strip.test.ts

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,27 @@ import { clipboard } from '../src/clipboard'
99

1010
describe('clipboard.writeHTML stripHtml fallback', () => {
1111
let writes: Array<{ html: string, text: string }>
12+
// Save originals so we can restore them after each test. Bun ships a
13+
// real `Blob` global, and other tests in the suite (e.g. the
14+
// build-optimizer optimizeTemplate suite) rely on it. A bare
15+
// `delete globalThis.Blob` in afterEach would wipe Bun's built-in and
16+
// poison every subsequent test in the run.
17+
let origWindowBlob: any
18+
let origWindowClipboardItem: any
19+
let origGlobalBlob: any
20+
let origGlobalClipboardItem: any
21+
let origNavigatorClipboard: any
1222

1323
beforeEach(() => {
1424
delete (window as any).craft
1525
writes = []
1626

27+
origWindowBlob = (window as any).Blob
28+
origWindowClipboardItem = (window as any).ClipboardItem
29+
origGlobalBlob = (globalThis as any).Blob
30+
origGlobalClipboardItem = (globalThis as any).ClipboardItem
31+
origNavigatorClipboard = (navigator as any).clipboard
32+
1733
// very-happy-dom doesn't ship Blob/ClipboardItem; stub minimal
1834
// versions that capture the bytes the production code feeds in.
1935
;(window as any).Blob = class FakeBlob {
@@ -37,11 +53,20 @@ describe('clipboard.writeHTML stripHtml fallback', () => {
3753
})
3854

3955
afterEach(() => {
40-
delete (navigator as any).clipboard
41-
delete (window as any).Blob
42-
delete (window as any).ClipboardItem
43-
delete (globalThis as any).Blob
44-
delete (globalThis as any).ClipboardItem
56+
if (origNavigatorClipboard === undefined) delete (navigator as any).clipboard
57+
else (navigator as any).clipboard = origNavigatorClipboard
58+
59+
if (origWindowBlob === undefined) delete (window as any).Blob
60+
else (window as any).Blob = origWindowBlob
61+
62+
if (origWindowClipboardItem === undefined) delete (window as any).ClipboardItem
63+
else (window as any).ClipboardItem = origWindowClipboardItem
64+
65+
if (origGlobalBlob === undefined) delete (globalThis as any).Blob
66+
else (globalThis as any).Blob = origGlobalBlob
67+
68+
if (origGlobalClipboardItem === undefined) delete (globalThis as any).ClipboardItem
69+
else (globalThis as any).ClipboardItem = origGlobalClipboardItem
4570
})
4671

4772
it('strips simple HTML tags from the plaintext alt', async () => {

packages/stx/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ export {
147147
stripLocalePrefix,
148148
translate,
149149
type BuildOptions,
150-
type BuildResult,
150+
type BuildResult as SiteBuildResult,
151151
type PageMeta,
152152
type RouterOptions,
153153
type SiteConfig,

packages/stx/src/site-builder/seo.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export function injectSeo(html: string, site: SiteConfig, page: PageMeta = {}, p
2323
tags.push(`<link rel="icon"${mime ? ` type="${mime}"` : ''} href="${escapeAttr(seo.favicon)}">`)
2424
}
2525
tags.push(
26+
`<title>${escapeText(title)}</title>`,
2627
`<link rel="canonical" href="${escapeAttr(url)}">`,
2728
`<meta name="description" content="${escapeAttr(description)}">`,
2829
`<meta property="og:title" content="${escapeAttr(title)}">`,
@@ -54,6 +55,12 @@ export function injectSeo(html: string, site: SiteConfig, page: PageMeta = {}, p
5455
// Strip any existing block we previously injected (idempotent rebuilds)
5556
html = html.replace(/<!--\s*SEO\s*-->[\s\S]*?<!--\s*\/SEO\s*-->\s*/g, '')
5657

58+
// Strip any existing <title> — site.config.ts pages[*].title is the
59+
// source of truth. Without this, stx's default `<title>stx Project</title>`
60+
// (or a stale per-page title) survives and the browser shows it instead
61+
// of the SEO block's title.
62+
html = html.replace(/<title>[\s\S]*?<\/title>\s*/i, '')
63+
5764
// Strip stale tags emitted by stx's defaults that aren't in the marker block
5865
html = html
5966
.replace(/<meta\s+name="title"\s+content="stx Project"[^>]*>\s*/g, '')
@@ -79,3 +86,10 @@ function escapeAttr(s: string): string {
7986
.replace(/</g, '&lt;')
8087
.replace(/>/g, '&gt;')
8188
}
89+
90+
function escapeText(s: string): string {
91+
return s
92+
.replace(/&/g, '&amp;')
93+
.replace(/</g, '&lt;')
94+
.replace(/>/g, '&gt;')
95+
}

packages/stx/src/utils.ts

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -794,19 +794,27 @@ export async function resolveTemplatePath(
794794
): Promise<string | null> {
795795
const result = await resolveTemplatePathInner(templatePath, currentFilePath, options, dependencies)
796796
if (result === null) return null
797-
// Final guard: every path the resolver returns must stay inside the
798-
// project root. Any escape (a malformed include path, an interpolated
799-
// user-controlled directive argument, ...) gets rejected here so the
800-
// caller treats it as "not found" rather than serving an arbitrary
801-
// file. We don't try to confine to layouts/components/partials
802-
// specifically — too easy to break legitimate setups — but the
803-
// project-root boundary is non-negotiable.
804-
const safe = assertInsideRoot(result, process.cwd())
805-
if (!safe) {
806-
console.warn(`[stx] rejected resolved template path that escapes project root: ${result} (template: ${templatePath}, from: ${currentFilePath})`)
807-
return null
797+
// Final guard: every path the resolver returns must stay inside a
798+
// trusted directory. Any escape (a malformed include path, an
799+
// interpolated user-controlled directive argument, ...) gets rejected
800+
// here so the caller treats it as "not found" rather than serving an
801+
// arbitrary file. The project root is the primary boundary, but
802+
// explicit `root`/`layoutsDir`/`componentsDir`/`partialsDir`/`pagesDir`
803+
// values are also trusted — they may legitimately point outside cwd
804+
// (e.g. tests using a fixture in another repo, monorepos pointing at
805+
// sibling packages). The guard is non-negotiable: if the path is
806+
// inside *none* of those, it gets rejected.
807+
const safeUnderCwd = assertInsideRoot(result, process.cwd())
808+
if (safeUnderCwd) return safeUnderCwd
809+
810+
const trustedDirs = [options.root, options.layoutsDir, options.componentsDir, options.partialsDir, options.pagesDir]
811+
.filter((d): d is string => typeof d === 'string' && d.length > 0)
812+
for (const dir of trustedDirs) {
813+
const resolvedDir = path.isAbsolute(dir) ? dir : path.resolve(process.cwd(), dir)
814+
if (assertInsideRoot(result, resolvedDir)) return path.resolve(result)
808815
}
809-
return safe
816+
console.warn(`[stx] rejected resolved template path that escapes project root: ${result} (template: ${templatePath}, from: ${currentFilePath})`)
817+
return null
810818
}
811819

812820
async function resolveTemplatePathInner(

0 commit comments

Comments
 (0)