Skip to content

Commit c7effd0

Browse files
committed
fix(variable-extractor): rewrite aliased imports into destructuring form
An import becomes destructuring, and the two spell renaming differently: `import { a as b }` is `const { a: b }`. The import spelling was pasted straight through, producing `const { a as b } = await import(…)`, which is a SyntaxError. The whole script then failed to parse, so every binding in it came back undefined, with nothing logged. That is the expensive part. A template whose values have all vanished renders its empty-state branch, so the page reads as a correct answer to a question nobody asked rather than as a failure. And it only bites when a module re-exports under aliases - which is exactly the shape `resources/functions` barrels use, because stx cannot parse `export … from`.
1 parent 54382f0 commit c7effd0

2 files changed

Lines changed: 118 additions & 6 deletions

File tree

packages/stx/src/variable-extractor.ts

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1006,15 +1006,40 @@ export function convertToCommonJS(scriptContent: string, filePath?: string): str
10061006
}
10071007
else if (namedImportMatch) {
10081008
const [, names, source] = namedImportMatch
1009-
const cleanNames = names.trim()
10101009
const resolved = resolveSource(source)
1011-
convertedLines.push(`const { ${cleanNames} } = await import('${resolved}')`)
1012-
for (const n of cleanNames.split(',')) {
1013-
const cleanName = n.trim().split(/\s+as\s+/).pop()?.trim()
1014-
if (cleanName) {
1015-
convertedLines.push(`module.exports.${cleanName} = ${cleanName};`)
1010+
1011+
/*
1012+
* An import turns into destructuring, and the two spell renaming
1013+
* differently: `import { a as b }` is `const { a: b }`. Pasting the
1014+
* import spelling through produced `const { a as b } = await import(…)`,
1015+
* which is a SyntaxError - so the whole script failed to parse and every
1016+
* binding in it came back undefined, with nothing logged. A template
1017+
* whose values all vanish renders its empty-state branch, which reads as
1018+
* a correct answer rather than a failure.
1019+
*/
1020+
const specifiers: string[] = []
1021+
const exported: string[] = []
1022+
1023+
for (const raw of names.split(',')) {
1024+
const specifier = raw.trim()
1025+
if (!specifier)
1026+
continue
1027+
1028+
const aliased = specifier.split(/\s+as\s+/)
1029+
if (aliased.length === 2) {
1030+
const [imported, local] = [aliased[0]!.trim(), aliased[1]!.trim()]
1031+
specifiers.push(`${imported}: ${local}`)
1032+
exported.push(local)
1033+
}
1034+
else {
1035+
specifiers.push(specifier)
1036+
exported.push(specifier)
10161037
}
10171038
}
1039+
1040+
convertedLines.push(`const { ${specifiers.join(', ')} } = await import('${resolved}')`)
1041+
for (const local of exported)
1042+
convertedLines.push(`module.exports.${local} = ${local};`)
10181043
}
10191044
else if (sideEffectMatch) {
10201045
const resolved = resolveSource(sideEffectMatch[1])
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { describe, expect, it } from 'bun:test'
2+
import { mkdtemp, rm } from 'node:fs/promises'
3+
import { tmpdir } from 'node:os'
4+
import path from 'node:path'
5+
import { convertToCommonJS, extractVariables } from '../../src/variable-extractor'
6+
7+
/**
8+
* `import { a as b }` in a `<script server>`.
9+
*
10+
* An import becomes destructuring, and the two spell renaming differently:
11+
* `import { a as b }` is `const { a: b }`. The import spelling was pasted
12+
* straight through, producing `const { a as b } = await import(…)` - a
13+
* SyntaxError. The whole script then failed to parse, so every binding in it
14+
* came back undefined, with nothing logged.
15+
*
16+
* That is the expensive part. A template whose values have all vanished renders
17+
* its empty-state branch, so the page reads as a correct answer to a question
18+
* nobody asked - a repository browser reporting "no such repository" for a
19+
* repository that plainly exists - rather than as a failure. And it only bites
20+
* when a barrel module re-exports under aliases, which is exactly the shape
21+
* `resources/functions` uses, because stx cannot parse `export … from`.
22+
*/
23+
describe('aliased named imports', () => {
24+
it('rewrites `as` into destructuring form, not the import form', () => {
25+
const out = convertToCommonJS(
26+
`import { tagNames as tagNamesImpl } from './mod'\nconst a = 1\n`,
27+
'test.stx',
28+
)
29+
30+
expect(out).toContain('tagNames: tagNamesImpl')
31+
expect(out).not.toContain('tagNames as tagNamesImpl')
32+
})
33+
34+
it('leaves an unaliased name alone', () => {
35+
const out = convertToCommonJS(
36+
`import { branchNames } from './mod'\nconst a = 1\n`,
37+
'test.stx',
38+
)
39+
40+
expect(out).toContain('{ branchNames }')
41+
})
42+
43+
it('handles a list mixing aliased and plain names', () => {
44+
const out = convertToCommonJS(
45+
`import { alpha, beta as betaLocal, gamma } from './mod'\nconst a = 1\n`,
46+
'test.stx',
47+
)
48+
49+
expect(out).toContain('alpha')
50+
expect(out).toContain('beta: betaLocal')
51+
expect(out).toContain('gamma')
52+
})
53+
54+
/**
55+
* The end-to-end shape: the local name has to actually hold the value. One
56+
* unbound name is enough to strand the script, and the neighbouring names
57+
* going missing with it is what makes the cause so hard to see.
58+
*/
59+
it('binds every local name to its export', async () => {
60+
const dir = await mkdtemp(path.join(tmpdir(), 'stx-alias-'))
61+
try {
62+
await Bun.write(
63+
path.join(dir, 'mod.ts'),
64+
'export function tagNames() { return ["v1"] }\n'
65+
+ 'export function branchNames() { return ["main"] }\n'
66+
+ 'export const MAX = 7\n',
67+
)
68+
69+
const ctx: Record<string, any> = {}
70+
await extractVariables(
71+
`import { tagNames as tagNamesImpl, branchNames, MAX as MAX_IMPL } from './mod'\n`
72+
+ `const tagKind = typeof tagNamesImpl\n`
73+
+ `const branchKind = typeof branchNames\n`
74+
+ `const max = MAX_IMPL\n`,
75+
ctx,
76+
path.join(dir, 'page.stx'),
77+
)
78+
79+
expect(ctx.tagKind).toBe('function')
80+
expect(ctx.branchKind).toBe('function')
81+
expect(ctx.max).toBe(7)
82+
}
83+
finally {
84+
await rm(dir, { recursive: true, force: true })
85+
}
86+
})
87+
})

0 commit comments

Comments
 (0)