@tagged-jsx/transform
v1.0.2
Published
Transform between tagged template literals and JSX syntax
Maintainers
Readme
@tagged-jsx/transform
Bidirectional conversion between tagged template literals and JSX syntax, with character-level source mapping for diagnostic translation.
Designed for SolidJS-ecosystem libraries that use the html tagged template tag (e.g., solid-styled-components, @solid-primitives/styled). Convert tagged template syntax to standard JSX and back, while preserving SolidJS's reactive expression semantics.
Convert this:
const el = html`<div class=${active ? "on" : "off"}>
<span>${content}</span>
</div>`;To this:
const el = <div class={active ? "on" : "off"}>
<span>{content}</span>
</div>;And back again. Used by the VS Code extension, TypeScript plugin, and formatter to provide seamless editing across both syntaxes.
Installation
npm install @tagged-jsx/transformUsage
Basic conversion
import { createJsxTransformer, createTaggedTransformer } from "@tagged-jsx/transform";
import ts from "typescript";
// Tagged template -> JSX
const toJSX = createJsxTransformer(["html", "jsx"], ts);
const jsxResult = toJSX.toJsx(`
const el = html\`<div class=\${active}>hello</div>\`;
`);
// Result: const el = <div class={active}>hello</div>
// JSX -> Tagged template
const toTagged = createTaggedTransformer("html", ts);
const taggedResult = toTagged.toTagged(`
const el = <div class={active}>hello</div>;
`);
// Result: const el = html`<div class=${active}>hello</div>`
// SolidJS components are wrapped in expressions:
const taggedResult2 = toTagged.toTagged(`
const el = <MyComponent prop={value}>{children}</MyComponent>;
`);
// Result: const el = html`<${MyComponent} prop=${value}>${children}</${MyComponent}>`With expression callbacks (SolidJS reactive expressions)
SolidJS evaluates interpolations eagerly in JSX but some template libraries use lazy evaluation. Callbacks bridge this gap by wrapping expressions with () => when converting to tagged templates, and unwrapping on the reverse path.
import {
createJsxTransformer,
createTaggedTransformer,
createExpressionTransformCallbacks,
} from "@tagged-jsx/transform";
import ts from "typescript";
const callbacks = createExpressionTransformCallbacks(ts);
const toJSX = createJsxTransformer(["html", "jsx"], ts, callbacks);
const toTagged = createTaggedTransformer("html", ts, callbacks);
// JSX (eager):
// <div class={signal()}>
// <Show when={condition}>
// Tagged template (lazy, with callbacks):
// html`<div class=${() => signal()}>
// html`<${Show} when=${() => condition}>`
// The callbacks ensure non-primitive expressions get () => wrapped
// for lazy evaluation, while primitives, arrow functions, and
// event handlers (on*) pass through unchanged.With source mappings (for diagnostic translation)
const { toJsxWithMappings } = createJsxTransformer(["jsx", "html"], ts);
const { code, mappings } = toJsxWithMappings(sourceCode);
// Map a position in the tagged source to the equivalent position in JSX
const jsxPos = getJsxPosition(taggedPos, mappings.mappings, code.length);
// Map a JSX diagnostic position back to the original tagged source
const taggedPos = getTaggedPosition(jsxPos, mappings.reverseMappings, sourceCode.length);API
createJsxTransformer(tags, ts, callbacks?)
Creates a transformer that converts tagged template literals to JSX syntax.
Parameters:
tags— Array of tag names to recognize (e.g.,["jsx", "html"])ts— A TypeScript module instancecallbacks?— OptionalTransformerCallbacksfor custom expression handling
Returns: { toJsx, toJsxWithMappings }
toJsx(code) — Converts all tagged templates in code matching the configured tags into JSX syntax. Processes templates iteratively (up to 100 per file), handling nested cases.
toJsxWithMappings(code) — Same as toJsx but also returns character-level source mappings.
createTaggedTransformer(tag, ts, callbacks?)
Creates a transformer that converts JSX syntax to tagged template literals.
Parameters:
tag— The tag name to use in output templates (e.g.,"html")ts— A TypeScript module instancecallbacks?— OptionalTransformerCallbacksfor custom expression handling
Returns: { toTagged, toTaggedWithMappings }
toTagged(code, callbacks?) — Converts all JSX elements in code to tagged template literals wrapping them in the configured tag. Accepts an optional per-invocation callbacks override. Component names (starting with uppercase) are wrapped in expressions: <MyComponent /> → html`<${MyComponent} />`. This follows SolidJS conventions where the html tag expects component names as interpolations.
toTaggedWithMappings(code, callbacks?) — Same as toTagged but also returns character-level source mappings.
getJsxPosition(taggedPosition, mappings, jsxCodeLength)
Given a character position in the original tagged template source, find the corresponding position in the JSX output using the diff-based mapping.
getTaggedPosition(jsxPosition, reverseMappings, taggedCodeLength)
The inverse of getJsxPosition. Given a JSX position, find the corresponding tagged template position. Used by the TypeScript plugin to map diagnostic locations back to the original source.
computeMappings(oldCode, newCode)
Computes forward and reverse character-level mappings between two strings using diff.
Transformer internals
createJsxTransformer algorithm
- Find the first
TaggedTemplateExpressionin the source whose tag matches the configured tags (depth-first traversal) - Extract the template strings array and expression nodes from the TypeScript AST
- Tokenize the strings using
@tagged-jsx/parse'stokenize() - Parse the tokens into an AST using
parse() - Attach whitespace metadata from template string segments to the AST via WeakMaps (preserves original spacing around attributes)
- Render the AST back to JSX text, calling
callbacks.toJSX()for each expression encountered - Replace the original tagged template text in the source
- Repeat until no more tagged templates remain
createTaggedTransformer algorithm
- Find the first JSX element or fragment (
JsxElement,JsxSelfClosingElement,JsxFragment; depth-first traversal) - Convert attributes preserving whitespace between tag name and attributes, and between attributes:
JSXAttributewith string initializer:class="foo"JSXAttributewith expression initializer:class=${expr}(with optional callback transformation)JSXAttributewithout initializer (boolean):disabledJSXSpreadAttribute:...${obj}
- Convert children recursively, preserving whitespace between opening tags/first children, between children, and between last children/closing tags
- Wrap the result in the configured tag:
tag`...` - Replace the JSX text in the source
- Repeat until no more JSX elements remain
Callback system
The TransformerCallbacks interface allows custom handling of expressions during conversion. This is essential for frameworks like SolidJS that use different evaluation models for tagged templates vs. JSX.
interface TransformerCallbacks {
toTagged?: (opts: ToTaggedCallbackOptions) => string;
toJSX?: (opts: ToJsxCallbackOptions) => string;
}ToTaggedCallbackOptions (JSX → Tagged direction)
| Field | Type | Description |
|-------|------|-------------|
| expression | ts.Expression | The TypeScript expression AST node |
| propName? | string | The attribute name (only set for attributes, not children) |
| propType | "attribute" | "child" | Whether this is an attribute value or a child expression |
| sourceCode | string | The full source code of the file being transformed |
ToJsxCallbackOptions (Tagged → JSX direction)
| Field | Type | Description |
|-------|------|-------------|
| expression | ts.Expression | The TypeScript expression AST node |
| propName? | string | The attribute name (only set for attributes) |
| propType | "attribute" | "child" | Whether attribute value or child expression |
| templateNode | ExpressionProp \| ExpressionNode | The parsed template AST node (from @tagged-jsx/parse) with token-level metadata |
| sourceCode | string | The full source code of the file being transformed |
Default callbacks: createExpressionTransformCallbacks(ts)
The built-in callbacks handle the () => wrap/unwrap pattern for idempotent round-tripping between JSX and tagged templates.
toTagged callback logic:
- Skip props:
refandon*(event handlers) are returned verbatim - Primitives: String/number literals,
true,false,null,undefinedare returned verbatim - Arrow functions: Returned verbatim (already carry parameter context)
- Everything else: Wrapped in
() => <expr>to convert eager evaluation to lazy thunks
toJSX callback logic:
- Skip props: Same
ref/on*skip logic - Primitives: Returned verbatim
- Arrow functions with parameters or block body: Returned verbatim (cannot safely unwrap)
- Zero-parameter arrow functions with expression body:
() =>prefix is stripped (reversing the wrapping)
This produces a clean round-trip suitable for SolidJS reactive expressions:
signal() --toTagged--> () => signal() --toJSX--> signal()Real-world SolidJS example:
// Source (JSX):
<button class={baseClass} onClick={handler} disabled={isDisabled()}>
<Show when={loaded()}><span>{text()}</span></Show>
</button>
// After toTagged with callbacks:
html`<button class=${() => baseClass} onClick=${handler} disabled=${() => isDisabled()}>
<${Show} when=${() => loaded()}><span>${() => text()}</span></${Show}>
</button>`
// Going back through toJSX with callbacks:
<button class={baseClass} onClick={handler} disabled={isDisabled()}>
<Show when={loaded()}><span>{text()}</span></Show>
</button>Note how onClick and primitives pass through untouched — only non-trivial expressions get wrapped.
---
## Source mapping system
The mapping module provides character-level offset tracking between tagged template and JSX representations, enabling tools to translate positions for error reporting, completions, and cursor synchronization.
Tagged source: html<div>${name}</div>
^-- position 14
|
v (mapped position)
JSX output: {name}
^-- position 6
This is used by the TypeScript plugin to map diagnostic positions from the JSX output back to the original tagged template source, so error squiggles appear at the correct locations in your `html` template literals.
The diff-based approach uses `diffChars` to compute fine-grained character differences, building both forward (tagged→JSX) and reverse (JSX→tagged) mappings.
---
## Whitespace model
The JSX-to-tagged direction preserves whitespace by extracting source text between known positions (e.g., between tag name and first attribute, between children). The tagged-to-JSX direction attaches whitespace metadata from the template string segments to the parsed AST using WeakMaps, separate from the parse package's interfaces.
This ensures that formatting like spacing around attributes, blank lines between elements, and indentation inside fragments is preserved through conversions.
## License
MIT