Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,21 @@ transpileJsxSource(input, {
})
```

By default, TypeScript syntax is preserved in the output. If your source needs to run directly
as JavaScript (for example, code entered in an editor), enable type stripping:

```ts
transpileJsxSource(input, {
typescript: 'strip',
})
```

Supported `typescript` modes:

- `'preserve'` (default): keep TypeScript syntax in output.
- `'strip'`: remove type-only declarations and erase inline type syntax (`: T`, `as T`,
`satisfies T`, non-null assertions, and type assertions) while still transpiling JSX.

### React runtime (`reactJsx`)

Need to compose React elements instead of DOM nodes? Import the dedicated helper from the `@knighted/jsx/react` subpath (React 18+ and `react-dom` are still required to mount the tree):
Expand Down
1 change: 1 addition & 0 deletions docs/next-steps.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ A few focused improvements will give @knighted/jsx a more polished, batteries-in
2. **Starter templates** – Ship StackBlitz/CodeSandbox starters (DOM-only, React, Lit + React) that highlight CDN flows and bundler builds. Link them in the README/docs so developers can experiment without cloning the repo.
3. **Diagnostics UX polish** – Build on the new `enableJsxDebugDiagnostics` helper by surfacing template codeframes, component names, and actionable remediation steps. Ship CLI toggles / README callouts so CDN demos and starters enable debug mode automatically in development while keeping production bundles pristine.
4. **Bundle-size trims** – With debug helpers moved to opt-in paths, refocus on analyzer-driven trims (property-information lookups, node bootstrap reuse, shared helper chunks). Validate the new floor across lite + standard builds with `npm run sizecheck` and document any remaining hotspots so future releases keep shrinking.
5. **TypeScript transform strategy** – Evaluate replacing (or augmenting) manual TS syntax erasure in `transpileJsxSource` with `oxc-transform` for `typescript: 'strip'` mode. Build a fixture matrix (annotations, interfaces/type aliases, `as`, `satisfies`, non-null assertions, generics) and compare output correctness, runtime behavior, and bundle impact before deciding whether to adopt `oxc-transform` as the default implementation.
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@knighted/jsx",
"version": "1.9.0",
"version": "1.9.1",
"description": "Runtime JSX tagged template that renders DOM or React trees anywhere with or without a build step.",
"keywords": [
"jsx runtime",
Expand Down
200 changes: 196 additions & 4 deletions src/transpile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,13 @@ import { normalizeJsxText } from './shared/normalize-text.js'
type AnyNode = Record<string, unknown>
type SourceRange = [number, number]
type TranspileSourceType = 'module' | 'script'
type TranspileTypeScriptMode = 'preserve' | 'strip'

export type TranspileJsxSourceOptions = {
sourceType?: TranspileSourceType
createElement?: string
fragment?: string
typescript?: TranspileTypeScriptMode
}

export type TranspileJsxSourceResult = {
Expand Down Expand Up @@ -64,6 +66,14 @@ const isSourceRange = (value: unknown): value is SourceRange =>
typeof value[1] === 'number'
const hasSourceRange = (value: unknown): value is { range: SourceRange } =>
isObjectRecord(value) && isSourceRange(value.range)
const tsWrapperExpressionNodeTypes = new Set([
'TSAsExpression',
'TSSatisfiesExpression',
'TSInstantiationExpression',
'TSNonNullExpression',
'TSTypeAssertion',
])

const compareByRangeStartDesc = (
first: { range: SourceRange },
second: { range: SourceRange },
Expand All @@ -74,6 +84,7 @@ class SourceJsxReactBuilder {
private readonly source: string,
private readonly createElementRef: string,
private readonly fragmentRef: string,
private readonly stripTypes: boolean,
) {}

compile(node: JSXElement | JSXFragment): string {
Expand Down Expand Up @@ -238,6 +249,25 @@ class SourceJsxReactBuilder {
return this.compileNode(node)
}

if (this.stripTypes && isObjectRecord(node)) {
if ('expression' in node && node.type === 'ParenthesizedExpression') {
return `(${this.compileExpression(
node.expression as Expression | JSXElement | JSXFragment,
)})`
}

if (
'expression' in node &&
typeof node.type === 'string' &&
tsWrapperExpressionNodeTypes.has(node.type)
) {
return this.compileExpression(
node.expression as Expression | JSXElement | JSXFragment,
)
}
}

/* c8 ignore next 3 -- defensive guard for malformed external AST nodes */
if (!hasSourceRange(node)) {
throw new Error('[jsx] Unable to read source range for expression node.')
}
Expand Down Expand Up @@ -307,13 +337,163 @@ const collectRootJsxNodes = (root: Program | Expression | JSXElement | JSXFragme
return nodes
}

type StripEdit = {
range: SourceRange
replacement?: string
}

const hasStringProperty = <K extends string>(
value: unknown,
key: K,
): value is Record<K, string> => isObjectRecord(value) && typeof value[key] === 'string'

const hasSourceAndExpressionRanges = (
value: unknown,
): value is {
type: string
range: SourceRange
expression: { range: SourceRange }
} =>
isObjectRecord(value) &&
typeof value.type === 'string' &&
hasSourceRange(value) &&
'expression' in value &&
hasSourceRange(value.expression)

const isTypeOnlyImportExport = (value: unknown): boolean =>
hasStringProperty(value, 'importKind')
? value.importKind === 'type'
: hasStringProperty(value, 'exportKind') && value.exportKind === 'type'

const isTypeOnlyNode = (value: unknown): boolean => {
if (!isObjectRecord(value) || typeof value.type !== 'string') {
return false
}

return [
'TSTypeAnnotation',
'TSTypeParameterDeclaration',
'TSTypeAliasDeclaration',
'TSInterfaceDeclaration',
'TSDeclareFunction',
'TSImportEqualsDeclaration',
'TSNamespaceExportDeclaration',
'TSModuleDeclaration',
].includes(value.type)
}

const createStripEditForTsWrapper = (
value: unknown,
source: string,
): StripEdit | null => {
if (!hasSourceAndExpressionRanges(value)) {
return null
}

if (
value.type !== 'TSAsExpression' &&
value.type !== 'TSSatisfiesExpression' &&
value.type !== 'TSInstantiationExpression' &&
value.type !== 'TSNonNullExpression' &&
value.type !== 'TSTypeAssertion'
) {
return null
}

const [exprStart, exprEnd] = value.expression.range
return {
range: value.range,
replacement: source.slice(exprStart, exprEnd),
}
}

const collectTypeScriptStripEdits = (source: string, root: Program): StripEdit[] => {
const edits: StripEdit[] = []

const walk = (value: unknown) => {
if (!isObjectRecord(value)) {
return
}

if (Array.isArray(value)) {
value.forEach(walk)
return
}

if (hasSourceRange(value)) {
if (isTypeOnlyNode(value) || isTypeOnlyImportExport(value)) {
edits.push({ range: value.range })
return
} else {
const wrapperEdit = createStripEditForTsWrapper(value, source)
if (wrapperEdit) {
edits.push(wrapperEdit)
return
}
}
}

for (const entry of Object.values(value)) {
walk(entry)
}
}

walk(root)
return edits
}

const rangeOverlaps = (first: SourceRange, second: SourceRange) =>
first[0] < second[1] && second[0] < first[1]

const compareStripEditPriority = (first: StripEdit, second: StripEdit) => {
const firstLength = first.range[1] - first.range[0]
const secondLength = second.range[1] - second.range[0]

if (firstLength !== secondLength) {
return secondLength - firstLength
}

return compareByRangeStartDesc(first, second)
}

const applyStripEdits = (magic: MagicString, edits: StripEdit[]) => {
if (!edits.length) {
return false
}

const appliedRanges: SourceRange[] = []
let changed = false

edits
.slice()
.sort(compareStripEditPriority)
.forEach(edit => {
/* c8 ignore next -- overlap handling is defensive after de-duplicated collection */
if (appliedRanges.some(range => rangeOverlaps(range, edit.range))) {
return
}

const [start, end] = edit.range
if (edit.replacement === undefined) {
magic.remove(start, end)
} else {
magic.overwrite(start, end, edit.replacement)
}
appliedRanges.push(edit.range)
changed = true
})

return changed
}

export function transpileJsxSource(
source: string,
options: TranspileJsxSourceOptions = {},
): TranspileJsxSourceResult {
const sourceType = options.sourceType ?? 'module'
const createElementRef = options.createElement ?? 'React.createElement'
const fragmentRef = options.fragment ?? 'React.Fragment'
const typescriptMode = options.typescript ?? 'preserve'

const parsed = parseSync(
'transpile-jsx-source.tsx',
Expand All @@ -326,14 +506,26 @@ export function transpileJsxSource(
throw new Error(formatParserError(firstError))
}

const magic = new MagicString(source)
const stripChanged =
typescriptMode === 'strip'
? applyStripEdits(magic, collectTypeScriptStripEdits(source, parsed.program))
: false

const jsxRoots = collectRootJsxNodes(parsed.program)
if (!jsxRoots.length) {
return { code: source, changed: false }
return {
code: stripChanged ? magic.toString() : source,
changed: stripChanged,
}
}

const builder = new SourceJsxReactBuilder(source, createElementRef, fragmentRef)
const magic = new MagicString(source)

const builder = new SourceJsxReactBuilder(
source,
createElementRef,
fragmentRef,
typescriptMode === 'strip',
)
jsxRoots.sort(compareByRangeStartDesc).forEach(node => {
magic.overwrite(node.range[0], node.range[1], builder.compile(node))
})
Expand Down
Loading
Loading