Skip to content

Commit 73fd431

Browse files
feat: add expression range for jsx to transform. (#93)
1 parent 6ef54c9 commit 73fd431

4 files changed

Lines changed: 86 additions & 13 deletions

File tree

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@knighted/jsx",
3-
"version": "1.12.0",
3+
"version": "1.13.0",
44
"description": "Runtime JSX tagged template that renders DOM or React trees anywhere with or without a build step.",
55
"keywords": [
66
"jsx runtime",

src/transform.ts

Lines changed: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ export type TransformJsxSourceResult = {
7979
diagnostics: TransformDiagnostic[]
8080
declarations?: TransformTopLevelDeclaration[]
8181
hasTopLevelJsxExpression?: boolean
82+
topLevelJsxExpressionRange?: SourceRange | null
8283
}
8384

8485
const createParserOptions = (sourceType: TransformSourceType) => ({
@@ -422,31 +423,51 @@ const unwrapExpressionNode = (value: unknown): unknown => {
422423
return current
423424
}
424425

425-
const isJsxExpressionNode = (value: unknown): boolean => {
426+
const toJsxExpressionNode = (value: unknown): Record<string, unknown> | null => {
426427
const unwrapped = unwrapExpressionNode(value)
427428
if (!isObjectRecord(unwrapped) || typeof unwrapped.type !== 'string') {
428-
return false
429+
return null
430+
}
431+
432+
if (unwrapped.type === 'JSXElement' || unwrapped.type === 'JSXFragment') {
433+
return unwrapped
429434
}
430435

431-
return unwrapped.type === 'JSXElement' || unwrapped.type === 'JSXFragment'
436+
return null
437+
}
438+
439+
type TopLevelJsxExpressionMetadata = {
440+
hasTopLevelJsxExpression: boolean
441+
topLevelJsxExpressionRange: SourceRange | null
432442
}
433443

434-
const collectTopLevelJsxExpressionMetadata = (body: unknown): boolean => {
444+
const createEmptyTopLevelJsxExpressionMetadata = (): TopLevelJsxExpressionMetadata => ({
445+
hasTopLevelJsxExpression: false,
446+
topLevelJsxExpressionRange: null,
447+
})
448+
449+
const collectTopLevelJsxExpressionMetadata = (
450+
body: unknown,
451+
): TopLevelJsxExpressionMetadata => {
435452
if (!Array.isArray(body)) {
436-
return false
453+
return createEmptyTopLevelJsxExpressionMetadata()
437454
}
438455

439456
for (const statement of body) {
440457
if (!isObjectRecord(statement) || statement.type !== 'ExpressionStatement') {
441458
continue
442459
}
443460

444-
if (isJsxExpressionNode(statement.expression)) {
445-
return true
461+
const jsxNode = toJsxExpressionNode(statement.expression)
462+
if (jsxNode) {
463+
return {
464+
hasTopLevelJsxExpression: true,
465+
topLevelJsxExpressionRange: toSourceRange(jsxNode),
466+
}
446467
}
447468
}
448469

449-
return false
470+
return createEmptyTopLevelJsxExpressionMetadata()
450471
}
451472

452473
const ensureSupportedOptions = (options: InternalTransformJsxSourceOptions) => {
@@ -522,9 +543,12 @@ export function transformJsxSource(
522543
const declarations = internalOptions.collectTopLevelDeclarations
523544
? collectTopLevelDeclarationMetadata(parsed.program.body)
524545
: undefined
525-
const hasTopLevelJsxExpression = internalOptions.collectTopLevelJsxExpression
546+
const topLevelJsxExpressionMetadata = internalOptions.collectTopLevelJsxExpression
526547
? collectTopLevelJsxExpressionMetadata(parsed.program.body)
527548
: undefined
549+
const hasTopLevelJsxExpression = topLevelJsxExpressionMetadata?.hasTopLevelJsxExpression
550+
const topLevelJsxExpressionRange =
551+
topLevelJsxExpressionMetadata?.topLevelJsxExpressionRange
528552

529553
if (parserDiagnostics.length) {
530554
return {
@@ -534,6 +558,7 @@ export function transformJsxSource(
534558
diagnostics: parserDiagnostics,
535559
declarations,
536560
hasTopLevelJsxExpression,
561+
topLevelJsxExpressionRange,
537562
}
538563
}
539564

@@ -553,6 +578,7 @@ export function transformJsxSource(
553578
diagnostics: parserDiagnostics,
554579
declarations,
555580
hasTopLevelJsxExpression,
581+
topLevelJsxExpressionRange,
556582
}
557583
}
558584

@@ -569,6 +595,7 @@ export function transformJsxSource(
569595
diagnostics: parserDiagnostics,
570596
declarations,
571597
hasTopLevelJsxExpression,
598+
topLevelJsxExpressionRange,
572599
}
573600
}
574601

@@ -593,6 +620,7 @@ export function transformJsxSource(
593620
diagnostics,
594621
declarations,
595622
hasTopLevelJsxExpression,
623+
topLevelJsxExpressionRange,
596624
}
597625
}
598626

@@ -605,5 +633,6 @@ export function transformJsxSource(
605633
diagnostics,
606634
declarations,
607635
hasTopLevelJsxExpression,
636+
topLevelJsxExpressionRange,
608637
}
609638
}

test/transform.test.ts

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,14 +56,17 @@ const App = () => (
5656
})
5757

5858
it('collects top-level JSX expression metadata when requested', () => {
59-
const input = '<button type="button">hello</button>; // trailing'
59+
const input = '(<button type="button">hello</button>) as any; // trailing'
6060

6161
const result = transformJsxSource(input, {
6262
collectTopLevelJsxExpression: true,
6363
})
6464

6565
expect(result.diagnostics).toEqual([])
6666
expect(result.hasTopLevelJsxExpression).toBe(true)
67+
expect(result.topLevelJsxExpressionRange).toHaveLength(2)
68+
const [start, end] = result.topLevelJsxExpressionRange as [number, number]
69+
expect(input.slice(start, end)).toBe('<button type="button">hello</button>')
6770
})
6871

6972
it('reports false top-level JSX expression metadata when absent', () => {
@@ -75,6 +78,7 @@ const App = () => (
7578

7679
expect(result.diagnostics).toEqual([])
7780
expect(result.hasTopLevelJsxExpression).toBe(false)
81+
expect(result.topLevelJsxExpressionRange).toBeNull()
7882
})
7983

8084
it('collects top-level declarations when requested', () => {
@@ -260,6 +264,7 @@ function lowerFn() { return null }
260264
expect(result.diagnostics[0]?.source).toBe('parser')
261265
expect(typeof result.hasTopLevelJsxExpression).toBe('boolean')
262266
expect(result.hasTopLevelJsxExpression).toBe(false)
267+
expect(result.topLevelJsxExpressionRange).toBeNull()
263268
})
264269

265270
it('returns parser diagnostics with source ranges', () => {
@@ -684,6 +689,7 @@ const value = (input satisfies string)
684689
})
685690

686691
expect(result.hasTopLevelJsxExpression).toBe(false)
692+
expect(result.topLevelJsxExpressionRange).toBeNull()
687693

688694
vi.doUnmock('oxc-parser')
689695
vi.resetModules()
@@ -721,6 +727,44 @@ const value = (input satisfies string)
721727
})
722728

723729
expect(result.hasTopLevelJsxExpression).toBe(true)
730+
expect(result.topLevelJsxExpressionRange).toBeNull()
731+
732+
vi.doUnmock('oxc-parser')
733+
vi.resetModules()
734+
})
735+
736+
it('returns false for non-JSX expression statement shapes', async () => {
737+
vi.resetModules()
738+
vi.doMock('oxc-parser', () => ({
739+
parseSync: () => ({
740+
errors: [],
741+
program: {
742+
body: [
743+
{
744+
type: 'ExpressionStatement',
745+
expression: null,
746+
},
747+
{
748+
type: 'ExpressionStatement',
749+
expression: {
750+
type: 'Identifier',
751+
name: 'value',
752+
},
753+
},
754+
],
755+
},
756+
}),
757+
}))
758+
759+
const { transformJsxSource: mockedTransformJsxSource } =
760+
await import('../src/transform.js')
761+
762+
const result = mockedTransformJsxSource('const value = 1', {
763+
collectTopLevelJsxExpression: true,
764+
})
765+
766+
expect(result.hasTopLevelJsxExpression).toBe(false)
767+
expect(result.topLevelJsxExpressionRange).toBeNull()
724768

725769
vi.doUnmock('oxc-parser')
726770
vi.resetModules()

0 commit comments

Comments
 (0)