From c6ecd88968263b63b8033b96a4a38c11f04e0cc3 Mon Sep 17 00:00:00 2001 From: KCM Date: Wed, 18 Mar 2026 15:38:08 -0500 Subject: [PATCH 1/4] feat: transform entrypoint. --- docs/next-steps.md | 1 - package-lock.json | 359 +++++++++++++++++++++- package.json | 8 +- src/transform.ts | 335 ++++++++++++++++++++ test/__snapshots__/transform.test.ts.snap | 115 +++++++ test/transform.test.ts | 178 +++++++++++ 6 files changed, 992 insertions(+), 4 deletions(-) create mode 100644 src/transform.ts create mode 100644 test/__snapshots__/transform.test.ts.snap create mode 100644 test/transform.test.ts diff --git a/docs/next-steps.md b/docs/next-steps.md index 1fe9f57..37a625a 100644 --- a/docs/next-steps.md +++ b/docs/next-steps.md @@ -6,4 +6,3 @@ 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 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..2096934 --- /dev/null +++ b/src/transform.ts @@ -0,0 +1,335 @@ +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 & { + /* 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 bindings = Array.isArray(statement.specifiers) + ? statement.specifiers + .map(specifier => + toImportBinding(specifier, asImportKind(statement.importKind)), + ) + .filter((binding): binding is TransformImportBinding => binding !== null) + : [] + + imports.push({ + source: statement.source.value, + importKind: asImportKind(statement.importKind), + sideEffectOnly: bindings.length === 0, + bindings, + range: toSourceRange(statement), + }) + }) + + return imports +} + +const ensureSupportedOptions = (options: TransformJsxSourceOptions) => { + 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 { + ensureSupportedOptions(options) + + const sourceType = options.sourceType ?? 'module' + const typescriptMode = options.typescript ?? 'preserve' + const typescriptStripBackend = options.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: options.createElement, + fragment: options.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) { + return { + code: transformed.code || source, + changed: transformed.code !== 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..29b55c3 --- /dev/null +++ b/test/transform.test.ts @@ -0,0 +1,178 @@ +import { describe, expect, it } from 'vitest' + +import { transformJsxSource } from '../src/transform.js' +import { transpileJsxSource } from '../src/transpile.js' + +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 result = transformJsxSource(input, { + sourceType: 'script', + typescript: 'strip', + typescriptStripBackend: 'transpile-manual', + }) + + 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() + }) +}) From c9764035d910ba2d552bfc9fcf103a108aef5284 Mon Sep 17 00:00:00 2001 From: KCM Date: Wed, 18 Mar 2026 15:58:01 -0500 Subject: [PATCH 2/4] refactor: address comments. --- README.md | 46 ++++++++++++++++++++++++++++------------ src/transform.ts | 26 ++++++++++++++--------- test/transform.test.ts | 48 ++++++++++++++++++++++++++++++++++++++---- 3 files changed, 93 insertions(+), 27 deletions(-) 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/src/transform.ts b/src/transform.ts index 2096934..ada4b44 100644 --- a/src/transform.ts +++ b/src/transform.ts @@ -42,7 +42,9 @@ export type TransformImport = { range: SourceRange | null } -export type TransformJsxSourceOptions = TranspileJsxSourceOptions & { +export type TransformJsxSourceOptions = TranspileJsxSourceOptions + +type InternalTransformJsxSourceOptions = TransformJsxSourceOptions & { /* Internal compare switch for parity spikes. */ typescriptStripBackend?: TypeScriptStripBackend } @@ -213,7 +215,7 @@ const collectImportMetadata = (body: unknown): TransformImport[] => { return imports } -const ensureSupportedOptions = (options: TransformJsxSourceOptions) => { +const ensureSupportedOptions = (options: InternalTransformJsxSourceOptions) => { if ( options.sourceType !== undefined && options.sourceType !== 'module' && @@ -249,11 +251,13 @@ export function transformJsxSource( source: string, options: TransformJsxSourceOptions = {}, ): TransformJsxSourceResult { - ensureSupportedOptions(options) + const internalOptions = options as InternalTransformJsxSourceOptions + + ensureSupportedOptions(internalOptions) - const sourceType = options.sourceType ?? 'module' - const typescriptMode = options.typescript ?? 'preserve' - const typescriptStripBackend = options.typescriptStripBackend ?? 'oxc-transform' + const sourceType = internalOptions.sourceType ?? 'module' + const typescriptMode = internalOptions.typescript ?? 'preserve' + const typescriptStripBackend = internalOptions.typescriptStripBackend ?? 'oxc-transform' const parsed = parseSync( 'transform-jsx-source.tsx', @@ -275,8 +279,8 @@ export function transformJsxSource( const transpileBaseOptions: TranspileJsxSourceOptions = { sourceType, - createElement: options.createElement, - fragment: options.fragment, + createElement: internalOptions.createElement, + fragment: internalOptions.fragment, typescript: 'preserve', } @@ -316,9 +320,11 @@ export function transformJsxSource( const diagnostics = [...parserDiagnostics, ...transformDiagnostics] if (transformDiagnostics.length) { + const fallbackCode = transformed.code || source + return { - code: transformed.code || source, - changed: transformed.code !== source, + code: fallbackCode, + changed: fallbackCode !== source, imports, diagnostics, } diff --git a/test/transform.test.ts b/test/transform.test.ts index 29b55c3..f752c05 100644 --- a/test/transform.test.ts +++ b/test/transform.test.ts @@ -1,8 +1,12 @@ -import { describe, expect, it } from 'vitest' +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 = ` @@ -163,16 +167,52 @@ const Button = ({ label }: Props): unknown => type Value = string const value = (input satisfies string) ` - - const result = transformJsxSource(input, { + 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() + }) }) From 41901f3c13b79e569fad597b745bc08147af60b7 Mon Sep 17 00:00:00 2001 From: KCM Date: Wed, 18 Mar 2026 16:04:21 -0500 Subject: [PATCH 3/4] test: patch coverage. --- test/transform.test.ts | 68 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/test/transform.test.ts b/test/transform.test.ts index f752c05..aeb4f3c 100644 --- a/test/transform.test.ts +++ b/test/transform.test.ts @@ -215,4 +215,72 @@ const value = (input satisfies string) 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() + }) }) From d2fb20baf6fe84165fc1488c63bd2abf523e40c4 Mon Sep 17 00:00:00 2001 From: KCM Date: Wed, 18 Mar 2026 16:14:37 -0500 Subject: [PATCH 4/4] refactor: address comments. --- docs/next-steps.md | 1 + src/transform.ts | 9 ++++----- test/transform.test.ts | 46 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 51 insertions(+), 5 deletions(-) diff --git a/docs/next-steps.md b/docs/next-steps.md index 37a625a..5397ef7 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. **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/src/transform.ts b/src/transform.ts index ada4b44..aaebc7f 100644 --- a/src/transform.ts +++ b/src/transform.ts @@ -195,18 +195,17 @@ const collectImportMetadata = (body: unknown): TransformImport[] => { return } + const importKind = asImportKind(statement.importKind) const bindings = Array.isArray(statement.specifiers) ? statement.specifiers - .map(specifier => - toImportBinding(specifier, asImportKind(statement.importKind)), - ) + .map(specifier => toImportBinding(specifier, importKind)) .filter((binding): binding is TransformImportBinding => binding !== null) : [] imports.push({ source: statement.source.value, - importKind: asImportKind(statement.importKind), - sideEffectOnly: bindings.length === 0, + importKind, + sideEffectOnly: bindings.length === 0 && importKind === 'value', bindings, range: toSourceRange(statement), }) diff --git a/test/transform.test.ts b/test/transform.test.ts index aeb4f3c..5faac37 100644 --- a/test/transform.test.ts +++ b/test/transform.test.ts @@ -283,4 +283,50 @@ const value = (input satisfies string) 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() + }) })