Skip to content

Commit 720732c

Browse files
feat: add stripTypes in transpilation. (#85)
1 parent 3280173 commit 720732c

6 files changed

Lines changed: 311 additions & 7 deletions

File tree

README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,21 @@ transpileJsxSource(input, {
9494
})
9595
```
9696

97+
By default, TypeScript syntax is preserved in the output. If your source needs to run directly
98+
as JavaScript (for example, code entered in an editor), enable type stripping:
99+
100+
```ts
101+
transpileJsxSource(input, {
102+
typescript: 'strip',
103+
})
104+
```
105+
106+
Supported `typescript` modes:
107+
108+
- `'preserve'` (default): keep TypeScript syntax in output.
109+
- `'strip'`: remove type-only declarations and erase inline type syntax (`: T`, `as T`,
110+
`satisfies T`, non-null assertions, and type assertions) while still transpiling JSX.
111+
97112
### React runtime (`reactJsx`)
98113

99114
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):

docs/next-steps.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,4 @@ A few focused improvements will give @knighted/jsx a more polished, batteries-in
66
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.
77
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.
88
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.
9+
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.

package-lock.json

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

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@knighted/jsx",
3-
"version": "1.9.0",
3+
"version": "1.9.1",
44
"description": "Runtime JSX tagged template that renders DOM or React trees anywhere with or without a build step.",
55
"keywords": [
66
"jsx runtime",

src/transpile.ts

Lines changed: 196 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,13 @@ import { normalizeJsxText } from './shared/normalize-text.js'
1717
type AnyNode = Record<string, unknown>
1818
type SourceRange = [number, number]
1919
type TranspileSourceType = 'module' | 'script'
20+
type TranspileTypeScriptMode = 'preserve' | 'strip'
2021

2122
export type TranspileJsxSourceOptions = {
2223
sourceType?: TranspileSourceType
2324
createElement?: string
2425
fragment?: string
26+
typescript?: TranspileTypeScriptMode
2527
}
2628

2729
export type TranspileJsxSourceResult = {
@@ -64,6 +66,14 @@ const isSourceRange = (value: unknown): value is SourceRange =>
6466
typeof value[1] === 'number'
6567
const hasSourceRange = (value: unknown): value is { range: SourceRange } =>
6668
isObjectRecord(value) && isSourceRange(value.range)
69+
const tsWrapperExpressionNodeTypes = new Set([
70+
'TSAsExpression',
71+
'TSSatisfiesExpression',
72+
'TSInstantiationExpression',
73+
'TSNonNullExpression',
74+
'TSTypeAssertion',
75+
])
76+
6777
const compareByRangeStartDesc = (
6878
first: { range: SourceRange },
6979
second: { range: SourceRange },
@@ -74,6 +84,7 @@ class SourceJsxReactBuilder {
7484
private readonly source: string,
7585
private readonly createElementRef: string,
7686
private readonly fragmentRef: string,
87+
private readonly stripTypes: boolean,
7788
) {}
7889

7990
compile(node: JSXElement | JSXFragment): string {
@@ -238,6 +249,25 @@ class SourceJsxReactBuilder {
238249
return this.compileNode(node)
239250
}
240251

252+
if (this.stripTypes && isObjectRecord(node)) {
253+
if ('expression' in node && node.type === 'ParenthesizedExpression') {
254+
return `(${this.compileExpression(
255+
node.expression as Expression | JSXElement | JSXFragment,
256+
)})`
257+
}
258+
259+
if (
260+
'expression' in node &&
261+
typeof node.type === 'string' &&
262+
tsWrapperExpressionNodeTypes.has(node.type)
263+
) {
264+
return this.compileExpression(
265+
node.expression as Expression | JSXElement | JSXFragment,
266+
)
267+
}
268+
}
269+
270+
/* c8 ignore next 3 -- defensive guard for malformed external AST nodes */
241271
if (!hasSourceRange(node)) {
242272
throw new Error('[jsx] Unable to read source range for expression node.')
243273
}
@@ -307,13 +337,163 @@ const collectRootJsxNodes = (root: Program | Expression | JSXElement | JSXFragme
307337
return nodes
308338
}
309339

340+
type StripEdit = {
341+
range: SourceRange
342+
replacement?: string
343+
}
344+
345+
const hasStringProperty = <K extends string>(
346+
value: unknown,
347+
key: K,
348+
): value is Record<K, string> => isObjectRecord(value) && typeof value[key] === 'string'
349+
350+
const hasSourceAndExpressionRanges = (
351+
value: unknown,
352+
): value is {
353+
type: string
354+
range: SourceRange
355+
expression: { range: SourceRange }
356+
} =>
357+
isObjectRecord(value) &&
358+
typeof value.type === 'string' &&
359+
hasSourceRange(value) &&
360+
'expression' in value &&
361+
hasSourceRange(value.expression)
362+
363+
const isTypeOnlyImportExport = (value: unknown): boolean =>
364+
hasStringProperty(value, 'importKind')
365+
? value.importKind === 'type'
366+
: hasStringProperty(value, 'exportKind') && value.exportKind === 'type'
367+
368+
const isTypeOnlyNode = (value: unknown): boolean => {
369+
if (!isObjectRecord(value) || typeof value.type !== 'string') {
370+
return false
371+
}
372+
373+
return [
374+
'TSTypeAnnotation',
375+
'TSTypeParameterDeclaration',
376+
'TSTypeAliasDeclaration',
377+
'TSInterfaceDeclaration',
378+
'TSDeclareFunction',
379+
'TSImportEqualsDeclaration',
380+
'TSNamespaceExportDeclaration',
381+
'TSModuleDeclaration',
382+
].includes(value.type)
383+
}
384+
385+
const createStripEditForTsWrapper = (
386+
value: unknown,
387+
source: string,
388+
): StripEdit | null => {
389+
if (!hasSourceAndExpressionRanges(value)) {
390+
return null
391+
}
392+
393+
if (
394+
value.type !== 'TSAsExpression' &&
395+
value.type !== 'TSSatisfiesExpression' &&
396+
value.type !== 'TSInstantiationExpression' &&
397+
value.type !== 'TSNonNullExpression' &&
398+
value.type !== 'TSTypeAssertion'
399+
) {
400+
return null
401+
}
402+
403+
const [exprStart, exprEnd] = value.expression.range
404+
return {
405+
range: value.range,
406+
replacement: source.slice(exprStart, exprEnd),
407+
}
408+
}
409+
410+
const collectTypeScriptStripEdits = (source: string, root: Program): StripEdit[] => {
411+
const edits: StripEdit[] = []
412+
413+
const walk = (value: unknown) => {
414+
if (!isObjectRecord(value)) {
415+
return
416+
}
417+
418+
if (Array.isArray(value)) {
419+
value.forEach(walk)
420+
return
421+
}
422+
423+
if (hasSourceRange(value)) {
424+
if (isTypeOnlyNode(value) || isTypeOnlyImportExport(value)) {
425+
edits.push({ range: value.range })
426+
return
427+
} else {
428+
const wrapperEdit = createStripEditForTsWrapper(value, source)
429+
if (wrapperEdit) {
430+
edits.push(wrapperEdit)
431+
return
432+
}
433+
}
434+
}
435+
436+
for (const entry of Object.values(value)) {
437+
walk(entry)
438+
}
439+
}
440+
441+
walk(root)
442+
return edits
443+
}
444+
445+
const rangeOverlaps = (first: SourceRange, second: SourceRange) =>
446+
first[0] < second[1] && second[0] < first[1]
447+
448+
const compareStripEditPriority = (first: StripEdit, second: StripEdit) => {
449+
const firstLength = first.range[1] - first.range[0]
450+
const secondLength = second.range[1] - second.range[0]
451+
452+
if (firstLength !== secondLength) {
453+
return secondLength - firstLength
454+
}
455+
456+
return compareByRangeStartDesc(first, second)
457+
}
458+
459+
const applyStripEdits = (magic: MagicString, edits: StripEdit[]) => {
460+
if (!edits.length) {
461+
return false
462+
}
463+
464+
const appliedRanges: SourceRange[] = []
465+
let changed = false
466+
467+
edits
468+
.slice()
469+
.sort(compareStripEditPriority)
470+
.forEach(edit => {
471+
/* c8 ignore next -- overlap handling is defensive after de-duplicated collection */
472+
if (appliedRanges.some(range => rangeOverlaps(range, edit.range))) {
473+
return
474+
}
475+
476+
const [start, end] = edit.range
477+
if (edit.replacement === undefined) {
478+
magic.remove(start, end)
479+
} else {
480+
magic.overwrite(start, end, edit.replacement)
481+
}
482+
appliedRanges.push(edit.range)
483+
changed = true
484+
})
485+
486+
return changed
487+
}
488+
310489
export function transpileJsxSource(
311490
source: string,
312491
options: TranspileJsxSourceOptions = {},
313492
): TranspileJsxSourceResult {
314493
const sourceType = options.sourceType ?? 'module'
315494
const createElementRef = options.createElement ?? 'React.createElement'
316495
const fragmentRef = options.fragment ?? 'React.Fragment'
496+
const typescriptMode = options.typescript ?? 'preserve'
317497

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

509+
const magic = new MagicString(source)
510+
const stripChanged =
511+
typescriptMode === 'strip'
512+
? applyStripEdits(magic, collectTypeScriptStripEdits(source, parsed.program))
513+
: false
514+
329515
const jsxRoots = collectRootJsxNodes(parsed.program)
330516
if (!jsxRoots.length) {
331-
return { code: source, changed: false }
517+
return {
518+
code: stripChanged ? magic.toString() : source,
519+
changed: stripChanged,
520+
}
332521
}
333522

334-
const builder = new SourceJsxReactBuilder(source, createElementRef, fragmentRef)
335-
const magic = new MagicString(source)
336-
523+
const builder = new SourceJsxReactBuilder(
524+
source,
525+
createElementRef,
526+
fragmentRef,
527+
typescriptMode === 'strip',
528+
)
337529
jsxRoots.sort(compareByRangeStartDesc).forEach(node => {
338530
magic.overwrite(node.range[0], node.range[1], builder.compile(node))
339531
})

0 commit comments

Comments
 (0)