From b44e2df7820ae72fab1f3982b8f81918eaad6e39 Mon Sep 17 00:00:00 2001 From: KCM Date: Sat, 28 Mar 2026 12:35:13 -0500 Subject: [PATCH] feat: detect top-level jsx expressions. --- package-lock.json | 97 +++++++++++++++++++------------------- package.json | 4 +- src/transform.ts | 72 ++++++++++++++++++++++++++++ test/transform.test.ts | 104 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 228 insertions(+), 49 deletions(-) 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', () => ({