Skip to content

Commit 2148caa

Browse files
NullVoxPopuliclaude
andcommitted
Add outputExtension to contentMappers entries
A content mapper turns `foo.gts` into virtual TypeScript, and declaration emit names the result `foo.d.gts.ts` with `./foo.gts` kept in specifiers. That is the only safe choice when nothing is known about the external build. When that build compiles `foo.gts` to `foo.js`, the published package needs `foo.d.ts` beside `foo.js`, and `.gts` specifiers do not resolve for consumers that do not run the mapper. `contentMappers[].outputExtension` (".js", ".mjs", or ".cjs") tells TypeScript what the external build produces. With it set, declaration emit names the file after the output (`foo.d.ts`, `foo.d.mts`, `foo.d.cts`) and rewrites relative specifiers that resolved through the mapper's extension to the output extension. Resolution, not string matching, decides the rewrite, so a specifier that resolved to a sibling `foo.d.gts.ts` is left alone. Any other value reports TS100069 on the value in tsconfig.json. The name collision from the issue (`app.ts` and `app.gts` both emitting `app.d.ts`) is caught by the existing TS5056 output-path check. JavaScript emit is unchanged: `rewriteRelativeImportExtensions` still rewrites only TypeScript extensions. Fixes #64053 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0188eg6bTrrUiWbj5FZaWH5Z
1 parent 253c5e2 commit 2148caa

33 files changed

Lines changed: 1192 additions & 1 deletion

tsc/internal/compiler/emitHost.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,10 @@ func (host *emitHost) ContentMapperExtensions() []string {
113113
return host.program.ContentMapperExtensions()
114114
}
115115

116+
func (host *emitHost) ContentMapperOutputExtension(fileName string) string {
117+
return host.program.ContentMapperOutputExtension(fileName)
118+
}
119+
116120
func (host *emitHost) UseCaseSensitiveFileNames() bool {
117121
return host.program.UseCaseSensitiveFileNames()
118122
}

tsc/internal/compiler/program.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -508,6 +508,12 @@ func (p *Program) GetContentMapper(file *ast.SourceFile) *contentmapper.Mapper {
508508
return nil
509509
}
510510

511+
// ContentMapperOutputExtension returns the extension an external build produces for a content-mapped
512+
// file, as declared by its mapper's "outputExtension", or an empty string when none is declared.
513+
func (p *Program) ContentMapperOutputExtension(fileName string) string {
514+
return p.opts.Config.ContentMapperOutputExtension(fileName)
515+
}
516+
511517
func (p *Program) ContentMapperExtensions() []string { return p.opts.Config.ContentMapperExtensions() }
512518
func (p *Program) CommandLine() *tsoptions.ParsedCommandLine { return p.opts.Config }
513519
func (p *Program) Host() CompilerHost { return p.opts.Host }

tsc/internal/contentmapper/contentmapper.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,11 @@ type Definition struct {
3131
Package string `json:"package"`
3232
Extensions []string `json:"extensions"`
3333
Options json.Value `json:"options,omitempty"`
34+
// OutputExtension is the JavaScript extension (".js", ".mjs", or ".cjs") that an external build
35+
// produces for the mapper's files. When set, declaration emit names each file's declaration after
36+
// that output (`component.vue` emits `component.d.ts`) and rewrites relative imports of the mapper's
37+
// files to the output extension so the emitted declarations resolve without the mapper.
38+
OutputExtension string `json:"outputExtension,omitempty"`
3439
}
3540

3641
// Manifest is the content-mapper information read from a package's package.json: its name and version
@@ -63,6 +68,13 @@ func IsSupportedVirtualExtension(extension string) bool {
6368
return supportedVirtualExtensions.Has(extension)
6469
}
6570

