Skip to content

Content mappers - #4712

Open
Andrew Branch (andrewbranch) wants to merge 54 commits into
microsoft:mainfrom
andrewbranch:content-mappers
Open

Content mappers#4712
Andrew Branch (andrewbranch) wants to merge 54 commits into
microsoft:mainfrom
andrewbranch:content-mappers

Conversation

@andrewbranch

@andrewbranch Andrew Branch (andrewbranch) commented Jul 23, 2026

Copy link
Copy Markdown
Member

Implements #2824 (comment)

Overview

Content mappers are external integrations that allow TypeScript to include otherwise unsupported file types in a program. They transform a foreign file’s original text into valid TypeScript syntax and provide mappings between the original and transformed content.

Users specify a set of file extensions to be handled by a content mapper package in a tsconfig.json file:

{
  "compilerOptions": {
    // ...
  },
  "contentMappers": [
    {
      "package": "vue-content-mapper",
      "extensions": [".vue"],
      "options": {
        "strictTemplates": true
      }
    }
  ],
  "include": ["src"] // implicitly includes .vue as well as .ts
}

When contentMappers are specified, tsc must be run with --loadExternalPlugins. VS Code passes --loadExternalPlugins to tsc --lsp only in trusted workspaces; otherwise, contentMappers are ignored in the LSP server.

The package field will be resolved as a Node.js module name. The optional options field must be an object and is passed through to the mapper.

The package.json of the content mapper package must specify a tsContentMapper top-level field describing how to spawn the content mapper process and what compiler options its transform requires. A mapper that reads additional project-specific configuration from external sources beyond those compiler options can additionally declare dynamicConfig: true:

{
  "name": "vue-content-mapper",
  "version": "1.0.0",
  "tsContentMapper": {
    "exec": ["node", "dist/server.js"],
    "compilerOptions": ["module", "jsx", "jsxImportSource"],
    "dynamicConfig": true
  }
}

Note that the content mapper process need not be run with Node.js or implemented in JavaScript; package resolution serves as a convenient way to associate a content mapper with a versioned identity that can be managed alongside other dependencies in a project, but the exec field can specify any command.

VS Code extensions can also register content mapper integrations with the TypeScript extension. A registration always supplies the extensions that should trigger configured-project discovery, and may additionally provide an inline manifest and options for using the mapper in inferred projects. Configured projects continue to use only the contentMappers declared in their config files; extension-provided inferred-project mappers never modify configured project behavior.

Protocol

When constructing a program for a config that specifies contentMappers, module resolution recognizes file lookups for the specified extensions and requests transformed content from mapper processes over STDIO. Content mappers communicate with TypeScript over JSON-RPC. TypeScript sends all requests; mappers do not send requests or notifications. All mappers handle initialize and transform. Mappers declaring dynamicConfig: true additionally handle openProject and closeProject.

type PositionEncoding = "utf-8" | "utf-16";

interface InitializeParams {
    protocolVersion: 1;
    /** The position encodings supported by TypeScript. The mapper must choose one of these encodings. */
    positionEncodings: PositionEncoding[];
    /** BCP 47 locale requested for diagnostics. */
    locale?: string;
}

interface InitializeResult {
    /** Must match the protocolVersion sent in InitializeParams. */
    protocolVersion: 1;
    /** The position encoding the mapper will use for all span mapping positions and diagnostic positions. */
    positionEncoding: PositionEncoding;
    /**
     * The source identifier displayed for mapper-produced diagnostics.
     * Must not be "ts", "tsc", "typescript", or any file extension TypeScript understands.
    */
    diagnosticSource: string;
}

/** This request is sent only to mappers that declare `dynamicConfig: true`. */
interface OpenProjectParams {
    /** Absolute tsconfig path, or an empty string for a project without a config file. */
    configFileName: string;
    /** Opaque process-local handle assigned by TypeScript. */
    projectHandle: string;
    /** Object from the contentMappers entry, when specified. */
    options?: Record<string, unknown>;
    /** The project's effective compiler options. */
    compilerOptions: CompilerOptions;
}

