Skip to content

Add content mapper output extensions - #64581

Open
Andrew Branch (andrewbranch) wants to merge 9 commits into
microsoft:mainfrom
andrewbranch:content-mapper-output-extensions
Open

Andrew Branch (andrewbranch) wants to merge 9 commits into
microsoft:mainfrom
andrewbranch:content-mapper-output-extensions

Conversation

@andrewbranch

Copy link
Copy Markdown
Member

Fixes #64053

This makes TypeScript content mapping a better citizen in a system where another tool is going to emit content mapped files to disk. The current system basically assumes a bundler or running the source in place.

A content mapper's package.json can now specify typescript.contentMapper.outputExtensions, a mapping from a content mapped file's original extension to the output extension that another tool will emit:

{
  "name": "ember-content-mapper",
  "typescript": {
    "contentMapper": {
      "exec": ["node", "server.js"],
      "outputExtensions": {
        ".gts": ".js"
      }
    }
  }
}

The same configuration is supported in tsconfig.json as an override (the entire mapping is overridden; keys are not merged):

{
  "contentMappers": [
    {
      "package": "ember-content-mapper",
      "extensions": [".gts"],
      "outputExtensions": {
        ".gts": ".js"
      }
    }
  ]
}

This causes:

  • The declaration file output for Component.gts to be Component.d.ts instead of Component.d.gts.ts, because there is expected to be a Component.js (that someone else will create).

  • ⚠️ The program that uses this content mapper must set rewriteRelativeImportExtensions: true. Why? If configuration indicates that Page.gts is going to be emitted as Page.js, then a plain TypeScript file in the same program that imports it:

    // routes.ts
    import Page from "./Page.gts";

    must be emitted as

    // routes.js
    import Page from "./Page.js";

    which is exactly what rewriteRelativeImportExtensions does.

    The alternative would be to import from "./Page.js" or even "./Page" depending on module resolution settings, i.e., an output-compatible path. That request is Content mappers: let registered extensions take part in extensionless module lookup #64549, and while this PR allows us space to consider that in the future, I strongly prefer imports that will trigger a content mapper be immediately recognizable by file extension.

Copilot AI balanced review requested due to automatic review settings October 1, 2026 23:07
@typescript-automation typescript-automation Bot added Author: Team For Uncommitted Bug PR for untriaged, rejected, closed or missing bug labels Oct 1, 2026

This comment was marked as resolved.

This comment was marked as resolved.

Preserve source phase import diagnostic codes and renumber content mapper output diagnostics; regenerate outputs and update the baseline.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

This comment was marked as resolved.

This comment was marked as low quality.

Preserve content mapper output extensions in the relocated VS Code extension API.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Adapt content mapper rewrites, output paths, hosts, and regression tests to strongly typed paths and case sensitivity.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
rewrites := b.ctx.host.ContentMapperExtensionRewrites()
ignoreCase := b.ctx.host.CaseSensitivity().IsCaseInsensitive()
if core.ShouldRewriteModuleSpecifierWithExtensions(specifier.AsString(), b.ch.compilerOptions, rewrites, ignoreCase) {
rewritten, _ := core.RewriteExtension(specifier.AsString(), rewrites, ignoreCase)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be a method on something?

@jakebailey Jake Bailey (jakebailey) left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the casing stuff might be out of date after the typed paths PR?

EmitResolver: emitResolver,
GetEmitModuleFormatOfFile: host.GetEmitModuleFormatOfFile,
ContentMapperExtensionRewrites: host.ContentMapperExtensionRewrites(),
IgnoreCase: host.CaseSensitivity().IsCaseInsensitive(),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not just pass the enum value?

Comment thread tsc/internal/core/core.go
Target string
}

func GetExtensionRewrite(path string, rewrites []ExtensionRewrite, ignoreCase bool) (ExtensionRewrite, bool) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do these belong in tspath, as methods on something with the right types?


func GetExternalOutputFileName(inputFileName tspath.RootedFilePath, options *core.CompilerOptions, host OutputPathsHost) tspath.RootedFilePath {
outputPath := getOutputFileNameWithoutChangingExtension(inputFileName, options.OutDir, host)
rewritten, ok := core.RewriteExtension(outputPath.AsString(), host.ContentMapperExtensionRewrites(), host.CaseSensitivity().IsCaseInsensitive())

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, these conversions to and from strings don't feel super to me

ImportName: "__rewriteRelativeImportExtension",
Scoped: false,
Text: `var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
Text: `var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx, extraExtensions, ignoreCase) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have we ever extended an existing tslib entry with params before? Wondering if we need a new name; we of course need to now publish tslib with this, so I need to get that publish pipeline created, oops

@leonidaz

Copy link
Copy Markdown

Andrew Branch (@andrewbranch) Thanks for working on this. The output-extension support is a useful step forward.

I strongly prefer imports that will trigger a content mapper be immediately recognizable by file extension.

I understand the preference for making the source format visible, but I don’t think that should rule out extensionless resolution when a project explicitly configures it and its build tool supports it.

TypeScript already allows import Card from "./Card" to resolve to Card.tsx in the appropriate resolution modes. That import does not reveal that the source contains JSX or requires JSX processing. Why should components authored in a registered, mapper-supported format have to expose that implementation detail in every import? The mechanisms differ, but the developer-facing expectation seems equivalent. [TypeScript’s extension-substitution rules](https://www.typescriptlang.org/docs/handbook/modules/reference.html#file-extension-substitution)

More fundamentally, moduleResolution: "bundler" is intended to model the bundler’s resolution behavior. When the bundler is configured to resolve ./Card to Card.tsrx, rejecting that import in TypeScript creates a mismatch between the type checker and a working build. This is particularly difficult to justify for noEmit projects, where TypeScript is not responsible for producing runtime imports. [Extensionless-path documentation](https://www.typescriptlang.org/docs/handbook/modules/reference.html#extensionless-relative-paths)

The proposal in #64549 is deliberately limited to registered extensions and contexts that already support extensionless lookup. The resolver would use the registered extension list; the mapper would still process the file after resolution selects it. Node ESM’s explicit-extension requirements would remain intact.

There are practical benefits beyond shorter imports. Extensionless paths let a component change source format without requiring changes throughout its import graph. They also preserve existing conventions in projects whose language tools already support this through Volar, avoiding import rewrites solely to migrate to TypeScript 7.

The precedence and declaration-emission questions deserve explicit answers. However, they seem like reasons to define the feature’s constraints rather than require source extensions universally, especially when declaration emit is irrelevant to a noEmit application.

I really hope you would consider an opt-in setting on a contentMappers entry. That would let projects request behavior consistent with their build tooling while keeping explicit extensions available as a project convention.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Author: Team For Uncommitted Bug PR for untriaged, rejected, closed or missing bug

Projects

Status: Not started

Development

Successfully merging this pull request may close these issues.

content-mapper generates inconsistent declaration extensions, making management of package.json#exports hard / verbose

4 participants