Skip to content

Commit e88948b

Browse files
committed
Add latest code of jsx transformer
1 parent 2af5ab0 commit e88948b

File tree

2 files changed

+169
-47
lines changed

2 files changed

+169
-47
lines changed

client/src/commands/paste_as_rescript_jsx.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ export const convertPlainTextToRescriptJsx = (
1616
}
1717

1818
try {
19+
// If you ever need to fix a bug in transformJsx,
20+
// please do so in https://github.com/nojaf/vanilla-jsx-to-rescript-jsx/blob/main/index.ts
21+
// and then copy the changes to transform-jsx.ts
1922
const formatted = transformJsx(text);
2023
return { kind: "success", formatted };
2124
} catch (error) {

client/src/commands/transform-jsx.ts

Lines changed: 166 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,170 @@ const floatRegex = /^-?\d+(\.\d+)?$/;
77

88
const rescriptKeywords = new Set(["type", "open", "as", "in"]);
99

10+
type Rule<T extends Node = Node> = {
11+
match: (node: T, parent: Node | null) => boolean;
12+
transform: (node: T, parent: Node | null, magicString: MagicString) => void;
13+
stopAfterMatch?: boolean; // If true, stop applying further rules after this one matches
14+
};
15+
16+
// Single quotes to double quotes
17+
const singleQuotesToDouble: Rule<Node> = {
18+
match: (node) =>
19+
node.type === "JSXAttribute" &&
20+
node.value?.type === "Literal" &&
21+
typeof node.value.raw === "string" &&
22+
node.value.raw.startsWith("'"),
23+
transform: (node, _, magicString) => {
24+
const attr = node as Extract<Node, { type: "JSXAttribute" }>;
25+
const value = attr.value as Extract<typeof attr.value, { type: "Literal" }>;
26+
magicString.update(value.start, value.end, `"${value.raw!.slice(1, -1)}"`);
27+
},
28+
};
29+
30+
// SVG width/height numeric to string
31+
const svgWidthHeightToString: Rule<Node> = {
32+
match: (node, parent) =>
33+
node.type === "JSXAttribute" &&
34+
parent?.type === "JSXOpeningElement" &&
35+
parent.name.type === "JSXIdentifier" &&
36+
parent.name.name.toLowerCase() === "svg" &&
37+
node.name.type === "JSXIdentifier" &&
38+
(node.name.name === "width" || node.name.name === "height") &&
39+
node.value?.type === "JSXExpressionContainer" &&
40+
node.value.expression?.type === "Literal" &&
41+
typeof node.value.expression.value === "number",
42+
transform: (node, _, magicString) => {
43+
const attr = node as Extract<Node, { type: "JSXAttribute" }>;
44+
const value = attr.value as Extract<
45+
typeof attr.value,
46+
{ type: "JSXExpressionContainer" }
47+
>;
48+
const expression = value.expression as Extract<
49+
typeof value.expression,
50+
{ type: "Literal" }
51+
>;
52+
const numericValue = String(expression.value);
53+
magicString.update(value.start, value.end, `"${numericValue}"`);
54+
},
55+
};
56+
57+
// Rescript keywords get underscore suffix
58+
const rescriptKeywordUnderscore: Rule<Node> = {
59+
match: (node) =>
60+
node.type === "JSXAttribute" &&
61+
node.name.type === "JSXIdentifier" &&
62+
rescriptKeywords.has(node.name.name),
63+
transform: (node, _, magicString) => {
64+
const attr = node as Extract<Node, { type: "JSXAttribute" }>;
65+
magicString.appendRight(attr.name.end, "_");
66+
},
67+
};
68+
69+
// aria- attributes to camelCase
70+
const ariaToCamelCase: Rule<Node> = {
71+
match: (node) =>
72+
node.type === "JSXAttribute" &&
73+
node.name.type === "JSXIdentifier" &&
74+
typeof node.name.name === "string" &&
75+
node.name.name.startsWith("aria-"),
76+
transform: (node, _, magicString) => {
77+
const attr = node as Extract<Node, { type: "JSXAttribute" }>;
78+
const name = attr.name.name as string;
79+
magicString.update(
80+
attr.name.start + 4,
81+
attr.name.start + 6,
82+
name[5]?.toUpperCase() || "",
83+
);
84+
},
85+
};
86+
87+
// data-testid to dataTestId
88+
const dataTestIdToCamelCase: Rule<Node> = {
89+
match: (node) =>
90+
node.type === "JSXAttribute" &&
91+
node.name.type === "JSXIdentifier" &&
92+
node.name.name === "data-testid",
93+
transform: (node, _, magicString) => {
94+
const attr = node as Extract<Node, { type: "JSXAttribute" }>;
95+
magicString.update(attr.name.start, attr.name.end, "dataTestId");
96+
},
97+
};
98+
99+
// Null values become =true
100+
const nullValueToTrue: Rule<Node> = {
101+
match: (node) => node.type === "JSXAttribute" && node.value === null,
102+
transform: (node, _, magicString) => {
103+
magicString.appendRight(node.end, "=true");
104+
},
105+
};
106+
107+
// Integer text nodes
108+
const integerTextNode: Rule<Node> = {
109+
match: (node) =>
110+
node.type === "JSXText" &&
111+
typeof node.raw === "string" &&
112+
integerRegex.test(node.raw.trim()),
113+
transform: (node, _, magicString) => {
114+
magicString.prependLeft(node.start, "{React.int(");
115+
magicString.appendRight(node.end, ")}");
116+
},
117+
stopAfterMatch: true,
118+
};
119+
120+
// Float text nodes
121+
const floatTextNode: Rule<Node> = {
122+
match: (node) =>
123+
node.type === "JSXText" &&
124+
typeof node.raw === "string" &&
125+
floatRegex.test(node.raw.trim()),
126+
transform: (node, _, magicString) => {
127+
magicString.prependLeft(node.start, "{React.float(");
128+
magicString.appendRight(node.end, ")}");
129+
},
130+
stopAfterMatch: true,
131+
};
132+
133+
// String text nodes
134+
const stringTextNode: Rule<Node> = {
135+
match: (node) =>
136+
node.type === "JSXText" &&
137+
typeof node.value === "string" &&
138+
node.value.trim() !== "",
139+
transform: (node, _, magicString) => {
140+
magicString.prependLeft(node.start, '{React.string("');
141+
magicString.appendRight(node.end, '")}');
142+
},
143+
stopAfterMatch: true,
144+
};
145+
146+
const rules: Rule<Node>[] = [
147+
singleQuotesToDouble,
148+
svgWidthHeightToString,
149+
rescriptKeywordUnderscore,
150+
ariaToCamelCase,
151+
dataTestIdToCamelCase,
152+
nullValueToTrue,
153+
integerTextNode,
154+
floatTextNode,
155+
stringTextNode,
156+
];
157+
158+
function applyRules(
159+
node: Node,
160+
parent: Node | null,
161+
rules: Rule<Node>[],
162+
magicString: MagicString,
163+
): void {
164+
for (const rule of rules) {
165+
if (rule.match(node, parent)) {
166+
rule.transform(node, parent, magicString);
167+
if (rule.stopAfterMatch) {
168+
break;
169+
}
170+
}
171+
}
172+
}
173+
10174
export function transformJsx(input: string): string {
11175
const magicString = new MagicString(input);
12176
const parseResult = parseSync("clipboard-input.tsx", input, {
@@ -15,53 +179,8 @@ export function transformJsx(input: string): string {
15179
});
16180

17181
walk(parseResult.program, {
18-
enter: (node: Node) => {
19-
if (node.type === "JSXAttribute") {
20-
if (node.value?.type === "Literal" && node.value.raw?.startsWith("'")) {
21-
magicString.update(
22-
node.value.start,
23-
node.value.end,
24-
`"${node.value.raw.slice(1, -1)}"`,
25-
);
26-
}
27-
28-
if (
29-
typeof node.name.name === "string" &&
30-
rescriptKeywords.has(node.name.name)
31-
) {
32-
magicString.appendRight(node.name.end, "_");
33-
}
34-
35-
if (
36-
typeof node.name.name === "string" &&
37-
node.name.name.startsWith("aria-")
38-
) {
39-
magicString.update(
40-
node.name.start + 4,
41-
node.name.start + 6,
42-
node.name.name[5]?.toUpperCase() || "",
43-
);
44-
}
45-
46-
if (node.name.name === "data-testid") {
47-
magicString.update(node.name.start, node.name.end, "dataTestId");
48-
}
49-
50-
if (node.value === null) {
51-
magicString.appendRight(node.end, "=true");
52-
}
53-
} else if (node.type === "JSXText") {
54-
if (node.raw && integerRegex.test(node.raw.trim())) {
55-
magicString.prependLeft(node.start, "{React.int(");
56-
magicString.appendRight(node.end, ")}");
57-
} else if (node.raw && floatRegex.test(node.raw.trim())) {
58-
magicString.prependLeft(node.start, "{React.float(");
59-
magicString.appendRight(node.end, ")}");
60-
} else if (node.value.trim()) {
61-
magicString.prependLeft(node.start, '{React.string("');
62-
magicString.appendRight(node.end, '")}');
63-
}
64-
}
182+
enter: (node: Node, parent: Node | null) => {
183+
applyRules(node, parent, rules, magicString);
65184
},
66185
});
67186

0 commit comments

Comments
 (0)