71+
var supportedOutputExtensions = collections.NewSetFromItems(".js", ".mjs", ".cjs")
72+
73+
// IsSupportedOutputExtension reports whether extension is a valid "outputExtension" for a content mapper.
74+
func IsSupportedOutputExtension(extension string) bool {
75+
return supportedOutputExtensions.Has(extension)
76+
}
77+
6678
// DiagnosticName returns the best available user-facing name, including when manifest resolution failed.
6779
func (m *Mapper) DiagnosticName() string {
6880
switch {

tsc/internal/diagnostics/diagnostics_generated.go

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

tsc/internal/diagnostics/extraDiagnosticMessages.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,5 +338,9 @@
338338
"Diagnostic directive {0} returned by the content mapper has an invalid 'unusedExpectDirectiveIndex'.": {
339339
"category": "Message",
340340
"code": 100068
341+
},
342+
"Content mapper output extension '{0}' must be '.js', '.mjs', or '.cjs'.": {
343+
"category": "Error",
344+
"code": 100069
341345
}
342346
}

tsc/internal/ls/autoimport/aliasresolver.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,11 @@ func (r *aliasResolver) ContentMapperExtensions() []string {
159159
return nil
160160
}
161161

162+
// ContentMapperOutputExtension implements checker.Program.
163+
func (r *aliasResolver) ContentMapperOutputExtension(fileName string) string {
164+
return ""
165+
}
166+
162167
// FileExists implements checker.Program.
163168
func (r *aliasResolver) FileExists(fileName string) bool {
164169
panic("unimplemented")

tsc/internal/modulespecifiers/specifiers_test.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,10 @@ func (h *mockModuleSpecifierGenerationHost) ContentMapperExtensions() []string {
4848
return h.contentMapperExtensions
4949
}
5050

51+
func (h *mockModuleSpecifierGenerationHost) ContentMapperOutputExtension(fileName string) string {
52+
return ""
53+
}
54+
5155
func (h *mockModuleSpecifierGenerationHost) GetProjectReferenceFromSource(path tspath.Path) *tsoptions.SourceOutputAndProjectReference {
5256
return nil
5357
}

tsc/internal/modulespecifiers/types.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ type ModuleSpecifierGenerationHost interface {
5151
// GetFileIncludeReasons() any // !!! TODO: adapt new resolution cache model
5252
CommonSourceDirectory() string
5353
ContentMapperExtensions() []string
54+
ContentMapperOutputExtension(fileName string) string
5455
GetGlobalTypingsCacheLocation() string
5556
UseCaseSensitiveFileNames() bool
5657
GetCurrentDirectory() string

tsc/internal/outputpaths/outputpaths.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
type OutputPathsHost interface {
1010
CommonSourceDirectory() string
1111
ContentMapperExtensions() []string
12+
ContentMapperOutputExtension(fileName string) string
1213
GetCurrentDirectory() string
1314
UseCaseSensitiveFileNames() bool
1415
}
@@ -147,6 +148,10 @@ func GetDeclarationEmitOutputFilePath(file string, options *core.CompilerOptions
147148

148149
func ChangeToDeclarationExtension(path string, host OutputPathsHost) string {
149150
if extension := tspath.GetLongestExtensionFromPath(path, host.ContentMapperExtensions(), false); extension != "" {
151+
if outputExtension := host.ContentMapperOutputExtension(path); outputExtension != "" {
152+
outputPath := tspath.RemoveExtension(path, extension) + outputExtension
153+
return tspath.RemoveFileExtension(outputPath) + tspath.GetDeclarationEmitExtensionForPath(outputPath)
154+
}
150155
return tspath.RemoveExtension(path, extension) + ".d" + extension + ".ts"
151156
}
152157
pathWithoutExtension := tspath.RemoveFileExtension(path)

tsc/internal/transformers/declarations/transform.go

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1592,7 +1592,33 @@ func (tx *DeclarationTransformer) rewriteModuleSpecifier(parent *ast.Node, input
15921592
return nil
15931593
}
15941594
tx.resultHasExternalModuleIndicator = tx.resultHasExternalModuleIndicator || (parent.Kind != ast.KindModuleDeclaration && parent.Kind != ast.KindImportType)
1595-
return input
1595+
return tx.rewriteContentMappedSpecifier(input)
1596+
}
1597+
1598+
// rewriteContentMappedSpecifier rewrites a relative specifier that resolves to a content-mapped file
1599+
// whose mapper declares an "outputExtension", so the specifier names the file an external build
1600+
// produces (`./component.vue` becomes `./component.js`). The declaration for that file is emitted under
1601+
// the same name (`component.d.ts`), so the rewritten specifier resolves without the mapper.
1602+
func (tx *DeclarationTransformer) rewriteContentMappedSpecifier(input *ast.Node) *ast.Node {
1603+
if !ast.IsStringLiteral(input) || !tspath.PathIsRelative(input.Text()) {
1604+
return input
1605+
}
1606+
resolved := tx.host.GetResolvedModuleFromModuleSpecifier(tx.state.currentSourceFile, input)
1607+
if resolved == nil || !resolved.IsResolved() || !resolved.ResolvedUsingExtraExtensions {
1608+
return input
1609+
}
1610+
outputExtension := tx.host.ContentMapperOutputExtension(resolved.ResolvedFileName)
1611+
if outputExtension == "" {
1612+
return input
1613+
}
1614+
extension := tspath.GetLongestExtensionFromPath(input.Text(), tx.host.ContentMapperExtensions(), !tx.host.UseCaseSensitiveFileNames())
1615+
if extension == "" {
1616+
return input
1617+
}
1618+
updated := tx.Factory().NewStringLiteral(tspath.RemoveExtension(input.Text(), extension)+outputExtension, input.AsStringLiteral().TokenFlags)
1619+
tx.EmitContext().SetOriginal(updated, input)
1620+
tx.EmitContext().AssignCommentAndSourceMapRanges(updated, input)
1621+
return updated
15961622
}
15971623

15981624
func (tx *DeclarationTransformer) preserveJsDoc(updated *ast.Node, original *ast.Node) {

0 commit comments

Comments
 (0)