ReviewOS

also looking at this

stacks/dtsx

fix: generate React component declarations

#3104
Merged chris wants to merge agent/react-component-declarations into main
14 files +875 -35

Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.

packages/zig-dtsx/src/extractors.zigmodified+16-71
Changes to packages/zig-dtsx/src/extractors.zig
@@ -4,7 +4,8 @@ const std = ;
44const ch = ;
55const types = ;
66const type_inf = ;
7const Scanner = .Scanner;
7const scanner = ;
8const Scanner = scanner.Scanner;
89const Declaration = types.Declaration;
910const DeclarationKind = types.DeclarationKind;
1011const Allocator = std.mem.Allocator;
@@ -1082,64 +1083,6 @@ pub fn extractFunction(s: *Scanner, decl_start: usize, is_exported: bool, is_asy
10821083// Variable extraction
10831084// ========================================================================
10841085
1085const JsxTagInfo = struct { delta: isize, end: usize };
1086
1087fn jsxTagAt(source: []const u8, index: usize) ?JsxTagInfo {
1088 if (index + 1 >= source.len or source[index] != ch.CH_LANGLE) return null;
1089 const next = source[index + 1];
1090 if (next == ch.CH_RANGLE) return .{ .delta = 1, .end = index + 1 };
1091 if (next == ch.CH_SLASH) {
1092 const close_end = ch.indexOfChar(source, ch.CH_RANGLE, index + 2) orelse return null;
1093 return .{ .delta = -1, .end = close_end };
1094 }
1095 if (!ch.isIdentStart(next)) return null;
1096
1097 var name_end = index + 2;
1098 while (name_end < source.len) : (name_end += 1) {
1099 const code = source[name_end];
1100 if (!ch.isIdentChar(code) and code != ch.CH_DOT and code != ':' and code != '-') break;
1101 }
1102 const tag_name = source[index + 1 .. name_end];
1103 if (name_end >= source.len) return null;
1104 const delimiter = source[name_end];
1105 if (!ch.isWhitespace(delimiter) and delimiter != ch.CH_RANGLE and delimiter != ch.CH_SLASH) return null;
1106
1107 var brace_depth: usize = 0;
1108 var quote: u8 = 0;
1109 var tag_end = name_end;
1110 while (tag_end < source.len) : (tag_end += 1) {
1111 const code = source[tag_end];
1112 if (quote != 0) {
1113 if (code == ch.CH_BACKSLASH) tag_end += 1 else if (code == quote) quote = 0;
1114 continue;
1115 }
1116 if (code == ch.CH_SQUOTE or code == ch.CH_DQUOTE or code == ch.CH_BACKTICK) {
1117 quote = code;
1118 } else if (code == ch.CH_LBRACE) {
1119 brace_depth += 1;
1120 } else if (code == ch.CH_RBRACE and brace_depth > 0) {
1121 brace_depth -= 1;
1122 } else if (code == ch.CH_RANGLE and brace_depth == 0) {
1123 break;
1124 }
1125 }
1126 if (tag_end >= source.len) return null;
1127
1128 var before_end = tag_end - 1;
1129 while (before_end > index and ch.isWhitespace(source[before_end])) before_end -= 1;
1130 if (source[before_end] == ch.CH_SLASH) return .{ .delta = 0, .end = tag_end };
1131
1132 var search_from = tag_end + 1;
1133 while (ch.indexOf(source, "</", search_from)) |close_start| {
1134 const close_name_start = close_start + 2;
1135 const close_name_end = close_name_start + tag_name.len;
1136 if (close_name_end <= source.len and std.mem.eql(u8, source[close_name_start..close_name_end], tag_name) and
1137 close_name_end < source.len and (source[close_name_end] == ch.CH_RANGLE or ch.isWhitespace(source[close_name_end]))) return .{ .delta = 1, .end = tag_end };
1138 search_from = close_start + 2;
1139 }
1140 return null;
1141}
1142
11431086/// Extract variable declaration(s)
11441087pub fn extractVariable(s: *Scanner, decl_start: usize, kind: []const u8, is_exported: bool) []const Declaration {
11451088 s.pos += kind.len; // skip const/let/var
@@ -1199,6 +1142,7 @@ pub fn extractVariable(s: *Scanner, decl_start: usize, kind: []const u8, is_expo
11991142 s.skipWhitespaceAndComments();
12001143 const init_start = s.pos;
12011144 var depth: isize = 0;
1145 var angle_depth: isize = 0;
12021146 var jsx_depth: isize = 0;
12031147 var jsx_tag_end: ?usize = null;
12041148 while (s.pos < s.len) {
@@ -1251,29 +1195,30 @@ pub fn extractVariable(s: *Scanner, decl_start: usize, kind: []const u8, is_expo
12511195 }
12521196 }
12531197 if (s.pos >= s.len) break;
1254 if (s.skipNonCode()) continue;
1198 // The slash in a JSX closing tag has already been
1199 // classified by jsxTagAt at the preceding `<`. Do not let
1200 // regex detection consume through a later closing tag.
1201 const jsx_closing_slash = s.source[s.pos] == ch.CH_SLASH and s.pos > 0 and s.source[s.pos - 1] == ch.CH_LANGLE;
1202 if (!jsx_closing_slash and s.skipNonCode()) continue;
12551203 const ic = s.source[s.pos];
1256 if (ic == ch.CH_LANGLE) {
1257 if (jsxTagAt(s.source, s.pos)) |tag| {
1204 if (ic == ch.CH_LANGLE and depth == 0) {
1205 if (scanner.jsxTagAt(s.source, s.pos)) |tag| {
12581206 jsx_depth = @max(0, jsx_depth + tag.delta);
12591207 jsx_tag_end = tag.end;
1260 } else {
1261 depth += 1;
1262 }
1208 } else if (s.pos + 1 >= s.len or s.source[s.pos + 1] != ch.CH_EQUAL) angle_depth += 1;
12631209 } else if (ic == ch.CH_LPAREN or ic == ch.CH_LBRACE or ic == ch.CH_LBRACKET) {
12641210 depth += 1;
12651211 } else if (ic == ch.CH_RPAREN or ic == ch.CH_RBRACE or ic == ch.CH_RBRACKET) {
12661212 depth -= 1;
1267 } else if (ic == ch.CH_RANGLE) {
1213 } else if (ic == ch.CH_RANGLE and depth == 0) {
12681214 if (jsx_tag_end != null and jsx_tag_end.? == s.pos) {
12691215 jsx_tag_end = null;
1270 } else if (jsx_depth == 0 and !s.isArrowGT() and depth > 0) {
1271 depth -= 1;
1272 }
1273 } else if (depth == 0 and jsx_depth == 0 and (ic == ch.CH_SEMI or ic == ch.CH_COMMA)) {
1216 } else if (jsx_depth == 0 and !s.isArrowGT() and angle_depth > 0 and
1217 (s.pos + 1 >= s.len or s.source[s.pos + 1] != ch.CH_EQUAL)) angle_depth -= 1;
1218 } else if (depth == 0 and angle_depth == 0 and jsx_depth == 0 and (ic == ch.CH_SEMI or ic == ch.CH_COMMA)) {
12741219 break;
12751220 }
1276 if (depth == 0 and jsx_depth == 0 and s.checkASITopLevel()) break;
1221 if (depth == 0 and angle_depth == 0 and jsx_depth == 0 and s.checkASITopLevel()) break;
12771222 s.pos += 1;
12781223 }
12791224 initializer_text = if (skip_isolated_initializer) "" else s.sliceTrimmed(init_start, s.pos);
packages/zig-dtsx/src/lib.zigmodified+14-0
Changes to packages/zig-dtsx/src/lib.zig
@@ -275,3 +275,17 @@ test "result_length stays within the null-terminated allocation" {
275275 const empty = [_]u8{0};
276276 try std.testing.expectEqual(@as(usize, 0), result_length(&empty));
277277}
278
279test "processSourceInternal preserves nested JSX declarations end to end" {
280 const source =
281 \\export const Results = () => (
282 \\ <List title="score > threshold" render={item => item.score > 0 ? <strong>{item.label}</strong> : <em>none</em>} />
283 \\)
284 \\export const resultCount = 2
285 ;
286 const result = try processSourceInternal(source.ptr, source.len, true, false);
287 defer std.heap.c_allocator.free(@constCast(result.ptr)[0 .. result.len + 1]);
288 const output = result.ptr[0..result.len];
289 try std.testing.expect(std.mem.indexOf(u8, output, "Results: () => JSX.Element;") != null);
290 try std.testing.expect(std.mem.indexOf(u8, output, "resultCount: 2;") != null);
291}
packages/zig-dtsx/src/scanner.zigmodified+155-4
Changes to packages/zig-dtsx/src/scanner.zig
@@ -8,6 +8,68 @@ const Declaration = types.Declaration;
88const DeclarationKind = types.DeclarationKind;
99const Allocator = std.mem.Allocator;
1010
11pub const JsxTagInfo = struct { delta: isize, end: usize };
12
13/// Recognize a JSX tag while skipping an implementation statement. Requiring
14/// a matching close for non-self-closing tags keeps comparisons, assertions,
15/// and generic type parameters out of JSX depth accounting.
16pub fn jsxTagAt(source: []const u8, index: usize) ?JsxTagInfo {
17 if (index + 1 >= source.len or source[index] != ch.CH_LANGLE) return null;
18 const next = source[index + 1];
19 if (next == ch.CH_RANGLE) return .{ .delta = 1, .end = index + 1 };
20 if (next == ch.CH_SLASH) {
21 const close_end = ch.indexOfChar(source, ch.CH_RANGLE, index + 2) orelse return null;
22 return .{ .delta = -1, .end = close_end };
23 }
24 if (!ch.isIdentStart(next)) return null;
25
26 var name_end = index + 2;
27 while (name_end < source.len) : (name_end += 1) {
28 const code = source[name_end];
29 if (!ch.isIdentChar(code) and code != ch.CH_DOT and code != ':' and code != '-') break;
30 }
31 if (name_end >= source.len) return null;
32 const tag_name = source[index + 1 .. name_end];
33 const delimiter = source[name_end];
34 if (!ch.isWhitespace(delimiter) and delimiter != ch.CH_RANGLE and delimiter != ch.CH_SLASH) return null;
35
36 var brace_depth: usize = 0;
37 var quote: u8 = 0;
38 var tag_end = name_end;
39 while (tag_end < source.len) : (tag_end += 1) {
40 const code = source[tag_end];
41 if (quote != 0) {
42 if (code == ch.CH_BACKSLASH) tag_end += 1 else if (code == quote) quote = 0;
43 continue;
44 }
45 if (code == ch.CH_SQUOTE or code == ch.CH_DQUOTE or code == ch.CH_BACKTICK) {
46 quote = code;
47 } else if (code == ch.CH_LBRACE) {
48 brace_depth += 1;
49 } else if (code == ch.CH_RBRACE and brace_depth > 0) {
50 brace_depth -= 1;
51 } else if (code == ch.CH_RANGLE and brace_depth == 0) {
52 break;
53 }
54 }
55 if (tag_end >= source.len) return null;
56
57 var before_end = tag_end - 1;
58 while (before_end > index and ch.isWhitespace(source[before_end])) before_end -= 1;
59 if (source[before_end] == ch.CH_SLASH) return .{ .delta = 0, .end = tag_end };
60
61 var search_from = tag_end + 1;
62 while (ch.indexOf(source, "</", search_from)) |close_start| {
63 const close_name_start = close_start + 2;
64 const close_name_end = close_name_start + tag_name.len;
65 if (close_name_end < source.len and std.mem.eql(u8, source[close_name_start..close_name_end], tag_name) and
66 (source[close_name_end] == ch.CH_RANGLE or ch.isWhitespace(source[close_name_end])))
67 return .{ .delta = 1, .end = tag_end };
68 search_from = close_start + 2;
69 }
70 return null;
71}
72
1173/// Comptime-optimized word comparison: uses integer casts for power-of-2 sizes (1/2/4/8 bytes).
1274inline fn comptime_match(comptime N: usize, src: *const [N]u8, comptime word: []const u8) bool {
1375 // Use integer comparison for power-of-2 sizes (u8, u16, u32, u64)
@@ -580,6 +642,10 @@ pub const Scanner = struct {
580642 /// Uses SIMD to skip 16 non-structural bytes at a time.
581643 pub fn skipToStatementEnd(self: *Scanner) void {
582644 var brace_depth: isize = 0;
645 var paren_depth: isize = 0;
646 var bracket_depth: isize = 0;
647 var jsx_depth: isize = 0;
648 const terminate_on_balanced_brace = self.pos < self.len and self.source[self.pos] == ch.CH_LBRACE;
583649 while (self.pos < self.len) {
584650 // SIMD fast-skip: bulk-skip bytes that can't be structural.
585651 if (brace_depth > 0) {
@@ -588,6 +654,11 @@ pub const Scanner = struct {
588654 const chunk: @Vector(16, u8) = self.source[self.pos..][0..16].*;
589655 const interesting = (chunk == @as(@Vector(16, u8), @splat(ch.CH_LBRACE))) |
590656 (chunk == @as(@Vector(16, u8), @splat(ch.CH_RBRACE))) |
657 (chunk == @as(@Vector(16, u8), @splat(ch.CH_LPAREN))) |
658 (chunk == @as(@Vector(16, u8), @splat(ch.CH_RPAREN))) |
659 (chunk == @as(@Vector(16, u8), @splat(ch.CH_LBRACKET))) |
660 (chunk == @as(@Vector(16, u8), @splat(ch.CH_RBRACKET))) |
661 (chunk == @as(@Vector(16, u8), @splat(ch.CH_LANGLE))) |
591662 (chunk == @as(@Vector(16, u8), @splat(ch.CH_SQUOTE))) |
592663 (chunk == @as(@Vector(16, u8), @splat(ch.CH_DQUOTE))) |
593664 (chunk == @as(@Vector(16, u8), @splat(ch.CH_BACKTICK))) |
@@ -604,6 +675,11 @@ pub const Scanner = struct {
604675 const chunk: @Vector(16, u8) = self.source[self.pos..][0..16].*;
605676 const interesting = (chunk == @as(@Vector(16, u8), @splat(ch.CH_LBRACE))) |
606677 (chunk == @as(@Vector(16, u8), @splat(ch.CH_RBRACE))) |
678 (chunk == @as(@Vector(16, u8), @splat(ch.CH_LPAREN))) |
679 (chunk == @as(@Vector(16, u8), @splat(ch.CH_RPAREN))) |
680 (chunk == @as(@Vector(16, u8), @splat(ch.CH_LBRACKET))) |
681 (chunk == @as(@Vector(16, u8), @splat(ch.CH_RBRACKET))) |
682 (chunk == @as(@Vector(16, u8), @splat(ch.CH_LANGLE))) |
607683 (chunk == @as(@Vector(16, u8), @splat(ch.CH_SQUOTE))) |
608684 (chunk == @as(@Vector(16, u8), @splat(ch.CH_DQUOTE))) |
609685 (chunk == @as(@Vector(16, u8), @splat(ch.CH_BACKTICK))) |
@@ -631,29 +707,62 @@ pub const Scanner = struct {
631707 continue;
632708 }
633709 if (c == ch.CH_SLASH) {
710 // JSX closing tags are not regex literals. Preserving the scan
711 // position here lets ASI terminate at the declaration after the
712 // complete element instead of consuming the following export.
713 if (self.pos > 0 and self.source[self.pos - 1] == ch.CH_LANGLE) {
714 self.pos += 1;
715 continue;
716 }
634717 if (self.skipNonCode()) continue;
635718 self.pos += 1;
636719 continue;
637720 }
721 if (c == ch.CH_LANGLE) {
722 if (jsxTagAt(self.source, self.pos)) |tag| jsx_depth = @max(0, jsx_depth + tag.delta);
723 self.pos += 1;
724 continue;
725 }
638726 if (c == ch.CH_LBRACE) {
639727 brace_depth += 1;
640728 self.pos += 1;
641729 continue;
642730 }
731 if (c == ch.CH_LPAREN) {
732 paren_depth += 1;
733 self.pos += 1;
734 continue;
735 }
736 if (c == ch.CH_RPAREN) {
737 if (paren_depth > 0) paren_depth -= 1;
738 self.pos += 1;
739 continue;
740 }
741 if (c == ch.CH_LBRACKET) {
742 bracket_depth += 1;
743 self.pos += 1;
744 continue;
745 }
746 if (c == ch.CH_RBRACKET) {
747 if (bracket_depth > 0) bracket_depth -= 1;
748 self.pos += 1;
749 continue;
750 }
643751 if (c == ch.CH_RBRACE) {
644 brace_depth -= 1;
645 if (brace_depth <= 0) {
752 const closes_nested_brace = brace_depth > 0;
753 if (closes_nested_brace) brace_depth -= 1;
754 if ((!closes_nested_brace or terminate_on_balanced_brace) and brace_depth == 0 and paren_depth == 0 and bracket_depth == 0 and jsx_depth == 0) {
646755 self.pos += 1;
647756 return;
648757 }
649758 self.pos += 1;
650759 continue;
651760 }
652 if (c == ch.CH_SEMI and brace_depth == 0) {
761 if (c == ch.CH_SEMI and brace_depth == 0 and paren_depth == 0 and bracket_depth == 0 and jsx_depth == 0) {
653762 self.pos += 1;
654763 return;
655764 }
656 if ((c == ch.CH_LF or c == ch.CH_CR) and brace_depth == 0) {
765 if ((c == ch.CH_LF or c == ch.CH_CR) and brace_depth == 0 and paren_depth == 0 and bracket_depth == 0 and jsx_depth == 0) {
657766 if (self.checkASITopLevel()) return;
658767 }
659768 self.pos += 1;
@@ -1094,6 +1203,48 @@ test "scanner template literals balance nested interpolation braces" {
10941203 try std.testing.expectEqualStrings(" rest", source[s.pos..]);
10951204}
10961205
1206test "scanner statement skipping does not treat JSX closing tags as regex" {
1207 const source = "({ value }) => (<div>{value}</div>)\nexport const next = 1";
1208 var s = Scanner.init(std.testing.allocator, source, true, true);
1209 defer s.deinit();
1210 s.skipToStatementEnd();
1211 try std.testing.expectEqual(std.mem.indexOfScalar(u8, source, '\n').?, s.pos);
1212
1213 const unwrapped = "({ value }) => <div>{value}</div>\nexport const next = 1";
1214 var unwrapped_scanner = Scanner.init(std.testing.allocator, unwrapped, true, true);
1215 defer unwrapped_scanner.deinit();
1216 unwrapped_scanner.skipToStatementEnd();
1217 try std.testing.expectEqual(std.mem.indexOfScalar(u8, unwrapped, '\n').?, unwrapped_scanner.pos);
1218
1219 const punctuation = "({ value }) => <div>{value}; ready</div>\nexport const next = 1";
1220 var punctuation_scanner = Scanner.init(std.testing.allocator, punctuation, true, true);
1221 defer punctuation_scanner.deinit();
1222 punctuation_scanner.skipToStatementEnd();
1223 try std.testing.expectEqual(std.mem.indexOfScalar(u8, punctuation, '\n').?, punctuation_scanner.pos);
1224}
1225
1226test "scanner keeps exports after nested JSX callback attributes" {
1227 const source =
1228 \\export const Results = () => (
1229 \\ <List render={item => item.score > 0 ? <strong>{item.label}</strong> : <em>none</em>} />
1230 \\)
1231 \\export const resultCount = 2
1232 ;
1233 var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
1234 defer arena.deinit();
1235 var s = Scanner.init(arena.allocator(), source, true, false);
1236 defer s.deinit();
1237 _ = try s.scan();
1238 try std.testing.expect(s.declarations.items.len > 0);
1239 try std.testing.expectEqualStrings("Results", s.declarations.items[0].name);
1240 try std.testing.expectEqualStrings(
1241 "() => (\n <List render={item => item.score > 0 ? <strong>{item.label}</strong> : <em>none</em>} />\n)",
1242 s.declarations.items[0].value,
1243 );
1244 try std.testing.expectEqual(@as(usize, 2), s.declarations.items.len);
1245 try std.testing.expectEqualStrings("resultCount", s.declarations.items[1].name);
1246}
1247
10971248// --- Tests added for performance/fix patches ---
10981249
10991250// isWordInText: when a hit fails the right-boundary check (e.g. searching for
packages/zig-dtsx/src/type_inference.zigmodified+9-0
Changes to packages/zig-dtsx/src/type_inference.zig
@@ -2245,6 +2245,12 @@ pub fn inferFunctionType(alloc: std.mem.Allocator, value: []const u8, in_union:
22452245 return_type = try inferFunctionBodyReturnType(alloc, body, params, depth + 1);
22462246 } else if (isDeclarationType(body)) {
22472247 return_type = body;
2248 } else if (isJsxExpression(body) or (body.len >= 2 and body[0] == '(' and body[body.len - 1] == ')' and
2249 findMatchingBracket(body, 0, '(', ')') == body.len - 1 and isJsxExpression(trim(body[1 .. body.len - 1]))))
2250 {
2251 // An arrow inside JSX attributes or children is an
2252 // implementation detail, not a higher-order return value.
2253 return_type = "JSX.Element";
22482254 } else if (ch.contains(body, "=>")) {
22492255 // Higher-order function returning another function
22502256 // Try to extract the outer function signature: (params) =>
@@ -2615,6 +2621,9 @@ test "JSX expressions infer portable element types" {
26152621 try std.testing.expectEqualStrings("JSX.Element", try inferNarrowType(alloc, "<><span>One</span><span>Two</span></>", false, false, 0));
26162622 try std.testing.expectEqualStrings("JSX.Element", try inferFunctionBodyReturnType(alloc, "{ return <Card title={title} /> }", "(title: string)", 0));
26172623 try std.testing.expectEqualStrings("null | JSX.Element", try inferFunctionBodyReturnType(alloc, "{ if (hidden) return null; return <Panel /> }", "(hidden: boolean)", 0));
2624 try std.testing.expectEqualStrings("() => JSX.Element", try inferNarrowType(alloc, "() => (<List render={item => <span>{item}</span>} />)", false, false, 0));
2625 try std.testing.expectEqualStrings("() => JSX.Element", try inferNarrowType(alloc, "() => <List render={item => <span>{item}</span>} />", false, false, 0));
2626 try std.testing.expectEqualStrings("() => JSX.Element", try inferNarrowType(alloc, "() => (\n<List title=\"score > threshold\" render={item => item.score > 0 ? <strong>{item.label}</strong> : <em>none</em>} />\n)", false, false, 0));
26182627 try std.testing.expectEqualStrings("boolean", try inferNarrowType(alloc, "<Value>input", false, false, 0));
26192628}
26202629
packages/zig-dtsx/test/react-components.test.tsmodified+71-0
Changes to packages/zig-dtsx/test/react-components.test.ts
@@ -66,4 +66,75 @@ describeIf('Zig React component declarations', () => {
6666 expect(output).toContain("import { memo } from 'react';")
6767 expect(output).toContain('MemoField: ReturnType<typeof memo>;')
6868 })
69
70 test('scans nested JSX attributes without swallowing adjacent exports', () => {
71 const output = dts(`
72 export const Results = () => <List
73 title="score > threshold"
74 render={item => item.score > 0 ? <strong>{item.label}</strong> : <em>none</em>}
75 />
76 export const resultCount = 2
77 `)
78
79 expect(output).toContain('Results: () => JSX.Element;')
80 expect(output).toContain('resultCount: 2;')
81 })
82
83 test('handles generic arrow components and custom element syntax', () => {
84 const output = dts(`
85 export interface CollectionProps<T> { items: T[] }
86 export const Collection = <T,>({ items }: CollectionProps<T>) => <>
87 <svg:path data-state="ready>pending" />
88 <my-element data-count={items.length} />
89 </>
90 `)
91
92 expect(output).toContain('Collection: <T,>({ items }: CollectionProps<T>) => JSX.Element;')
93 })
94
95 test('infers conditional JSX expression returns', () => {
96 const output = dts('export const MaybePanel = (hidden: boolean) => hidden ? null : <section />')
97 expect(output).toContain('MaybePanel: (hidden: boolean) => null | JSX.Element;')
98 })
99
100 test('preserves namespace wrappers and type-only prop imports', () => {
101 const output = dts(`
102 import * as React from 'react'
103 import type { ExternalProps } from './types'
104 const External = (props: ExternalProps) => <Widget {...props} />
105 export const MemoExternal = React.memo(External)
106 `)
107
108 expect(output).toContain("import * as React from 'react';")
109 expect(output).toContain('MemoExternal: ReturnType<typeof React.memo>;')
110 })
111
112 test('isolated declarations skip JSX bodies behind explicit contracts', () => {
113 const output = processSource(`
114 import type { FC } from 'react'
115 export interface DashboardProps { title: string }
116 export const Dashboard: FC<DashboardProps> = ({ title }) => (
117 <DashboardShell title={\`status > \${title}\`}>
118 {title.length > 0 ? <h1>{title}</h1> : null}
119 </DashboardShell>
120 )
121 export const stable: 'yes' = 'yes'
122 `, true, true)
123
124 expect(output).toContain("import type { FC } from 'react';")
125 expect(output).toContain('Dashboard: FC<DashboardProps>;')
126 expect(output).toContain("stable: 'yes';")
127 })
128
129 test('emits anonymous default JSX arrows containing text punctuation', () => {
130 const output = dts(`
131 export interface WelcomeProps { name: string }
132 export default ({ name }: WelcomeProps) => <main>Hello, {name};</main>
133 export const version = '1.0.0'
134 `)
135
136 expect(output).toContain('declare const __dtsx_default_export__: ({ name }: WelcomeProps) => JSX.Element;')
137 expect(output).toContain('export default __dtsx_default_export__;')
138 expect(output).toContain("version: '1.0.0';")
139 })
69140})