diff --git a/package-lock.json b/package-lock.json index bd0262b..9940a86 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@knighted/jsx", - "version": "1.10.0", + "version": "1.11.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@knighted/jsx", - "version": "1.10.0", + "version": "1.11.0", "license": "MIT", "dependencies": { "@napi-rs/wasm-runtime": "^1.1.1", diff --git a/package.json b/package.json index a50dfad..dedf37a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@knighted/jsx", - "version": "1.10.0", + "version": "1.11.0", "description": "Runtime JSX tagged template that renders DOM or React trees anywhere with or without a build step.", "keywords": [ "jsx runtime", diff --git a/src/transform.ts b/src/transform.ts index aaebc7f..f11d6c4 100644 --- a/src/transform.ts +++ b/src/transform.ts @@ -42,7 +42,29 @@ export type TransformImport = { range: SourceRange | null } -export type TransformJsxSourceOptions = TranspileJsxSourceOptions +export type TransformTopLevelDeclarationKind = 'function' | 'class' | 'variable' + +export type TransformTopLevelDeclarationExportKind = 'none' | 'named' | 'default' + +export type TransformVariableInitializerKind = + | 'arrow-function' + | 'function-expression' + | 'class-expression' + | 'other' + | null + +export type TransformTopLevelDeclaration = { + name: string + kind: TransformTopLevelDeclarationKind + exportKind: TransformTopLevelDeclarationExportKind + range: SourceRange | null + statementRange: SourceRange | null + initializerKind: TransformVariableInitializerKind +} + +export type TransformJsxSourceOptions = TranspileJsxSourceOptions & { + collectTopLevelDeclarations?: boolean +} type InternalTransformJsxSourceOptions = TransformJsxSourceOptions & { /* Internal compare switch for parity spikes. */ @@ -54,6 +76,7 @@ export type TransformJsxSourceResult = { changed: boolean imports: TransformImport[] diagnostics: TransformDiagnostic[] + declarations?: TransformTopLevelDeclaration[] } const createParserOptions = (sourceType: TransformSourceType) => ({ @@ -214,6 +237,163 @@ const collectImportMetadata = (body: unknown): TransformImport[] => { return imports } +const toIdentifierName = (value: unknown): string | null => { + if (!isObjectRecord(value)) { + return null + } + + if (value.type !== 'Identifier') { + return null + } + + return typeof value.name === 'string' ? value.name : null +} + +const toVariableInitializerKind = (value: unknown): TransformVariableInitializerKind => { + if (!isObjectRecord(value) || typeof value.type !== 'string') { + return null + } + + if (value.type === 'ArrowFunctionExpression') { + return 'arrow-function' + } + + if (value.type === 'FunctionExpression') { + return 'function-expression' + } + + if (value.type === 'ClassExpression') { + return 'class-expression' + } + + return 'other' +} + +const pushTopLevelDeclarationMetadata = ({ + declaration, + exportKind, + statementRange, + declarations, +}: { + declaration: Record + exportKind: TransformTopLevelDeclarationExportKind + statementRange: SourceRange | null + declarations: TransformTopLevelDeclaration[] +}) => { + if (declaration.type === 'FunctionDeclaration') { + const name = toIdentifierName(declaration.id) + if (!name) { + return + } + + declarations.push({ + name, + kind: 'function', + exportKind, + range: toSourceRange(declaration), + statementRange, + initializerKind: null, + }) + return + } + + if (declaration.type === 'ClassDeclaration') { + const name = toIdentifierName(declaration.id) + if (!name) { + return + } + + declarations.push({ + name, + kind: 'class', + exportKind, + range: toSourceRange(declaration), + statementRange, + initializerKind: null, + }) + return + } + + if (!Array.isArray(declaration.declarations)) { + return + } + + for (const declarator of declaration.declarations) { + if (!isObjectRecord(declarator)) { + continue + } + + const name = toIdentifierName(declarator.id) + if (!name) { + continue + } + + declarations.push({ + name, + kind: 'variable', + exportKind, + range: toSourceRange(declarator), + statementRange, + initializerKind: toVariableInitializerKind(declarator.init), + }) + } +} + +const collectTopLevelDeclarationMetadata = ( + body: unknown, +): TransformTopLevelDeclaration[] => { + if (!Array.isArray(body)) { + return [] + } + + const declarations: TransformTopLevelDeclaration[] = [] + + for (const statement of body) { + if (!isObjectRecord(statement) || typeof statement.type !== 'string') { + continue + } + + const statementRange = toSourceRange(statement) + + if (statement.type === 'ExportNamedDeclaration') { + if (!isObjectRecord(statement.declaration)) { + continue + } + + pushTopLevelDeclarationMetadata({ + declaration: statement.declaration, + exportKind: 'named', + statementRange, + declarations, + }) + continue + } + + if (statement.type === 'ExportDefaultDeclaration') { + if (!isObjectRecord(statement.declaration)) { + continue + } + + pushTopLevelDeclarationMetadata({ + declaration: statement.declaration, + exportKind: 'default', + statementRange, + declarations, + }) + continue + } + + pushTopLevelDeclarationMetadata({ + declaration: statement, + exportKind: 'none', + statementRange, + declarations, + }) + } + + return declarations +} + const ensureSupportedOptions = (options: InternalTransformJsxSourceOptions) => { if ( options.sourceType !== undefined && @@ -244,6 +424,15 @@ const ensureSupportedOptions = (options: InternalTransformJsxSourceOptions) => { `[jsx] Unsupported typescriptStripBackend "${String(options.typescriptStripBackend)}". Use "oxc-transform" or "transpile-manual".`, ) } + + if ( + options.collectTopLevelDeclarations !== undefined && + typeof options.collectTopLevelDeclarations !== 'boolean' + ) { + throw new Error( + `[jsx] Unsupported collectTopLevelDeclarations value "${String(options.collectTopLevelDeclarations)}". Use true or false.`, + ) + } } export function transformJsxSource( @@ -266,6 +455,9 @@ export function transformJsxSource( const parserDiagnostics = parsed.errors.map(error => toDiagnostic('parser', error)) const imports = collectImportMetadata(parsed.program.body) + const declarations = internalOptions.collectTopLevelDeclarations + ? collectTopLevelDeclarationMetadata(parsed.program.body) + : undefined if (parserDiagnostics.length) { return { @@ -273,6 +465,7 @@ export function transformJsxSource( changed: false, imports, diagnostics: parserDiagnostics, + declarations, } } @@ -290,6 +483,7 @@ export function transformJsxSource( changed: result.changed, imports, diagnostics: parserDiagnostics, + declarations, } } @@ -304,6 +498,7 @@ export function transformJsxSource( changed: result.changed, imports, diagnostics: parserDiagnostics, + declarations, } } @@ -326,6 +521,7 @@ export function transformJsxSource( changed: fallbackCode !== source, imports, diagnostics, + declarations, } } @@ -336,5 +532,6 @@ export function transformJsxSource( changed: jsxResult.code !== source, imports, diagnostics, + declarations, } } diff --git a/test/transform.test.ts b/test/transform.test.ts index 5faac37..b174539 100644 --- a/test/transform.test.ts +++ b/test/transform.test.ts @@ -51,6 +51,180 @@ const App = () => ( expect(transformed.changed).toBe(transpiled.changed) expect(transformed.imports).toEqual([]) expect(transformed.diagnostics).toEqual([]) + expect(transformed.declarations).toBeUndefined() + }) + + it('collects top-level declarations when requested', () => { + const input = ` +const LocalArrow = () => +const LocalFunctionExpr = function named() { return null } +const LocalClassExpr = class Named {} +const LocalValue = 42 +function LocalFn() { return } +class LocalClass {} +export const ExportedArrow = () => +export function ExportedFn() { return } +export default function App() { return } +` + + const result = transformJsxSource(input, { + collectTopLevelDeclarations: true, + typescript: 'strip', + }) + + expect(result.diagnostics).toEqual([]) + expect(result.declarations).toHaveLength(9) + expect(result.declarations).toEqual([ + { + name: 'LocalArrow', + kind: 'variable', + exportKind: 'none', + range: expect.any(Array), + statementRange: expect.any(Array), + initializerKind: 'arrow-function', + }, + { + name: 'LocalFunctionExpr', + kind: 'variable', + exportKind: 'none', + range: expect.any(Array), + statementRange: expect.any(Array), + initializerKind: 'function-expression', + }, + { + name: 'LocalClassExpr', + kind: 'variable', + exportKind: 'none', + range: expect.any(Array), + statementRange: expect.any(Array), + initializerKind: 'class-expression', + }, + { + name: 'LocalValue', + kind: 'variable', + exportKind: 'none', + range: expect.any(Array), + statementRange: expect.any(Array), + initializerKind: 'other', + }, + { + name: 'LocalFn', + kind: 'function', + exportKind: 'none', + range: expect.any(Array), + statementRange: expect.any(Array), + initializerKind: null, + }, + { + name: 'LocalClass', + kind: 'class', + exportKind: 'none', + range: expect.any(Array), + statementRange: expect.any(Array), + initializerKind: null, + }, + { + name: 'ExportedArrow', + kind: 'variable', + exportKind: 'named', + range: expect.any(Array), + statementRange: expect.any(Array), + initializerKind: 'arrow-function', + }, + { + name: 'ExportedFn', + kind: 'function', + exportKind: 'named', + range: expect.any(Array), + statementRange: expect.any(Array), + initializerKind: null, + }, + { + name: 'App', + kind: 'function', + exportKind: 'default', + range: expect.any(Array), + statementRange: expect.any(Array), + initializerKind: null, + }, + ]) + + expect(result.declarations?.every(declaration => declaration.range !== null)).toBe( + true, + ) + expect( + result.declarations?.every(declaration => declaration.statementRange !== null), + ).toBe(true) + }) + + it('collects top-level declarations for all valid identifier naming styles', () => { + const input = ` +const camelCase = () => null +const snake_case = () => null +const $dollar = () => null +const _underscore = () => null +function lowerFn() { return null } +` + + const result = transformJsxSource(input, { + collectTopLevelDeclarations: true, + }) + + expect(result.diagnostics).toEqual([]) + expect(result.declarations).toHaveLength(5) + expect(result.declarations).toEqual([ + { + name: 'camelCase', + kind: 'variable', + exportKind: 'none', + range: expect.any(Array), + statementRange: expect.any(Array), + initializerKind: 'arrow-function', + }, + { + name: 'snake_case', + kind: 'variable', + exportKind: 'none', + range: expect.any(Array), + statementRange: expect.any(Array), + initializerKind: 'arrow-function', + }, + { + name: '$dollar', + kind: 'variable', + exportKind: 'none', + range: expect.any(Array), + statementRange: expect.any(Array), + initializerKind: 'arrow-function', + }, + { + name: '_underscore', + kind: 'variable', + exportKind: 'none', + range: expect.any(Array), + statementRange: expect.any(Array), + initializerKind: 'arrow-function', + }, + { + name: 'lowerFn', + kind: 'function', + exportKind: 'none', + range: expect.any(Array), + statementRange: expect.any(Array), + initializerKind: null, + }, + ]) + }) + + it('returns a declarations array on parser-error paths when requested', () => { + const input = 'const App = () => \nimport {' + + const result = transformJsxSource(input, { + collectTopLevelDeclarations: true, + }) + + expect(result.diagnostics[0]?.source).toBe('parser') + expect(Array.isArray(result.declarations)).toBe(true) }) it('returns parser diagnostics with source ranges', () => { @@ -244,6 +418,14 @@ const value = (input satisfies string) ) }) + it('throws for unsupported collectTopLevelDeclarations values', () => { + expect(() => + transformJsxSource('const value = 1', { + collectTopLevelDeclarations: 'yes' as unknown as boolean, + }), + ).toThrow(/Unsupported collectTopLevelDeclarations value/) + }) + it('normalizes import metadata even when range fields are missing', async () => { vi.resetModules() vi.doMock('oxc-parser', () => ({ @@ -284,6 +466,162 @@ const value = (input satisfies string) vi.resetModules() }) + it('normalizes declaration metadata with mixed export wrappers and missing ranges', async () => { + vi.resetModules() + vi.doMock('oxc-parser', () => ({ + parseSync: () => ({ + errors: [], + program: { + body: [ + { + type: 'ExportNamedDeclaration', + declaration: { + type: 'VariableDeclaration', + declarations: [ + { + type: 'VariableDeclarator', + id: { type: 'Identifier', name: 'namedNoInit' }, + }, + ], + }, + }, + { + type: 'ExportDefaultDeclaration', + declaration: { + type: 'FunctionDeclaration', + id: { type: 'Identifier', name: 'DefaultFn' }, + }, + }, + { + type: 'ExportDefaultDeclaration', + declaration: { + type: 'ClassDeclaration', + id: { type: 'Identifier', name: 'DefaultClass' }, + }, + }, + { + type: 'ExportDefaultDeclaration', + declaration: { + type: 'FunctionDeclaration', + id: null, + }, + }, + { + type: 'VariableDeclaration', + declarations: [ + null, + { + type: 'VariableDeclarator', + id: { type: 'ObjectPattern' }, + init: { type: 'ArrowFunctionExpression' }, + }, + { + type: 'VariableDeclarator', + id: { type: 'Identifier', name: 'classExprValue' }, + init: { type: 'ClassExpression' }, + }, + { + type: 'VariableDeclarator', + id: { type: 'Identifier', name: 'otherInitValue' }, + init: { type: 'CallExpression' }, + }, + ], + }, + { + type: 'ExpressionStatement', + }, + { + type: 'ExportNamedDeclaration', + declaration: null, + }, + { + type: 'ExportDefaultDeclaration', + declaration: null, + }, + ], + }, + }), + })) + + const { transformJsxSource: mockedTransformJsxSource } = + await import('../src/transform.js') + + const result = mockedTransformJsxSource('const value = 1', { + collectTopLevelDeclarations: true, + }) + + expect(result.declarations).toHaveLength(5) + expect(result.declarations).toEqual([ + { + name: 'namedNoInit', + kind: 'variable', + exportKind: 'named', + initializerKind: null, + range: null, + statementRange: null, + }, + { + name: 'DefaultFn', + kind: 'function', + exportKind: 'default', + initializerKind: null, + range: null, + statementRange: null, + }, + { + name: 'DefaultClass', + kind: 'class', + exportKind: 'default', + initializerKind: null, + range: null, + statementRange: null, + }, + { + name: 'classExprValue', + kind: 'variable', + exportKind: 'none', + initializerKind: 'class-expression', + range: null, + statementRange: null, + }, + { + name: 'otherInitValue', + kind: 'variable', + exportKind: 'none', + initializerKind: 'other', + range: null, + statementRange: null, + }, + ]) + + vi.doUnmock('oxc-parser') + vi.resetModules() + }) + + it('returns an empty declarations array 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', { + collectTopLevelDeclarations: true, + }) + + expect(result.declarations).toEqual([]) + + vi.doUnmock('oxc-parser') + vi.resetModules() + }) + it('marks sideEffectOnly only for value imports with no bindings', async () => { vi.resetModules() vi.doMock('oxc-parser', () => ({