/** This response is required only from mappers that declare `dynamicConfig: true`. */
interface OpenProjectResult {
    /**
     * Stable fingerprint of all dynamically discovered configuration that can affect transforms.
     */
    configIdentity: string;
    /**
     * Absolute file names whose changes may alter configIdentity or transform output.
     * May only be returned when the package declares `dynamicConfig: true`. Do not include
     * the files being transformed; those are watched separately.
     */
    watchedFiles?: string[];
}

interface TransformParams {
    fileName: string;
    /** Original content of the file to be transformed. */
    content: string;
    /** Object from the contentMappers entry, when specified. */
    options?: Record<string, unknown>;
    /** Project handle supplied in openProject. Absent for mappers without `dynamicConfig: true`. */
    projectHandle?: string;
    /** The subset of compiler options that the mapper requested in its package.json. */
    compilerOptions: CompilerOptions;
}

interface MappedOutput {
    /** Valid JS, JSX, TS, TSX, or JSON text that TypeScript can parse, according to the specified `scriptKind`. */
    text: string;
    /** The kind of syntax returned in `text`. Defaults to `ScriptKind.TS` if not specified. */
    scriptKind?: ScriptKind;
    /** Mappings between the original and transformed content. */
    mappings?: SpanMapping[];
}

interface TransformResult extends MappedOutput {
    /** Parse errors in the original content. */
    diagnostics?: MapperDiagnostic[];
    /** Additional generated files associated with this input. */
    supplemental?: MappedOutput[];
}

/** This request is sent only to mappers that declare `dynamicConfig: true`. */
interface CloseProjectParams {
    /** Project handle supplied in openProject. */
    projectHandle: string;
}

/** Positions and lengths are in the specified `positionEncoding`. */
type SpanMapping = [
    generatedStart: number,
    generatedLength: number,
    originalStart: number,
    originalLength: number,
    kind: SpanMapKind,
    features?: SpanMapFeature,
];

enum ScriptKind {
    JS = 1,
    JSX = 2,
    TS = 3,
    TSX = 4,
    JSON = 6,
}

enum SpanMapKind {
    /** Verbatim spans in generated output have the same length and content as their counterparts in original text. */
    Verbatim = 0,
    /** Atom spans in generated output may have different length and content than their counterparts in the original text. */
    Atom = 1,
    /** Alias spans in generated output may have different length and content than their counterparts in the original text, but diagnostics display their original text. */
    Alias = 2,
}

/** Controls which TypeScript language service features may use a span. */
enum SpanMapFeature {
    None = 0,
    Hover = 1 << 0,
    SignatureHelp = 1 << 1,
    Completion = 1 << 2,
    Definition = 1 << 3,
    TypeDefinition = 1 << 4,
    Implementation = 1 << 5,
    SourceDefinition = 1 << 6,
    References = 1 << 7,
    DocumentHighlights = 1 << 8,
    Rename = 1 << 9,
    CallHierarchy = 1 << 10,
    CodeActions = 1 << 11,
    Formatting = 1 << 12,
    InlayHints = 1 << 13,
    SemanticTokens = 1 << 14,
    FoldingRanges = 1 << 15,
    SelectionRanges = 1 << 16,
    LinkedEditing = 1 << 17,
    AutoInsert = 1 << 18,
    DocumentSymbols = 1 << 19,
    CodeLens = 1 << 20,
    /** Enables every language service feature. This is the default when `features` is omitted. */
    All = (CodeLens << 1) - 1,
}

/** Start and length are in the specified `positionEncoding`. */
interface MapperDiagnostic {
    messageText: string;
    start: number;
    length: number;
    code?: number;
}

A mapper may return supplemental outputs when a file contributes more than one TypeScript or JavaScript file, such as an Astro component containing multiple script blocks. TypeScript automatically includes these outputs in the same program as the canonical output, so they participate in binding and type checking without needing to be imported. Supplemental outputs receive compiler-assigned virtual file names based on their order and scriptKind, but those names are not module resolution targets and cannot be imported directly. Imports written inside supplemental outputs resolve relative to the directory containing the original file.

Span maps

For a content mapper to be useful, it needs to provide a mapping between the transformed output and the original content. In the CLI, these mappings are used to show TypeScript-generated diagnostics in the original, non-TypeScript content. Take a simple example:

