From 2d897fe2fed4727e5f25cba613896179b854546d Mon Sep 17 00:00:00 2001 From: KCM Date: Mon, 16 Mar 2026 14:05:31 -0500 Subject: [PATCH 1/2] feat: add stripTypes in transpilation. --- README.md | 15 +++ docs/next-steps.md | 1 + package-lock.json | 4 +- package.json | 2 +- src/transpile.ts | 217 ++++++++++++++++++++++++++++++++++++++++- test/transpile.test.ts | 53 ++++++++++ 6 files changed, 285 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 7f26239..3244bc9 100644 --- a/README.md +++ b/README.md @@ -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): diff --git a/docs/next-steps.md b/docs/next-steps.md index 37a625a..1fe9f57 100644 --- a/docs/next-steps.md +++ b/docs/next-steps.md @@ -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. diff --git a/package-lock.json b/package-lock.json index fac0499..60ac3ee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@knighted/jsx", - "version": "1.9.0", + "version": "1.9.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@knighted/jsx", - "version": "1.9.0", + "version": "1.9.1", "license": "MIT", "dependencies": { "@napi-rs/wasm-runtime": "^1.1.1", diff --git a/package.json b/package.json index ef64c6d..bd8b350 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/transpile.ts b/src/transpile.ts index 749fd84..ac594f4 100644 --- a/src/transpile.ts +++ b/src/transpile.ts @@ -17,11 +17,13 @@ import { normalizeJsxText } from './shared/normalize-text.js' type AnyNode = Record 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 = { @@ -74,6 +76,7 @@ class SourceJsxReactBuilder { private readonly source: string, private readonly createElementRef: string, private readonly fragmentRef: string, + private readonly stripTypes: boolean, ) {} compile(node: JSXElement | JSXFragment): string { @@ -238,6 +241,44 @@ 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 && node.type === 'TSAsExpression') { + return this.compileExpression( + node.expression as Expression | JSXElement | JSXFragment, + ) + } + + if ('expression' in node && node.type === 'TSSatisfiesExpression') { + return this.compileExpression( + node.expression as Expression | JSXElement | JSXFragment, + ) + } + + if ('expression' in node && node.type === 'TSInstantiationExpression') { + return this.compileExpression( + node.expression as Expression | JSXElement | JSXFragment, + ) + } + + if ('expression' in node && node.type === 'TSNonNullExpression') { + return this.compileExpression( + node.expression as Expression | JSXElement | JSXFragment, + ) + } + + if ('expression' in node && node.type === 'TSTypeAssertion') { + return this.compileExpression( + node.expression as Expression | JSXElement | JSXFragment, + ) + } + } + if (!hasSourceRange(node)) { throw new Error('[jsx] Unable to read source range for expression node.') } @@ -307,6 +348,161 @@ const collectRootJsxNodes = (root: Program | Expression | JSXElement | JSXFragme return nodes } +type StripEdit = { + range: SourceRange + replacement?: string +} + +const hasStringProperty = ( + value: unknown, + key: K, +): value is Record => isObjectRecord(value) && typeof value[key] === 'string' + +const hasSourceAndExpressionRanges = ( + value: unknown, +): value is { + type: string + range: SourceRange + expression: { range: SourceRange } +} => { + if (!isObjectRecord(value)) { + return false + } + + if (typeof value.type !== 'string' || !hasSourceRange(value)) { + return false + } + + if (!('expression' in value)) { + return false + } + + return 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) || !isObjectRecord(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 }) + } else { + const wrapperEdit = createStripEditForTsWrapper(value, source) + if (wrapperEdit) { + edits.push(wrapperEdit) + } + } + } + + 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 => { + 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 = {}, @@ -314,6 +510,7 @@ export function transpileJsxSource( 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', @@ -326,14 +523,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)) }) diff --git a/test/transpile.test.ts b/test/transpile.test.ts index fc01e4d..0954184 100644 --- a/test/transpile.test.ts +++ b/test/transpile.test.ts @@ -152,4 +152,57 @@ const View = ({ maybeNull, maybeUndefined, extra }) => ( it('reports parser failures with jsx-prefixed diagnostics', () => { expect(() => transpileJsxSource('const view =
')).toThrow(/\[jsx\]/) }) + + it('preserves TypeScript syntax by default', () => { + const input = ` +type Props = { label: string } +const Button = ({ label }: Props): unknown => +` + + const result = transpileJsxSource(input, { sourceType: 'script' }) + + expect(result.changed).toBe(true) + expect(result.code).toContain('type Props = { label: string }') + expect(result.code).toContain('({ label }: Props): unknown =>') + }) + + it('strips TypeScript annotations and type-only declarations when enabled', () => { + const input = ` +type Props = { label: string } +const Button = ({ label }: Props): unknown => +const App = () =>