diff --git a/package-lock.json b/package-lock.json
index 9940a86..f1ef7b7 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@knighted/jsx",
- "version": "1.11.0",
+ "version": "1.12.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@knighted/jsx",
- "version": "1.11.0",
+ "version": "1.12.0",
"license": "MIT",
"dependencies": {
"@napi-rs/wasm-runtime": "^1.1.1",
@@ -40,7 +40,7 @@
"jsdom": "^27.2.0",
"lint-staged": "^16.2.7",
"lit": "^3.2.1",
- "next": "^16.1.6",
+ "next": "^16.1.7",
"oxlint": "^1.51.0",
"prettier": "^3.7.3",
"react": "^19.0.0",
@@ -1646,16 +1646,16 @@
}
},
"node_modules/@next/env": {
- "version": "16.1.6",
- "resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.6.tgz",
- "integrity": "sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ==",
+ "version": "16.1.7",
+ "resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.7.tgz",
+ "integrity": "sha512-rJJbIdJB/RQr2F1nylZr/PJzamvNNhfr3brdKP6s/GW850jbtR70QlSfFselvIBbcPUOlQwBakexjFzqLzF6pg==",
"dev": true,
"license": "MIT"
},
"node_modules/@next/swc-darwin-arm64": {
- "version": "16.1.6",
- "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.6.tgz",
- "integrity": "sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw==",
+ "version": "16.1.7",
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.7.tgz",
+ "integrity": "sha512-b2wWIE8sABdyafc4IM8r5Y/dS6kD80JRtOGrUiKTsACFQfWWgUQ2NwoUX1yjFMXVsAwcQeNpnucF2ZrujsBBPg==",
"cpu": [
"arm64"
],
@@ -1670,9 +1670,9 @@
}
},
"node_modules/@next/swc-darwin-x64": {
- "version": "16.1.6",
- "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.6.tgz",
- "integrity": "sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ==",
+ "version": "16.1.7",
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.7.tgz",
+ "integrity": "sha512-zcnVaaZulS1WL0Ss38R5Q6D2gz7MtBu8GZLPfK+73D/hp4GFMrC2sudLky1QibfV7h6RJBJs/gOFvYP0X7UVlQ==",
"cpu": [
"x64"
],
@@ -1687,9 +1687,9 @@
}
},
"node_modules/@next/swc-linux-arm64-gnu": {
- "version": "16.1.6",
- "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.6.tgz",
- "integrity": "sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw==",
+ "version": "16.1.7",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.7.tgz",
+ "integrity": "sha512-2ant89Lux/Q3VyC8vNVg7uBaFVP9SwoK2jJOOR0L8TQnX8CAYnh4uctAScy2Hwj2dgjVHqHLORQZJ2wH6VxhSQ==",
"cpu": [
"arm64"
],
@@ -1704,9 +1704,9 @@
}
},
"node_modules/@next/swc-linux-arm64-musl": {
- "version": "16.1.6",
- "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.6.tgz",
- "integrity": "sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==",
+ "version": "16.1.7",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.7.tgz",
+ "integrity": "sha512-uufcze7LYv0FQg9GnNeZ3/whYfo+1Q3HnQpm16o6Uyi0OVzLlk2ZWoY7j07KADZFY8qwDbsmFnMQP3p3+Ftprw==",
"cpu": [
"arm64"
],
@@ -1721,9 +1721,9 @@
}
},
"node_modules/@next/swc-linux-x64-gnu": {
- "version": "16.1.6",
- "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.6.tgz",
- "integrity": "sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==",
+ "version": "16.1.7",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.7.tgz",
+ "integrity": "sha512-KWVf2gxYvHtvuT+c4MBOGxuse5TD7DsMFYSxVxRBnOzok/xryNeQSjXgxSv9QpIVlaGzEn/pIuI6Koosx8CGWA==",
"cpu": [
"x64"
],
@@ -1738,9 +1738,9 @@
}
},
"node_modules/@next/swc-linux-x64-musl": {
- "version": "16.1.6",
- "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.6.tgz",
- "integrity": "sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==",
+ "version": "16.1.7",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.7.tgz",
+ "integrity": "sha512-HguhaGwsGr1YAGs68uRKc4aGWxLET+NevJskOcCAwXbwj0fYX0RgZW2gsOCzr9S11CSQPIkxmoSbuVaBp4Z3dA==",
"cpu": [
"x64"
],
@@ -1755,9 +1755,9 @@
}
},
"node_modules/@next/swc-win32-arm64-msvc": {
- "version": "16.1.6",
- "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.6.tgz",
- "integrity": "sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==",
+ "version": "16.1.7",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.7.tgz",
+ "integrity": "sha512-S0n3KrDJokKTeFyM/vGGGR8+pCmXYrjNTk2ZozOL1C/JFdfUIL9O1ATaJOl5r2POe56iRChbsszrjMAdWSv7kQ==",
"cpu": [
"arm64"
],
@@ -1772,9 +1772,9 @@
}
},
"node_modules/@next/swc-win32-x64-msvc": {
- "version": "16.1.6",
- "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.6.tgz",
- "integrity": "sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A==",
+ "version": "16.1.7",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.7.tgz",
+ "integrity": "sha512-mwgtg8CNZGYm06LeEd+bNnOUfwOyNem/rOiP14Lsz+AnUY92Zq/LXwtebtUiaeVkhbroRCQ0c8GlR4UT1U+0yg==",
"cpu": [
"x64"
],
@@ -4107,13 +4107,16 @@
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
- "version": "2.9.11",
- "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.11.tgz",
- "integrity": "sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==",
+ "version": "2.10.12",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.12.tgz",
+ "integrity": "sha512-qyq26DxfY4awP2gIRXhhLWfwzwI+N5Nxk6iQi8EFizIaWIjqicQTE4sLnZZVdeKPRcVNoJOkkpfzoIYuvCKaIQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
- "baseline-browser-mapping": "dist/cli.js"
+ "baseline-browser-mapping": "dist/cli.cjs"
+ },
+ "engines": {
+ "node": ">=6.0.0"
}
},
"node_modules/bidi-js": {
@@ -6631,15 +6634,15 @@
}
},
"node_modules/next": {
- "version": "16.1.6",
- "resolved": "https://registry.npmjs.org/next/-/next-16.1.6.tgz",
- "integrity": "sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==",
+ "version": "16.1.7",
+ "resolved": "https://registry.npmjs.org/next/-/next-16.1.7.tgz",
+ "integrity": "sha512-WM0L7WrSvKwoLegLYr6V+mz+RIofqQgVAfHhMp9a88ms0cFX8iX9ew+snpWlSBwpkURJOUdvCEt3uLl3NNzvWg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@next/env": "16.1.6",
+ "@next/env": "16.1.7",
"@swc/helpers": "0.5.15",
- "baseline-browser-mapping": "^2.8.3",
+ "baseline-browser-mapping": "^2.9.19",
"caniuse-lite": "^1.0.30001579",
"postcss": "8.4.31",
"styled-jsx": "5.1.6"
@@ -6651,14 +6654,14 @@
"node": ">=20.9.0"
},
"optionalDependencies": {
- "@next/swc-darwin-arm64": "16.1.6",
- "@next/swc-darwin-x64": "16.1.6",
- "@next/swc-linux-arm64-gnu": "16.1.6",
- "@next/swc-linux-arm64-musl": "16.1.6",
- "@next/swc-linux-x64-gnu": "16.1.6",
- "@next/swc-linux-x64-musl": "16.1.6",
- "@next/swc-win32-arm64-msvc": "16.1.6",
- "@next/swc-win32-x64-msvc": "16.1.6",
+ "@next/swc-darwin-arm64": "16.1.7",
+ "@next/swc-darwin-x64": "16.1.7",
+ "@next/swc-linux-arm64-gnu": "16.1.7",
+ "@next/swc-linux-arm64-musl": "16.1.7",
+ "@next/swc-linux-x64-gnu": "16.1.7",
+ "@next/swc-linux-x64-musl": "16.1.7",
+ "@next/swc-win32-arm64-msvc": "16.1.7",
+ "@next/swc-win32-x64-msvc": "16.1.7",
"sharp": "^0.34.4"
},
"peerDependencies": {
diff --git a/package.json b/package.json
index dedf37a..819f836 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@knighted/jsx",
- "version": "1.11.0",
+ "version": "1.12.0",
"description": "Runtime JSX tagged template that renders DOM or React trees anywhere with or without a build step.",
"keywords": [
"jsx runtime",
@@ -173,7 +173,7 @@
"jsdom": "^27.2.0",
"lint-staged": "^16.2.7",
"lit": "^3.2.1",
- "next": "^16.1.6",
+ "next": "^16.1.7",
"oxlint": "^1.51.0",
"prettier": "^3.7.3",
"react": "^19.0.0",
diff --git a/src/transform.ts b/src/transform.ts
index f11d6c4..f40861f 100644
--- a/src/transform.ts
+++ b/src/transform.ts
@@ -64,6 +64,7 @@ export type TransformTopLevelDeclaration = {
export type TransformJsxSourceOptions = TranspileJsxSourceOptions & {
collectTopLevelDeclarations?: boolean
+ collectTopLevelJsxExpression?: boolean
}
type InternalTransformJsxSourceOptions = TransformJsxSourceOptions & {
@@ -77,6 +78,7 @@ export type TransformJsxSourceResult = {
imports: TransformImport[]
diagnostics: TransformDiagnostic[]
declarations?: TransformTopLevelDeclaration[]
+ hasTopLevelJsxExpression?: boolean
}
const createParserOptions = (sourceType: TransformSourceType) => ({
@@ -394,6 +396,59 @@ const collectTopLevelDeclarationMetadata = (
return declarations
}
+const unwrapExpressionNode = (value: unknown): unknown => {
+ let current = value
+
+ while (isObjectRecord(current) && typeof current.type === 'string') {
+ if (current.type === 'ParenthesizedExpression') {
+ current = current.expression
+ continue
+ }
+
+ if (
+ current.type === 'TSAsExpression' ||
+ current.type === 'TSSatisfiesExpression' ||
+ current.type === 'TSInstantiationExpression' ||
+ current.type === 'TSNonNullExpression' ||
+ current.type === 'TSTypeAssertion'
+ ) {
+ current = current.expression
+ continue
+ }
+
+ break
+ }
+
+ return current
+}
+
+const isJsxExpressionNode = (value: unknown): boolean => {
+ const unwrapped = unwrapExpressionNode(value)
+ if (!isObjectRecord(unwrapped) || typeof unwrapped.type !== 'string') {
+ return false
+ }
+
+ return unwrapped.type === 'JSXElement' || unwrapped.type === 'JSXFragment'
+}
+
+const collectTopLevelJsxExpressionMetadata = (body: unknown): boolean => {
+ if (!Array.isArray(body)) {
+ return false
+ }
+
+ for (const statement of body) {
+ if (!isObjectRecord(statement) || statement.type !== 'ExpressionStatement') {
+ continue
+ }
+
+ if (isJsxExpressionNode(statement.expression)) {
+ return true
+ }
+ }
+
+ return false
+}
+
const ensureSupportedOptions = (options: InternalTransformJsxSourceOptions) => {
if (
options.sourceType !== undefined &&
@@ -433,6 +488,15 @@ const ensureSupportedOptions = (options: InternalTransformJsxSourceOptions) => {
`[jsx] Unsupported collectTopLevelDeclarations value "${String(options.collectTopLevelDeclarations)}". Use true or false.`,
)
}
+
+ if (
+ options.collectTopLevelJsxExpression !== undefined &&
+ typeof options.collectTopLevelJsxExpression !== 'boolean'
+ ) {
+ throw new Error(
+ `[jsx] Unsupported collectTopLevelJsxExpression value "${String(options.collectTopLevelJsxExpression)}". Use true or false.`,
+ )
+ }
}
export function transformJsxSource(
@@ -458,6 +522,9 @@ export function transformJsxSource(
const declarations = internalOptions.collectTopLevelDeclarations
? collectTopLevelDeclarationMetadata(parsed.program.body)
: undefined
+ const hasTopLevelJsxExpression = internalOptions.collectTopLevelJsxExpression
+ ? collectTopLevelJsxExpressionMetadata(parsed.program.body)
+ : undefined
if (parserDiagnostics.length) {
return {
@@ -466,6 +533,7 @@ export function transformJsxSource(
imports,
diagnostics: parserDiagnostics,
declarations,
+ hasTopLevelJsxExpression,
}
}
@@ -484,6 +552,7 @@ export function transformJsxSource(
imports,
diagnostics: parserDiagnostics,
declarations,
+ hasTopLevelJsxExpression,
}
}
@@ -499,6 +568,7 @@ export function transformJsxSource(
imports,
diagnostics: parserDiagnostics,
declarations,
+ hasTopLevelJsxExpression,
}
}
@@ -522,6 +592,7 @@ export function transformJsxSource(
imports,
diagnostics,
declarations,
+ hasTopLevelJsxExpression,
}
}
@@ -533,5 +604,6 @@ export function transformJsxSource(
imports,
diagnostics,
declarations,
+ hasTopLevelJsxExpression,
}
}
diff --git a/test/transform.test.ts b/test/transform.test.ts
index b174539..102dc05 100644
--- a/test/transform.test.ts
+++ b/test/transform.test.ts
@@ -52,6 +52,29 @@ const App = () => (
expect(transformed.imports).toEqual([])
expect(transformed.diagnostics).toEqual([])
expect(transformed.declarations).toBeUndefined()
+ expect(transformed.hasTopLevelJsxExpression).toBeUndefined()
+ })
+
+ it('collects top-level JSX expression metadata when requested', () => {
+ const input = '; // trailing'
+
+ const result = transformJsxSource(input, {
+ collectTopLevelJsxExpression: true,
+ })
+
+ expect(result.diagnostics).toEqual([])
+ expect(result.hasTopLevelJsxExpression).toBe(true)
+ })
+
+ it('reports false top-level JSX expression metadata when absent', () => {
+ const input = 'const App = () => '
+
+ const result = transformJsxSource(input, {
+ collectTopLevelJsxExpression: true,
+ })
+
+ expect(result.diagnostics).toEqual([])
+ expect(result.hasTopLevelJsxExpression).toBe(false)
})
it('collects top-level declarations when requested', () => {
@@ -227,6 +250,18 @@ function lowerFn() { return null }
expect(Array.isArray(result.declarations)).toBe(true)
})
+ it('returns JSX expression metadata on parser-error paths when requested', () => {
+ const input = 'import {'
+
+ const result = transformJsxSource(input, {
+ collectTopLevelJsxExpression: true,
+ })
+
+ expect(result.diagnostics[0]?.source).toBe('parser')
+ expect(typeof result.hasTopLevelJsxExpression).toBe('boolean')
+ expect(result.hasTopLevelJsxExpression).toBe(false)
+ })
+
it('returns parser diagnostics with source ranges', () => {
const input = 'import {'
@@ -426,6 +461,14 @@ const value = (input satisfies string)
).toThrow(/Unsupported collectTopLevelDeclarations value/)
})
+ it('throws for unsupported collectTopLevelJsxExpression values', () => {
+ expect(() =>
+ transformJsxSource('const value = 1', {
+ collectTopLevelJsxExpression: 'yes' as unknown as boolean,
+ }),
+ ).toThrow(/Unsupported collectTopLevelJsxExpression value/)
+ })
+
it('normalizes import metadata even when range fields are missing', async () => {
vi.resetModules()
vi.doMock('oxc-parser', () => ({
@@ -622,6 +665,67 @@ const value = (input satisfies string)
vi.resetModules()
})
+ it('returns false for JSX expression metadata when parser body is not an array', async () => {
+ vi.resetModules()
+ vi.doMock('oxc-parser', () => ({
+ parseSync: () => ({
+ errors: [],
+ program: {
+ body: null,
+ },
+ }),
+ }))
+
+ const { transformJsxSource: mockedTransformJsxSource } =
+ await import('../src/transform.js')
+
+ const result = mockedTransformJsxSource('const value = 1', {
+ collectTopLevelJsxExpression: true,
+ })
+
+ expect(result.hasTopLevelJsxExpression).toBe(false)
+
+ vi.doUnmock('oxc-parser')
+ vi.resetModules()
+ })
+
+ it('detects top-level JSX expressions through TS/parens wrappers', async () => {
+ vi.resetModules()
+ vi.doMock('oxc-parser', () => ({
+ parseSync: () => ({
+ errors: [],
+ program: {
+ body: [
+ {
+ type: 'ExpressionStatement',
+ expression: {
+ type: 'ParenthesizedExpression',
+ expression: {
+ type: 'TSAsExpression',
+ expression: {
+ type: 'JSXFragment',
+ },
+ },
+ },
+ },
+ ],
+ },
+ }),
+ }))
+
+ const { transformJsxSource: mockedTransformJsxSource } =
+ await import('../src/transform.js')
+
+ const result = mockedTransformJsxSource('const value = 1', {
+ collectTopLevelJsxExpression: true,
+ })
+
+ expect(result.hasTopLevelJsxExpression).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', () => ({