// original content:
(+ 1 2 "oops")

// transformed content:
add(1, 2, "oops");

// span mapping:
add(1, 2, "oops");
^^^                 [0, 3)    [1, 2) + atom
    ^               [4, 5)    [3, 4) 1 verbatim
       ^            [7, 8)    [5, 6) 2 verbatim
          ^^^^^^    [10, 16)  [7, 13) "oops" verbatim

TypeScript sees and checks the transformed content, in this case producing a diagnostic for the string literal "oops" because it is not a number. The span mapping allows TypeScript to report the diagnostic in the original content, at the correct location of the string literal ([7, 13), instead of [10, 16)).

In this example, add mapped to + with SpanMapKind.Atom, indicating a correspondence between the two spans, but with different lengths and content. If the name add failed to resolve, the displayed diagnostic range would cover +, but the message would still reference the identifier add:

add.lisp:1:2 - error TS2304: Cannot find name 'add'.

1 (+ 1 2 "oops")
   ~

The mapper can use SpanMapKind.Alias instead of SpanMapKind.Atom to indicate that the generated and original text name the same entity. When the diagnostic is rendered, the original text of the alias span (+) will be substituted for the generated text (add) in the diagnostic message:

add.lisp:1:2 - error TS2304: Cannot find name '+'.
1 (+ 1 2 "oops")
   ~

Gaps in the span map are treated as fully synthesized content and cannot be mapped to a location in the original text. Unlike in Volar, diagnostics in unmappable regions are not discarded. In the CLI, they cause a short snippet of the transformed content to be shown with the diagnostic. A common case may be a content mapper that synthesizes an import statement at the top of the file used in scaffolding. If that import fails to resolve, the user will see:

app.vue:1:26 - error TS2307: Cannot find module '@vue/content-mapper-utils' or its corresponding type declarations.
  This location is in code generated by the content mapper '@vue/content-mapper@1.0.0' and has no corresponding location in the original file.

1 import { scaffolding } from "@vue/content-mapper-utils";
                              ~~~~~~~~~~~~~~~~~~~~~~~~~~~

Spans in the generated output must not overlap, but multiple may map to the same span in the original content. In other words, one range in the original content can map to multiple ranges in the transformed content. This can be useful in the language server when combined with SpanMapFeature and SpanMapKind. Broadly speaking, when a language server request is received for a position in a content-mapped file, the handler maps it to every projection whose feature mask includes the requested operation, performs analysis on the transformed content, and maps visible results back through spans that participate in the same feature. This lets a mapper independently select, for example, one projection for hover and another for definitions or references.

The language server currently supports the following features for content-mapped files:

  • Diagnostics - always mapped to original content where possible; diagnostics in synthesized regions are collected and reported at the top of the file. Diagnostics are intentionally not represented by a feature flag, so generated code cannot opt out of diagnostic reporting.
  • Position-based features - hover, signature help, completions, definitions, type definitions, implementations, source definitions, references, document highlights, rename, call hierarchy, code actions, formatting, linked editing, and auto-insert map incoming positions or ranges through spans participating in their corresponding SpanMapFeature flag.
  • Document-wide features - inlay hints, semantic tokens, folding ranges, selection ranges, document symbols, and CodeLens map visible results back only through spans participating in their corresponding flag.
  • Text edits - feature participation does not make a mapping edit-safe. Rename, code action, completion, and formatting edits may be written back only through exact, length-preserving SpanMapKind.Verbatim mappings.

Language service requests and visible results can be disabled independently for any span by clearing the corresponding bits, or disabled for all features with SpanMapFeature.None. If features is omitted from the span mapping tuple, it defaults to SpanMapFeature.All, enabling every supported language service feature for that span.

Note

Unlike with Volar, feature participation must be statically determined by the content mapper during transformation. This level of LSP feature mapping is not intended to replace fully custom language servers. TypeScript’s goal in providing language service support for content-mapped files is to support a good editing experience inside <script> blocks or similar verbatim ranges that embed normal TypeScript or JavaScript code without a third-party language server needing to proxy every request unchanged. We expect that ecosystems implementing complex transforms may still want to implement their own language servers alongside TypeScript’s, and either augment or replace TypeScript’s implementation of these language service features. Content mappers provide a baseline editing experience, but they also provide the API foundation for more specialized language servers to build on. Vue tooling, for example, may choose to enable TypeScript features only for selected projections while a separate language server handles the rest, accessing the AST, type, and symbol information of transformed content through an API connection to TypeScript’s language server.

