diff --git a/README.md b/README.md
index 3244bc9..d2c46d2 100644
--- a/README.md
+++ b/README.md
@@ -68,39 +68,52 @@ const button = jsx`
document.body.append(button)
```
-### Source transpilation (`transpileJsxSource`)
+### Source transpilation (`transpileJsxSource` + `transformJsxSource`)
-Need to transform raw JSX source text (e.g. code typed in an editor) without Babel? Use `transpileJsxSource`:
+Need to transform raw JSX source text (for example, code typed in an editor) without Babel?
+Use one of these subpath exports:
+
+- `@knighted/jsx/transpile` for code-only output (`{ code, changed }`).
+- `@knighted/jsx/transform` for code plus parser-backed import metadata and diagnostics
+ (`{ code, changed, imports, diagnostics }`).
```ts
import { transpileJsxSource } from '@knighted/jsx/transpile'
+import { transformJsxSource } from '@knighted/jsx/transform'
const input = `
-const App = () => {
- return
-}
+import React from 'react'
+const App = () =>
`
-const { code } = transpileJsxSource(input)
-// -> const App = () => { return React.createElement("button", null, "click me") }
+const transpiled = transpileJsxSource(input)
+// -> { code, changed }
+
+const transformed = transformJsxSource(input, { typescript: 'strip' })
+// -> { code, changed, imports, diagnostics }
```
-By default this emits `React.createElement(...)` and `React.Fragment`. Override them when needed:
+Both entrypoints emit `React.createElement(...)` and `React.Fragment` by default. Override
+them when needed:
```ts
transpileJsxSource(input, {
createElement: '__jsx',
fragment: '__fragment',
})
+
+transformJsxSource(input, {
+ createElement: '__jsx',
+ fragment: '__fragment',
+})
```
-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:
+By default, TypeScript syntax is preserved in output. If your source needs to run directly as
+JavaScript, enable type stripping:
```ts
-transpileJsxSource(input, {
- typescript: 'strip',
-})
+transpileJsxSource(input, { typescript: 'strip' })
+transformJsxSource(input, { typescript: 'strip' })
```
Supported `typescript` modes:
@@ -109,6 +122,13 @@ Supported `typescript` modes:
- `'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.
+Environment note:
+
+- Both APIs are ESM-first and work in Node.
+- For direct browser usage of `@knighted/jsx/transform`, use a CDN/runtime that can resolve
+ `oxc-transform` for the browser build (for example, modern ESM CDNs that bundle or map
+ WASM/native bindings automatically).
+
### 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 1fe9f57..5397ef7 100644
--- a/docs/next-steps.md
+++ b/docs/next-steps.md
@@ -6,4 +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.
+5. **CLI init support for transform browser runtime** – Extend `@knighted/jsx init` so browser-oriented projects can opt into the `@knighted/jsx/transform` runtime path with the required `oxc-transform` browser/WASM setup (and clear install guidance for bundler vs CDN flows). Define a small contract for what init configures, then validate with fixture projects that call `transformJsxSource` in browser builds and confirm both successful execution and actionable failure diagnostics when bindings are missing.
diff --git a/package-lock.json b/package-lock.json
index 5976be9..bd0262b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,17 +1,18 @@
{
"name": "@knighted/jsx",
- "version": "1.9.2",
+ "version": "1.10.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@knighted/jsx",
- "version": "1.9.2",
+ "version": "1.10.0",
"license": "MIT",
"dependencies": {
"@napi-rs/wasm-runtime": "^1.1.1",
"magic-string": "^0.30.21",
"oxc-parser": "^0.116.0",
+ "oxc-transform": "^0.116.0",
"property-information": "^7.1.0",
"tar": "^7.5.11"
},
@@ -2116,6 +2117,326 @@
"url": "https://github.com/sponsors/Boshen"
}
},
+ "node_modules/@oxc-transform/binding-android-arm-eabi": {
+ "version": "0.116.0",
+ "resolved": "https://registry.npmjs.org/@oxc-transform/binding-android-arm-eabi/-/binding-android-arm-eabi-0.116.0.tgz",
+ "integrity": "sha512-Vf7CMaDsbII2TxssMNsKIDD7oNSV7OHvUJ6xiCYxbYhSFgznCzYwxyG5NRHqlwO+8dMF09LCFPyS8AGdKGNv2A==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-transform/binding-android-arm64": {
+ "version": "0.116.0",
+ "resolved": "https://registry.npmjs.org/@oxc-transform/binding-android-arm64/-/binding-android-arm64-0.116.0.tgz",
+ "integrity": "sha512-FB53dkrEZlAVO5L8dbRh23UmoQtw2X0gtGIN0aXhTrn/Oa02A7p3MU0bCNMS5hOJZyHDlPAKmEKIaXkCVMk0ig==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-transform/binding-darwin-arm64": {
+ "version": "0.116.0",
+ "resolved": "https://registry.npmjs.org/@oxc-transform/binding-darwin-arm64/-/binding-darwin-arm64-0.116.0.tgz",
+ "integrity": "sha512-AWqvogz3c8YZ+YVSIrZm4vUHZTI3xjCncpawHFuqirPYCXavenb9WptZy30yLl2KiJuc4I2pn+Dhd/CNcUKRSw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-transform/binding-darwin-x64": {
+ "version": "0.116.0",
+ "resolved": "https://registry.npmjs.org/@oxc-transform/binding-darwin-x64/-/binding-darwin-x64-0.116.0.tgz",
+ "integrity": "sha512-efFe19I+5na4WgcfJ0s532/7Z1ijHPJLXsPgxCqNcetmtmhPas1ynr5XwZ8yl2x9EsQ4iWQGkfMA82lHy7/s8w==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-transform/binding-freebsd-x64": {
+ "version": "0.116.0",
+ "resolved": "https://registry.npmjs.org/@oxc-transform/binding-freebsd-x64/-/binding-freebsd-x64-0.116.0.tgz",
+ "integrity": "sha512-fKO2b0jXsdkSjjH76GZlPrybpGKObAxW8qkl7hEihqIAxebzxzf3UI0Gu1Cmp+z/zd3YgGyDI4oPiabwtANvDg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-transform/binding-linux-arm-gnueabihf": {
+ "version": "0.116.0",
+ "resolved": "https://registry.npmjs.org/@oxc-transform/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.116.0.tgz",
+ "integrity": "sha512-O/xE52difAtp5uhI7avX5gO3ssb+TOsg9TYApffRZr5xunvN3t1MicrGxt0DVFlQbTMvltU5+tCZQw1uEibo0Q==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-transform/binding-linux-arm-musleabihf": {
+ "version": "0.116.0",
+ "resolved": "https://registry.npmjs.org/@oxc-transform/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.116.0.tgz",
+ "integrity": "sha512-0XqeERexv6djB6kUtS3gfSyzgmsZiFJItgbeum39mmL8zgzRFpkRj319A1mNt+Gv2S1CokqB/nRDeBUU0fYYfw==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-transform/binding-linux-arm64-gnu": {
+ "version": "0.116.0",
+ "resolved": "https://registry.npmjs.org/@oxc-transform/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.116.0.tgz",
+ "integrity": "sha512-DfyFbtOSBlRmmk+kEPP61xuvzPNNYd03IePInP9pkRckmoqS6GhUHnNkXw7BdA6bxBlXrAKNSZbUjnJcMS939Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-transform/binding-linux-arm64-musl": {
+ "version": "0.116.0",
+ "resolved": "https://registry.npmjs.org/@oxc-transform/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.116.0.tgz",
+ "integrity": "sha512-XvzxTwhECdcCnbgoMQW9LXxgohAfzaqzHjRvd+SEdkdYOjch9wuXA/rFGe7pkk0JLHB3cWGnUiMSX2Vu6ZBxcw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-transform/binding-linux-ppc64-gnu": {
+ "version": "0.116.0",
+ "resolved": "https://registry.npmjs.org/@oxc-transform/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.116.0.tgz",
+ "integrity": "sha512-AdqcXIn2tQmVLVisKzbeIZjzsBmc6JA+kfx32YsL5JD0ylmCCZz8W5mCXPX+BkrzdKHjI+17KbpUptBhpZPSLg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-transform/binding-linux-riscv64-gnu": {
+ "version": "0.116.0",
+ "resolved": "https://registry.npmjs.org/@oxc-transform/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.116.0.tgz",
+ "integrity": "sha512-7UWXpx8Y4WJ64ezOfPcqqVaC4HbZ5Pjuf09pIXYMFwWDJMxVYXgJG/VKDsbc3gH4zGKkQfSuga7yC26NcoPZiQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-transform/binding-linux-riscv64-musl": {
+ "version": "0.116.0",
+ "resolved": "https://registry.npmjs.org/@oxc-transform/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.116.0.tgz",
+ "integrity": "sha512-Tu8zqxccV/zczmaxohSdpabe2yRU8giywDiqjMCwAT2MZORZ7DRtBQgPQ1QyG9e4U26RqsUWk5dz6uZRGuJnQQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-transform/binding-linux-s390x-gnu": {
+ "version": "0.116.0",
+ "resolved": "https://registry.npmjs.org/@oxc-transform/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.116.0.tgz",
+ "integrity": "sha512-CRlr6ymctdxwlcjWTWH7DIIVkaxWm/8gpOkVUx2BGGd3miLQ+Oxi3azg9ldQCiyU2hefKoLlsbkp73Mwrirlcg==",
+ "cpu": [
+ "s390x"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-transform/binding-linux-x64-gnu": {
+ "version": "0.116.0",
+ "resolved": "https://registry.npmjs.org/@oxc-transform/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.116.0.tgz",
+ "integrity": "sha512-ltGhSvim+dSRroHgEPSxXzaGaeXQv+p9OGf25lCwrXzvr0+wwmck3I3XM3F0PWH8H3eIUi9L4dZedF4NhkQTig==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-transform/binding-linux-x64-musl": {
+ "version": "0.116.0",
+ "resolved": "https://registry.npmjs.org/@oxc-transform/binding-linux-x64-musl/-/binding-linux-x64-musl-0.116.0.tgz",
+ "integrity": "sha512-YVwQbAJwpzKkR5robMVDkmfCO2TYH1x29fFap2pXVSp8pHCD01S9DnrQPKy5jevKFrQXJZleihvOXsmbEQCOTA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-transform/binding-openharmony-arm64": {
+ "version": "0.116.0",
+ "resolved": "https://registry.npmjs.org/@oxc-transform/binding-openharmony-arm64/-/binding-openharmony-arm64-0.116.0.tgz",
+ "integrity": "sha512-gQ51zTtFFg0YFGSI0i0AxB9FIUkeYQAkyJE3hMkvzzQQa0luOgEWbTC5vym5AL3Jab/VSvAQ46i9hovW33FbCQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-transform/binding-wasm32-wasi": {
+ "version": "0.116.0",
+ "resolved": "https://registry.npmjs.org/@oxc-transform/binding-wasm32-wasi/-/binding-wasm32-wasi-0.116.0.tgz",
+ "integrity": "sha512-FGZyQxbghADcBsaLIAl7glvXfSjMxYEFsnxu9ESeDXJI/Fn7ck7w5eYAL/FfGFQFNokC2lD2VLKUC94eAyLxuQ==",
+ "cpu": [
+ "wasm32"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@napi-rs/wasm-runtime": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@oxc-transform/binding-win32-arm64-msvc": {
+ "version": "0.116.0",
+ "resolved": "https://registry.npmjs.org/@oxc-transform/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.116.0.tgz",
+ "integrity": "sha512-NTAfRJ12dV1oxGmko+7ntFE4vbeLMQn6FNXtmDNtb8YvzPLGSqqoBr86ZAAXmfC00j0Ku4h2MJP5CybpbgWKxA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-transform/binding-win32-ia32-msvc": {
+ "version": "0.116.0",
+ "resolved": "https://registry.npmjs.org/@oxc-transform/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.116.0.tgz",
+ "integrity": "sha512-X+XKp3Y0Xus7yLG0XsYA4oEf/wK1zSEhUY2f80OTQlOCYII7DLjVaR8Y3lA+UG3WhinEtK3ykh4T97q5zUyxVQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-transform/binding-win32-x64-msvc": {
+ "version": "0.116.0",
+ "resolved": "https://registry.npmjs.org/@oxc-transform/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.116.0.tgz",
+ "integrity": "sha512-9gfpNUeFMAAatZ21ygvcmRT55xNHF1JbBobFKjX2ZDF43QiUAQYhUXS9PeekYsMbDtoocMMydh+380hZ5trjHQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
"node_modules/@oxlint/binding-android-arm-eabi": {
"version": "1.51.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.51.0.tgz",
@@ -6500,6 +6821,40 @@
"@oxc-parser/binding-win32-x64-msvc": "0.116.0"
}
},
+ "node_modules/oxc-transform": {
+ "version": "0.116.0",
+ "resolved": "https://registry.npmjs.org/oxc-transform/-/oxc-transform-0.116.0.tgz",
+ "integrity": "sha512-8V1nWua+1JuWeVkUIoLrkI4B/ua0/yembsWQnzxWjcom3DNE07byPJh7zPReZnK/MZ0d+bkvudcXJbSWv2tZpA==",
+ "license": "MIT",
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/Boshen"
+ },
+ "optionalDependencies": {
+ "@oxc-transform/binding-android-arm-eabi": "0.116.0",
+ "@oxc-transform/binding-android-arm64": "0.116.0",
+ "@oxc-transform/binding-darwin-arm64": "0.116.0",
+ "@oxc-transform/binding-darwin-x64": "0.116.0",
+ "@oxc-transform/binding-freebsd-x64": "0.116.0",
+ "@oxc-transform/binding-linux-arm-gnueabihf": "0.116.0",
+ "@oxc-transform/binding-linux-arm-musleabihf": "0.116.0",
+ "@oxc-transform/binding-linux-arm64-gnu": "0.116.0",
+ "@oxc-transform/binding-linux-arm64-musl": "0.116.0",
+ "@oxc-transform/binding-linux-ppc64-gnu": "0.116.0",
+ "@oxc-transform/binding-linux-riscv64-gnu": "0.116.0",
+ "@oxc-transform/binding-linux-riscv64-musl": "0.116.0",
+ "@oxc-transform/binding-linux-s390x-gnu": "0.116.0",
+ "@oxc-transform/binding-linux-x64-gnu": "0.116.0",
+ "@oxc-transform/binding-linux-x64-musl": "0.116.0",
+ "@oxc-transform/binding-openharmony-arm64": "0.116.0",
+ "@oxc-transform/binding-wasm32-wasi": "0.116.0",
+ "@oxc-transform/binding-win32-arm64-msvc": "0.116.0",
+ "@oxc-transform/binding-win32-ia32-msvc": "0.116.0",
+ "@oxc-transform/binding-win32-x64-msvc": "0.116.0"
+ }
+ },
"node_modules/oxlint": {
"version": "1.51.0",
"resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.51.0.tgz",
diff --git a/package.json b/package.json
index 665ba25..a50dfad 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@knighted/jsx",
- "version": "1.9.2",
+ "version": "1.10.0",
"description": "Runtime JSX tagged template that renders DOM or React trees anywhere with or without a build step.",
"keywords": [
"jsx runtime",
@@ -106,6 +106,11 @@
"import": "./dist/transpile.js",
"default": "./dist/transpile.js"
},
+ "./transform": {
+ "types": "./dist/transform.d.ts",
+ "import": "./dist/transform.js",
+ "default": "./dist/transform.js"
+ },
"./loader": {
"import": "./dist/loader/jsx.js",
"default": "./dist/loader/jsx.js"
@@ -185,6 +190,7 @@
"@napi-rs/wasm-runtime": "^1.1.1",
"magic-string": "^0.30.21",
"oxc-parser": "^0.116.0",
+ "oxc-transform": "^0.116.0",
"property-information": "^7.1.0",
"tar": "^7.5.11"
},
diff --git a/src/transform.ts b/src/transform.ts
new file mode 100644
index 0000000..aaebc7f
--- /dev/null
+++ b/src/transform.ts
@@ -0,0 +1,340 @@
+import { parseSync } from 'oxc-parser'
+import { transformSync } from 'oxc-transform'
+import { transpileJsxSource, type TranspileJsxSourceOptions } from './transpile.js'
+
+type SourceRange = [number, number]
+type TransformSourceType = 'module' | 'script'
+type TypeScriptStripBackend = 'oxc-transform' | 'transpile-manual'
+type DiagnosticSource = 'parser' | 'transform'
+type OxcDiagnosticLike = {
+ severity: string
+ message: string
+ labels?: Array<{
+ start: number
+ end: number
+ }>
+ codeframe: string | null
+ helpMessage: string | null
+}
+
+export type TransformDiagnostic = {
+ source: DiagnosticSource
+ severity: string
+ message: string
+ range: SourceRange | null
+ codeframe: string | null
+ helpMessage: string | null
+}
+
+export type TransformImportBinding = {
+ kind: 'default' | 'named' | 'namespace'
+ local: string
+ imported: string | null
+ isTypeOnly: boolean
+ range: SourceRange | null
+}
+
+export type TransformImport = {
+ source: string
+ importKind: 'type' | 'value'
+ sideEffectOnly: boolean
+ bindings: TransformImportBinding[]
+ range: SourceRange | null
+}
+
+export type TransformJsxSourceOptions = TranspileJsxSourceOptions
+
+type InternalTransformJsxSourceOptions = TransformJsxSourceOptions & {
+ /* Internal compare switch for parity spikes. */
+ typescriptStripBackend?: TypeScriptStripBackend
+}
+
+export type TransformJsxSourceResult = {
+ code: string
+ changed: boolean
+ imports: TransformImport[]
+ diagnostics: TransformDiagnostic[]
+}
+
+const createParserOptions = (sourceType: TransformSourceType) => ({
+ lang: 'tsx' as const,
+ sourceType,
+ range: true,
+ preserveParens: true,
+})
+
+const isObjectRecord = (value: unknown): value is Record =>
+ typeof value === 'object' && value !== null
+
+const isSourceRange = (value: unknown): value is SourceRange =>
+ Array.isArray(value) &&
+ value.length === 2 &&
+ typeof value[0] === 'number' &&
+ typeof value[1] === 'number'
+
+const toSourceRange = (value: unknown): SourceRange | null => {
+ if (!isObjectRecord(value) || !isSourceRange(value.range)) {
+ return null
+ }
+
+ return value.range
+}
+
+const asImportKind = (value: unknown): 'type' | 'value' =>
+ value === 'type' ? 'type' : 'value'
+
+const toDiagnostic = (
+ source: DiagnosticSource,
+ diagnostic: OxcDiagnosticLike,
+): TransformDiagnostic => {
+ const firstLabel = diagnostic.labels?.[0]
+ const range: SourceRange | null =
+ firstLabel &&
+ typeof firstLabel.start === 'number' &&
+ typeof firstLabel.end === 'number'
+ ? [firstLabel.start, firstLabel.end]
+ : null
+
+ return {
+ source,
+ severity: diagnostic.severity,
+ message: diagnostic.message,
+ range,
+ codeframe: diagnostic.codeframe,
+ helpMessage: diagnostic.helpMessage,
+ }
+}
+
+const toImportBinding = (
+ specifier: unknown,
+ declarationImportKind: 'type' | 'value',
+): TransformImportBinding | null => {
+ if (!isObjectRecord(specifier) || typeof specifier.type !== 'string') {
+ return null
+ }
+
+ if (specifier.type === 'ImportDefaultSpecifier') {
+ const localName =
+ isObjectRecord(specifier.local) && typeof specifier.local.name === 'string'
+ ? specifier.local.name
+ : null
+
+ if (!localName) {
+ return null
+ }
+
+ return {
+ kind: 'default',
+ local: localName,
+ imported: 'default',
+ isTypeOnly: declarationImportKind === 'type',
+ range: toSourceRange(specifier),
+ }
+ }
+
+ if (specifier.type === 'ImportNamespaceSpecifier') {
+ const localName =
+ isObjectRecord(specifier.local) && typeof specifier.local.name === 'string'
+ ? specifier.local.name
+ : null
+
+ if (!localName) {
+ return null
+ }
+
+ return {
+ kind: 'namespace',
+ local: localName,
+ imported: '*',
+ isTypeOnly: declarationImportKind === 'type',
+ range: toSourceRange(specifier),
+ }
+ }
+
+ if (specifier.type === 'ImportSpecifier') {
+ const importedName =
+ isObjectRecord(specifier.imported) && typeof specifier.imported.name === 'string'
+ ? specifier.imported.name
+ : null
+ const localName =
+ isObjectRecord(specifier.local) && typeof specifier.local.name === 'string'
+ ? specifier.local.name
+ : null
+
+ if (!importedName || !localName) {
+ return null
+ }
+
+ return {
+ kind: 'named',
+ local: localName,
+ imported: importedName,
+ isTypeOnly:
+ declarationImportKind === 'type' || asImportKind(specifier.importKind) === 'type',
+ range: toSourceRange(specifier),
+ }
+ }
+
+ return null
+}
+
+const collectImportMetadata = (body: unknown): TransformImport[] => {
+ if (!Array.isArray(body)) {
+ return []
+ }
+
+ const imports: TransformImport[] = []
+
+ body.forEach(statement => {
+ if (
+ !isObjectRecord(statement) ||
+ statement.type !== 'ImportDeclaration' ||
+ !isObjectRecord(statement.source) ||
+ typeof statement.source.value !== 'string'
+ ) {
+ return
+ }
+
+ const importKind = asImportKind(statement.importKind)
+ const bindings = Array.isArray(statement.specifiers)
+ ? statement.specifiers
+ .map(specifier => toImportBinding(specifier, importKind))
+ .filter((binding): binding is TransformImportBinding => binding !== null)
+ : []
+
+ imports.push({
+ source: statement.source.value,
+ importKind,
+ sideEffectOnly: bindings.length === 0 && importKind === 'value',
+ bindings,
+ range: toSourceRange(statement),
+ })
+ })
+
+ return imports
+}
+
+const ensureSupportedOptions = (options: InternalTransformJsxSourceOptions) => {
+ if (
+ options.sourceType !== undefined &&
+ options.sourceType !== 'module' &&
+ options.sourceType !== 'script'
+ ) {
+ throw new Error(
+ `[jsx] Unsupported sourceType "${String(options.sourceType)}". Use "module" or "script".`,
+ )
+ }
+
+ if (
+ options.typescript !== undefined &&
+ options.typescript !== 'preserve' &&
+ options.typescript !== 'strip'
+ ) {
+ throw new Error(
+ `[jsx] Unsupported typescript mode "${String(options.typescript)}". Use "preserve" or "strip".`,
+ )
+ }
+
+ if (
+ options.typescriptStripBackend !== undefined &&
+ options.typescriptStripBackend !== 'oxc-transform' &&
+ options.typescriptStripBackend !== 'transpile-manual'
+ ) {
+ throw new Error(
+ `[jsx] Unsupported typescriptStripBackend "${String(options.typescriptStripBackend)}". Use "oxc-transform" or "transpile-manual".`,
+ )
+ }
+}
+
+export function transformJsxSource(
+ source: string,
+ options: TransformJsxSourceOptions = {},
+): TransformJsxSourceResult {
+ const internalOptions = options as InternalTransformJsxSourceOptions
+
+ ensureSupportedOptions(internalOptions)
+
+ const sourceType = internalOptions.sourceType ?? 'module'
+ const typescriptMode = internalOptions.typescript ?? 'preserve'
+ const typescriptStripBackend = internalOptions.typescriptStripBackend ?? 'oxc-transform'
+
+ const parsed = parseSync(
+ 'transform-jsx-source.tsx',
+ source,
+ createParserOptions(sourceType),
+ )
+
+ const parserDiagnostics = parsed.errors.map(error => toDiagnostic('parser', error))
+ const imports = collectImportMetadata(parsed.program.body)
+
+ if (parserDiagnostics.length) {
+ return {
+ code: source,
+ changed: false,
+ imports,
+ diagnostics: parserDiagnostics,
+ }
+ }
+
+ const transpileBaseOptions: TranspileJsxSourceOptions = {
+ sourceType,
+ createElement: internalOptions.createElement,
+ fragment: internalOptions.fragment,
+ typescript: 'preserve',
+ }
+
+ if (typescriptMode !== 'strip') {
+ const result = transpileJsxSource(source, transpileBaseOptions)
+ return {
+ code: result.code,
+ changed: result.changed,
+ imports,
+ diagnostics: parserDiagnostics,
+ }
+ }
+
+ if (typescriptStripBackend === 'transpile-manual') {
+ const result = transpileJsxSource(source, {
+ ...transpileBaseOptions,
+ typescript: 'strip',
+ })
+
+ return {
+ code: result.code,
+ changed: result.changed,
+ imports,
+ diagnostics: parserDiagnostics,
+ }
+ }
+
+ const transformed = transformSync('transform-jsx-source.tsx', source, {
+ lang: 'tsx',
+ sourceType,
+ jsx: 'preserve',
+ typescript: {},
+ })
+ const transformDiagnostics = transformed.errors.map(error =>
+ toDiagnostic('transform', error),
+ )
+ const diagnostics = [...parserDiagnostics, ...transformDiagnostics]
+
+ if (transformDiagnostics.length) {
+ const fallbackCode = transformed.code || source
+
+ return {
+ code: fallbackCode,
+ changed: fallbackCode !== source,
+ imports,
+ diagnostics,
+ }
+ }
+
+ const jsxResult = transpileJsxSource(transformed.code, transpileBaseOptions)
+
+ return {
+ code: jsxResult.code,
+ changed: jsxResult.code !== source,
+ imports,
+ diagnostics,
+ }
+}
diff --git a/test/__snapshots__/transform.test.ts.snap b/test/__snapshots__/transform.test.ts.snap
new file mode 100644
index 0000000..4138aa1
--- /dev/null
+++ b/test/__snapshots__/transform.test.ts.snap
@@ -0,0 +1,115 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`transformJsxSource() > produces a stable parser diagnostics snapshot shape 1`] = `
+[
+ {
+ "codeframe": "",
+ "helpMessage": null,
+ "message": "Expected \`}\` but found \`EOF\`",
+ "range": [
+ 8,
+ 8,
+ ],
+ "severity": "Error",
+ "source": "parser",
+ },
+]
+`;
+
+exports[`transformJsxSource() > produces deterministic import metadata snapshots 1`] = `
+[
+ {
+ "bindings": [
+ {
+ "imported": "default",
+ "isTypeOnly": false,
+ "kind": "default",
+ "local": "React",
+ "range": [
+ 8,
+ 13,
+ ],
+ },
+ {
+ "imported": "useMemo",
+ "isTypeOnly": false,
+ "kind": "named",
+ "local": "memo",
+ "range": [
+ 17,
+ 32,
+ ],
+ },
+ {
+ "imported": "FC",
+ "isTypeOnly": true,
+ "kind": "named",
+ "local": "FC",
+ "range": [
+ 34,
+ 41,
+ ],
+ },
+ ],
+ "importKind": "value",
+ "range": [
+ 1,
+ 56,
+ ],
+ "sideEffectOnly": false,
+ "source": "react",
+ },
+ {
+ "bindings": [
+ {
+ "imported": "*",
+ "isTypeOnly": false,
+ "kind": "namespace",
+ "local": "Theme",
+ "range": [
+ 64,
+ 74,
+ ],
+ },
+ ],
+ "importKind": "value",
+ "range": [
+ 57,
+ 92,
+ ],
+ "sideEffectOnly": false,
+ "source": "@app/theme",
+ },
+ {
+ "bindings": [
+ {
+ "imported": "Palette",
+ "isTypeOnly": true,
+ "kind": "named",
+ "local": "Palette",
+ "range": [
+ 107,
+ 114,
+ ],
+ },
+ ],
+ "importKind": "type",
+ "range": [
+ 93,
+ 133,
+ ],
+ "sideEffectOnly": false,
+ "source": "./palette",
+ },
+ {
+ "bindings": [],
+ "importKind": "value",
+ "range": [
+ 134,
+ 152,
+ ],
+ "sideEffectOnly": true,
+ "source": "./app.css",
+ },
+]
+`;
diff --git a/test/transform.test.ts b/test/transform.test.ts
new file mode 100644
index 0000000..5faac37
--- /dev/null
+++ b/test/transform.test.ts
@@ -0,0 +1,332 @@
+import { describe, expect, it, vi } from 'vitest'
+
+import { transformJsxSource } from '../src/transform.js'
+import { transpileJsxSource } from '../src/transpile.js'
+
+type ManualStripBackendOptions = Parameters[1] & {
+ typescriptStripBackend: 'transpile-manual'
+}
+
+describe('transformJsxSource()', () => {
+ it('produces deterministic import metadata snapshots', () => {
+ const input = `
+import React, { useMemo as memo, type FC } from 'react'
+import * as Theme from '@app/theme'
+import type { Palette } from './palette'
+import './app.css'
+
+const App: FC = () => {memo(() => Theme, [])}
+`
+
+ const first = transformJsxSource(input, { typescript: 'strip' })
+ const second = transformJsxSource(input, { typescript: 'strip' })
+
+ expect(first.imports).toEqual(second.imports)
+ expect(first.imports).toMatchSnapshot()
+ })
+
+ it('produces a stable parser diagnostics snapshot shape', () => {
+ const result = transformJsxSource('import {')
+ const normalizedDiagnostics = result.diagnostics.map(diagnostic => ({
+ ...diagnostic,
+ codeframe: diagnostic.codeframe ? '' : null,
+ }))
+
+ expect(normalizedDiagnostics).toMatchSnapshot()
+ })
+
+ it('matches transpile output in preserve mode', () => {
+ const input = `
+const App = () => (
+ <>
+
+ >
+)
+`
+
+ const transformed = transformJsxSource(input)
+ const transpiled = transpileJsxSource(input)
+
+ expect(transformed.code).toBe(transpiled.code)
+ expect(transformed.changed).toBe(transpiled.changed)
+ expect(transformed.imports).toEqual([])
+ expect(transformed.diagnostics).toEqual([])
+ })
+
+ it('returns parser diagnostics with source ranges', () => {
+ const input = 'import {'
+
+ const result = transformJsxSource(input)
+
+ expect(result.changed).toBe(false)
+ expect(result.code).toBe(input)
+ expect(result.diagnostics.length).toBeGreaterThan(0)
+ expect(result.diagnostics[0]?.source).toBe('parser')
+ expect(result.diagnostics[0]?.range).toEqual([8, 8])
+ })
+
+ it('extracts normalized import metadata in declaration order', () => {
+ const input = `
+import React, { useState as useAlias, type FC } from 'react'
+import * as Shared from '@scope/shared'
+import type { Props } from './types'
+import './styles.css'
+
+const App: FC = () =>
+`
+
+ const result = transformJsxSource(input, { typescript: 'strip' })
+
+ expect(result.diagnostics).toEqual([])
+ expect(result.imports).toMatchObject([
+ {
+ source: 'react',
+ importKind: 'value',
+ sideEffectOnly: false,
+ bindings: [
+ {
+ kind: 'default',
+ local: 'React',
+ imported: 'default',
+ isTypeOnly: false,
+ },
+ {
+ kind: 'named',
+ local: 'useAlias',
+ imported: 'useState',
+ isTypeOnly: false,
+ },
+ {
+ kind: 'named',
+ local: 'FC',
+ imported: 'FC',
+ isTypeOnly: true,
+ },
+ ],
+ },
+ {
+ source: '@scope/shared',
+ importKind: 'value',
+ sideEffectOnly: false,
+ bindings: [
+ {
+ kind: 'namespace',
+ local: 'Shared',
+ imported: '*',
+ isTypeOnly: false,
+ },
+ ],
+ },
+ {
+ source: './types',
+ importKind: 'type',
+ sideEffectOnly: false,
+ bindings: [
+ {
+ kind: 'named',
+ local: 'Props',
+ imported: 'Props',
+ isTypeOnly: true,
+ },
+ ],
+ },
+ {
+ source: './styles.css',
+ importKind: 'value',
+ sideEffectOnly: true,
+ bindings: [],
+ },
+ ])
+
+ expect(result.imports.every(entry => entry.range !== null)).toBe(true)
+ expect(result.imports[0]?.bindings.every(binding => binding.range !== null)).toBe(
+ true,
+ )
+ })
+
+ it('uses oxc-transform strip backend by default', () => {
+ const input = `
+type Props = { label: string }
+const Button = ({ label }: Props): unknown =>
+`
+
+ const result = transformJsxSource(input, {
+ sourceType: 'script',
+ typescript: 'strip',
+ })
+
+ expect(result.diagnostics).toEqual([])
+ expect(result.code).not.toContain('type Props =')
+ expect(result.code).not.toContain(': Props')
+ expect(result.code).toContain('React.createElement("button", null, label)')
+ expect(() => new Function(result.code)).not.toThrow()
+ })
+
+ it('supports manual strip backend for side-by-side parity checks', () => {
+ const input = `
+type Value = string
+const value = (input satisfies string)
+`
+ const internalOptions: ManualStripBackendOptions = {
+ sourceType: 'script',
+ typescript: 'strip',
+ typescriptStripBackend: 'transpile-manual',
+ }
+
+ const result = transformJsxSource(input, internalOptions)
+
+ expect(result.diagnostics).toEqual([])
+ expect(result.code).not.toContain('type Value =')
+ expect(result.code).not.toContain('satisfies string')
+ expect(() => new Function(result.code)).not.toThrow()
+ })
+
+ it('keeps changed aligned with returned code when transform emits diagnostics', async () => {
+ const source = "const value: string = 'ok'"
+ vi.resetModules()
+ vi.doMock('oxc-transform', () => ({
+ transformSync: () => ({
+ code: '',
+ helpersUsed: {},
+ errors: [
+ {
+ severity: 'Error',
+ message: 'mock transform failure',
+ labels: [{ start: 0, end: 0 }],
+ codeframe: null,
+ helpMessage: null,
+ },
+ ],
+ }),
+ }))
+
+ const { transformJsxSource: mockedTransformJsxSource } =
+ await import('../src/transform.js')
+
+ const result = mockedTransformJsxSource(source, {
+ sourceType: 'script',
+ typescript: 'strip',
+ })
+
+ expect(result.code).toBe(source)
+ expect(result.changed).toBe(false)
+ expect(result.diagnostics[0]?.source).toBe('transform')
+
+ vi.doUnmock('oxc-transform')
+ vi.resetModules()
+ })
+
+ it('throws for unsupported sourceType values', () => {
+ expect(() =>
+ transformJsxSource('const value = 1', {
+ sourceType: 'invalid' as unknown as 'module',
+ }),
+ ).toThrow(/Unsupported sourceType/)
+ })
+
+ it('throws for unsupported typescript mode values', () => {
+ expect(() =>
+ transformJsxSource('const value = 1', {
+ typescript: 'erase-all' as unknown as 'preserve',
+ }),
+ ).toThrow(/Unsupported typescript mode/)
+ })
+
+ it('throws for unsupported internal strip backend values', () => {
+ const invalidBackendOptions = {
+ sourceType: 'script' as const,
+ typescript: 'strip' as const,
+ typescriptStripBackend: 'unknown-backend',
+ } as unknown as Parameters[1]
+
+ expect(() => transformJsxSource('const value = 1', invalidBackendOptions)).toThrow(
+ /Unsupported typescriptStripBackend/,
+ )
+ })
+
+ it('normalizes import metadata even when range fields are missing', async () => {
+ vi.resetModules()
+ vi.doMock('oxc-parser', () => ({
+ parseSync: () => ({
+ errors: [],
+ program: {
+ body: [
+ {
+ type: 'ImportDeclaration',
+ source: { value: 'pkg' },
+ importKind: 'type',
+ specifiers: [
+ {
+ type: 'ImportSpecifier',
+ imported: { name: 'Thing' },
+ local: { name: 'Thing' },
+ importKind: 'value',
+ },
+ ],
+ },
+ ],
+ },
+ }),
+ }))
+
+ const { transformJsxSource: mockedTransformJsxSource } =
+ await import('../src/transform.js')
+
+ const result = mockedTransformJsxSource('const value = 1')
+
+ expect(result.imports).toHaveLength(1)
+ expect(result.imports[0]?.range).toBeNull()
+ expect(result.imports[0]?.bindings[0]?.range).toBeNull()
+ expect(result.imports[0]?.importKind).toBe('type')
+ expect(result.imports[0]?.bindings[0]?.isTypeOnly).toBe(true)
+
+ vi.doUnmock('oxc-parser')
+ vi.resetModules()
+ })
+
+ it('marks sideEffectOnly only for value imports with no bindings', async () => {
+ vi.resetModules()
+ vi.doMock('oxc-parser', () => ({
+ parseSync: () => ({
+ errors: [],
+ program: {
+ body: [
+ {
+ type: 'ImportDeclaration',
+ source: { value: './value-side-effect' },
+ importKind: 'value',
+ specifiers: [],
+ },
+ {
+ type: 'ImportDeclaration',
+ source: { value: './type-side-effect' },
+ importKind: 'type',
+ specifiers: [],
+ },
+ ],
+ },
+ }),
+ }))
+
+ const { transformJsxSource: mockedTransformJsxSource } =
+ await import('../src/transform.js')
+
+ const result = mockedTransformJsxSource('const value = 1')
+
+ expect(result.imports).toMatchObject([
+ {
+ source: './value-side-effect',
+ importKind: 'value',
+ sideEffectOnly: true,
+ },
+ {
+ source: './type-side-effect',
+ importKind: 'type',
+ sideEffectOnly: false,
+ },
+ ])
+
+ vi.doUnmock('oxc-parser')
+ vi.resetModules()
+ })
+})