Failure handling

Mappers return diagnostics for unparseable content, and errors in the transformed text itself are handled by TypeScript like any other file. If the mapper fails in an unexpected way (e.g., crashes or doesn’t conform to the protocol), TypeScript reports a localized diagnostic and treats the file as an empty TypeScript file. After five failures in a single project, TypeScript stops attempting to transform files with that content mapper and issues a final diagnostic reporting the failure.

LSP activation

TypeScript’s language server can only know to care about the file extensions registered in contentMappers once the server is running and has discovered a tsconfig.json that specifies them. In the case where a user opens a directory in VS Code and opens a single .vue file, the TypeScript VS Code extension hasn’t even activated, much less spawned a server that knows about a contentMappers registration. To address this, third-party VS Code extensions need to explicitly activate the TypeScript extension and register their content mapper contributions:

const extension = vscode.extensions.getExtension("TypeScriptTeam.native-preview");
const api = await extension?.activate();

const registration = api?.registerContentMappers(
    "publisher.vue-language-features",
    [{ extensions: [".vue"] }],
);

Registering extensions causes TypeScript to inspect matching documents that are already open and discover any configured projects that provide a mapper for them. The returned disposable removes the contribution.

An extension may also provide a mapper for files that do not belong to a configured project by including an inline inferred-project contribution:

const registration = api?.registerContentMappers(
    "publisher.vue-language-features",
    [{
        extensions: [".vue"],
        inferredProjectContribution: {
            options: { strictTemplates: true }, // corresponds to tsconfig.json contentMappers options
            manifest: {                         // corresponds to a content mapper's package.json
                name: "vue-content-mapper",
                version: "1.0.0",
                exec: [process.execPath, mapperEntryPoint],
                cwd: extension.extensionUri,
                compilerOptions: ["module", "jsx", "jsxImportSource"],
                dynamicConfig: true,
            },
        },
    }],
);

It's recommended that extensions always provide an inferredProjectContribution, and to supply a manifest built from resolving the workspace-installed content mapper package, falling back to a bundled version if the package is not installed. But ultimately, the extension is responsible for content mapper resolution and version/fallback policy.

Emit

Content-mapped files are not emitted to JavaScript. When --declaration is enabled, however, declaration files are emitted from the transformed content. The declaration file name for App.svelte is App.d.svelte.ts. Declaration files for supplemental outputs of a file named App.svelte are emitted as App.svelte.0.d.ts, App.svelte.1.d.ts, etc., and are automatically referenced by App.d.svelte.ts. Declaration maps are currently not supported.

Incremental, build, watch, and process consolidation

Content mappers are supported in --incremental, --build, and --watch modes. Each project records sorted mapper transform identities in .tsbuildinfo and compares them during up-to-date checks. Changing an identity forces files handled by that mapper to be transformed again.

For a mapper without dynamicConfig: true, the transform identity is computed without starting its process. It includes the resolved package name and version, the tsconfig entry’s options, and the values of compiler options named by tsContentMapper.compilerOptions. Consequently, incremental and solution-build status checks do not spawn processes for mappers with static configuration.

For a mapper declaring dynamicConfig: true, TypeScript sends openProject to obtain configIdentity before an up-to-date decision. The mapper is responsible for changing configIdentity whenever dynamically discovered configuration that can affect transforms changes.

TypeScript watches the absolute paths returned in watchedFiles. A change invalidates only projects that reported that path, closes their current mapper project configuration, obtains a fresh identity and watch set, and performs a normal project rebuild. Other projects using the same mapper package continue using their existing project handles and the shared process.

Note

Modifying a static-config content mapper implementation during local development will not change its identity, so you’ll need to bump the local package.json version, or use --force or --clean to clear cached outputs if testing with --incremental or --build.

In --build mode with project references, and in some instances in the language server, it’s possible to have a project graph with many projects all defining the same content mapper. To avoid excessive spawning of child processes, TypeScript deduplicates content mapper processes by resolved package name and version. For a mapper with dynamicConfig: true, one process may have many open project handles. Dynamic-config mappers must isolate project-specific state by projectHandle, accept requests for different projects in any order, and release that state on closeProject. Static-config mappers receive transforms without a project handle. Processes remain alive while any project using that package is retained.

API integration

Content-mapped SourceFiles can be inspected by the JavaScript API. For a content-mapped SourceFile, file.text is the transformed text, file.originalText is the original text, and file.spanMap exposes an API for mapping between the two. Regardless of the positionEncoding used by the content mapper, accessing the span map through the JavaScript API always yields UTF-16 positions.

const mapped = file.spanMap.generatedToOriginalPosition(10);
// { position, fidelity }
// See _packages/native-preview/src/ast/spanMap.ts for details.

If the content mapper provided supplemental outputs for a file, the file names are set on file.supplementalOutputs and can be retrieved with program.getSourceFile(fileName).

To-do

  • Break out content mapper time in --extendedDiagnostics and LSP logs
  • Turn on additional language service features
  • Decide whether mapper parse diagnostics prevent text from being used
  • Investigate timeout/cancellation in LSP
  • Investigate if declaration maps can work by double-mapping back to original text
  • In VS Code, provide a read-only view of the transformed content for debuggability
  • Provide a JavaScript library for implementing the content mapper protocol

@remcohaszing

Copy link
Copy Markdown

First of all, this is a great start! Thanks for working on this. ❤️

I only read the PR description and comments. I have some thoughts and feedback.


Content-mapped files are not emitted to JavaScript. When --declaration is enabled, however, declaration files are emitted from the transformed content. The declaration file name for App.svelte is App.d.svelte.ts. Declaration maps are currently not supported.

This is probably correct for many languages, but not all. I believe Svelte, Vue, and Astro files are published to npm as-is.

MDX is different though. MDX is a language that is syntactic sugar for JS(X). It gets compiled to plain JS. It should be treated the same as JSX. For example, the following MDX:

# Hello {props.user.name}

<Avatar user={props.user} />

gets compiled to roughly the same JavaScript as the following JSX:

export default function MDXContent(props) {
  return (
    <>
      <h1>
        Hello {props.user.name}
      </h1>
      <Avatar user={props.user} />
    </>
  )
}

I don’t see a reason to publish to MDX to npm, but I believe the situation is similar for Ember (cc Alex Matchneer (@machty)). I imagine a content mapper should be able to specify how its declarations are emitted.

I’m also not sure how emit should work with mapped files. I can imagine it it works with noEmit or emitDeclarationsOnly. Alternatively the protocol can support a new request type to get the content and sourcemap to emit.


type SpanMapping = [
    generatedStart: number,
    generatedLength: number,
    originalStart: number,
    originalLength: number,
    kind: SpanMapKind,
    purpose?: SpanMapPurpose,
];

I really like that generatedLength and originalLength can differ. This is currently not possible in Volar. I do wonder if this causes compatibility issues with Volar. It would be nice to reuse the TypeScript content mapper with other Volar services instead of having to write separate mappers for TypeScript and Volar. This is more of a concern for Volar than for TypeScript. cc Johnson Chu (@johnsoncodehk)


enum SpanMapPurpose {
  /** Disables all language service features for the span. */
  None = 0,
  /** Used by features that inspect semantic information, such as hover, signature help, and completions. */
  Semantic = 1 << 0,
  /** Used by features that locate symbols, such as definitions, references, rename, and call hierarchy. */
  Navigation = 1 << 1,
  /** Enables both semantic and navigation features. This is the default when `purpose` is omitted. */
  All = Semantic | Navigation,
}

I think we need for fine-grained control. Notably, actions that read are generally safe, but actions that write may be unsafe, as the edit would be based on mapped content, but be applied to to the actual content. Edits produce valid TypeScript, but might produce content that’s not valid in the original content. For example, say we have an MDX file with YAML frontmatter. The pipe represents the cursor position.

---
|
---

Now autocomplete might turn this into something like:

---
{
  created: new Date(),
  title: ''
}|
---

This completion made sense in TypeScript, but it doesn’t make sense in YAML. There are no Date constructors in YAML. And while the rest happens to be valid YAML, most would consider it non-idiomatic. In this case, I would probably like to enable hover, signature, definitions, references, and hierarchy. But renaming and completions are more dangerous actions to perform.


I see that content mappers can specify which compiler options they consume. This is a great start, but they may also need other options. How are users supposed to specify those? Should content mappers support custom compiler options?

{
  "compilerOptions": {
    "mdxRemarkPlugins": [],
    //
  },
  "contentMappers": [
    {
      "package": "@mdx-js/content-mapper",
      "extensions": [".mdx"]
    }
  ]
}

Or should they get their own config option? In this case, how should extended tsconfigs be handled?

{
  "compilerOptions": {
    //
  },
  "contentMappers": [
    {
      "package": "@mdx-js/content-mapper",
      "extensions": [".mdx"],
      "options": {
        "remarkPlugins": []
      }
    }
  ]
}

For editor JSON schema support it might also be nicer to use a mapping instead of an array.

{
  "compilerOptions": {
    //
  },
  "contentMappers": {
    "@mdx-js/content-mapper": {
      "extensions": [".mdx"],
      "options": {
        "remarkPlugins": []
      }
    }
  }
}

Can multiple mapped content ranges overlap with the same position in a source file? For example, you can either import or inject JSX components in MDX. So say we have the following MDX, where the pipe is the cursor:

import { Imported } from 'module'

<Imported />
<Injected| />

This is roughly equivalent to the following JSX:

import { Imported } from 'module'

export default function MDXContent(props) {
  return (
    <>
      <Imported />
      <props.components.Injected />
    </>
  )
}

This means that while typing JSX, it’s nice to get completions from imported members as well as members on the props.components type. To achive this in Volar, we roughly map this to the following virtual content:

import { Imported } from 'module'

export default function MDXContent(props) {
  return (
    <>
      <Imported />
      <props.components.Injected| />
    </>
  )
}

Injected|

Notice that there are two cursors in the virtual content.


A related issue for content mappers is microsoft/TypeScript#31894. This doesn’t need to be resolved in the first iteration, but it’s a big pain point in the current Volar based approach that I want to highlight.

A scenario:

Say you want to add a content mapper to JSX support to TypeScript. You could make the JSX behaviour configurable using some types on a namespace named JSX. It’s up to the user, such as @types/react to define these types. They should specify for example JSX.Element, and JDX.IntrinsicElements. Now several years later you decide to add a new type, JSX.ElementType. This breaks @types/react, because it didn’t specify JSX.ElementType. Not specifying this type just became a type error.

JSX support is builtin to TypeScript. So they broke their own rule. JSX.ElementType is optional. In fact, any type can be omitted from the JSX namespace. This will lead to weird behaviour, but it doesn’t make the type checker fail. The JSX namespace can even be defined in different places.

It’s common for other content mappers to depend on types that may or may not be defined. Currently this is done by depending on undefined behaviour of /*unresolved*/ any, and the behaviour has been broken a couple of times already.


I feel like it would be useful to add support for injecting a custom TypeScript file into the program. I believe some Volar integrations add boilerplate that could be reused to avoid the need to re-parse them all.

But another situation I have in mind is Next.js. They support typed routes by emitting a TypeScript file and continuously updating it when you run the server. I feel like they could abuse the content mapper system by making the user commit an empty file named whatever.next-types, then implement a content mapper for .next-types files to inject their code.

IMO this might as well be supported without burdening the end user with the hassle of requiring an empty file for the content mapper.

@jasonlyu123

Lyu, Wei-Da (jasonlyu123) commented Aug 4, 2026

Copy link
Copy Markdown

The reason for that patch is: when the file is empty or when the cursor is pointed at the end of a file I would not get LSP completions

About completion, I found another problem. Completion position is currently mapped to the right of the cursor. If the span right after the symbol is a SpanMapKind.Atom, completion will be skipped. For example, Svelte transform {a} in the markup to a;. The mapping from } to ; needs to be a SpanMapKind.Atom because the text has changed. Wondering if it would be better to treat the completion position as the end of a range. Something like this

	ranges := l.converters.FromLSPRange(file, lsproto.Range{
		Start: lsproto.Position{Line: LSPPosition.Line, Character: LSPPosition.Character - 1},
		End:   LSPPosition,
	}, spanmap.FeatureCompletion)

	position := int(ranges[0].Span.End())

I also want to get feedback on the constraints I’ve put on overlapping segments. The current rules are

  • Mappings must be ordered by start position in the transformer output text, and spans in the transformer output text must not overlap.
  • Spans in the original text may be perfect duplicates (i.e., multiple spans in the transformer text may map to the same span in the original text) but otherwise must not overlap.

One problem I found is with adding a quote to an identifier. Svelte transforms element attributes to an object literal.

<button foo="" >

to

{ svelteHTML.createElement("element", { "foo":"",});}

Because JavaScript's identifier rules differ from HTML attribute rules, attributes need to be surrounded by quotes. This caused a conflict with diagnostics and references-related features. The diagnostics range includes the quote, but references don't. If I made the quote mapped to the first character, document highlight will highlight foo as f"oo". But when the quote doesn't have a mapping, the diagnostics range might be mapped to a nearby position. A workaround I found is to map the first quote to the first character of the identifier, while leaving the original length as 0 so it doesn't overlap. Not sure if it counts as not overlapping. Or it's just that the current validation doesn't complain about it.

Another question about mapping: should the span be broken down by identifier or tokens? It seems so in your example. Or can it just be one span? For example, should an unedited expression like document.getElement('') be broken down into document, ., getElement, (, '' and )? From my testing, it seems like just one span also works,

@andrewbranch

Copy link
Copy Markdown
Member Author

I found another problem. Completion position is currently mapped to the right of the cursor.

It’s actually the same problem Michael Arnaldi (@mikearnaldi) found. I have a fix stashed but it’s more complicated than I’d like so I’m working on some other things while I think about it.

Thanks for the quoted attribute example; I think we can relax the span overlap rules a little bit.

Another question about mapping: should the span be broken down by identifier or tokens? It seems so in your example. Or can it just be one span? For example, should an unedited expression like document.getElement('') be broken down into document, ., getElement, (, '' and )?

This is totally up to the content mapper implementation, but generally speaking, there’s no reason to have contiguous spans with the same kind/features. You should aim for as few spans as it takes to get the behavior you want.

@andrewbranch

Copy link
Copy Markdown
Member Author

Made a few significant updates and edited the PR description:

  • replaced SpanMapPurpose with per-LSP-feature bit flags
  • added two different ways of getting extra configuration options into content mappers

@andrewbranch

Copy link
Copy Markdown
Member Author

Another significant change: a content mapper may now emit additional supplemental files as part of any Transform response. PR description updated again.

@johnnyreilly

John Reilly (johnnyreilly) commented Aug 7, 2026

Copy link
Copy Markdown

Quick question. I've been beavering away on adding 7.1 TS support to ts-loader:

TypeStrong/ts-loader#1704

One of the incomplete pieces is custom transformers: https://github.com/TypeStrong/ts-loader#getcustomtransformers

It's possible I've asked this elsewhere and forgotten the answer, if so apologies! But I'm wondering if this functionality is likely to cover what transformers did in the TS version of the API? See: https://github.com/microsoft/TypeScript/blob/b465fdbfe175304d9b977da137b2c178ae1091d3/src/compiler/program.ts#L2693

@andrewbranch

Andrew Branch (andrewbranch) commented Aug 7, 2026

Copy link
Copy Markdown
Member Author

No, but custom transformers are still planned, mentioned in #4830. I’ll add ts-loader to the list of projects that needs it!

@andrewbranch

Copy link
Copy Markdown
Member Author

Third party VS Code extensions can now contribute bundled content mappers directly, described in the “LSP Activation” section of the PR description. The important change from TS 6 Server Plugins is that extensions can only directly affect inferred projects (ones without jsconfig/tsconfig files). Since content mappers are supported via config on the CLI, we felt it would be weird for your IDE to invisibly patch your existing config to give you different results in the editor vs. on the CLI. So, the editor can only automatically apply content mappers when there's no config file in play.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

9 participants