Skip to content

Commit 28ea036

Browse files
authored
Fixes related to type and attrs (#11)
* Fixes related to type and attrs * Add debug scripts * Rename
1 parent c1a9bb5 commit 28ea036

19 files changed

Lines changed: 650 additions & 49 deletions

CLAUDE.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,11 @@ test-cases/
5656
└── preview.ts # Storybook preview config
5757
```
5858

59+
## Scripts
60+
61+
- `scripts/debug-test.mjs` - Generates `.actual.tsx` files for failing test cases to compare against expected `.output.tsx` files. Run with `node scripts/debug-test.mjs` after `pnpm build`.
62+
- `scripts/update-fixtures.mjs` - Updates fixture files.
63+
5964
## Adding Test Cases
6065

6166
Create matching `.input.tsx` and `.output.tsx` files in `test-cases/`. Tests auto-discover all pairs and fail if any file is missing its counterpart.

scripts/debug-test.mjs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { applyTransform } from "jscodeshift/src/testUtils.js";
2+
import transform from "../dist/transform.mjs";
3+
import { readFileSync, writeFileSync } from "fs";
4+
import { join } from "path";
5+
6+
// Minimal fixture adapter that returns false for shouldSupportExternalStyles
7+
// (matching the behavior of fixtureAdapter for most test cases)
8+
const fixtureAdapter = {
9+
shouldSupportExternalStyles(ctx) {
10+
return (
11+
ctx.filePath.includes("external-styles-support") && ctx.componentName === "ExportedButton"
12+
);
13+
},
14+
resolveValue(_ctx) {
15+
return null;
16+
},
17+
};
18+
19+
// Get test case names from command line or use defaults
20+
const defaultTestCases = [
21+
"attrs",
22+
"duplicate-type-identifier",
23+
"removed-export",
24+
"static-properties",
25+
];
26+
27+
const testCases = process.argv.slice(2).length > 0 ? process.argv.slice(2) : defaultTestCases;
28+
29+
const projectRoot = join(import.meta.dirname, "..");
30+
const testCasesDir = join(projectRoot, "test-cases");
31+
32+
for (const name of testCases) {
33+
const inputPath = join(testCasesDir, `${name}.input.tsx`);
34+
const input = readFileSync(inputPath, "utf8");
35+
const result = applyTransform(
36+
transform,
37+
{ adapter: fixtureAdapter },
38+
{ source: input, path: inputPath },
39+
{ parser: "tsx" },
40+
);
41+
writeFileSync(join(testCasesDir, `${name}.actual.tsx`), result);
42+
// eslint-disable-next-line no-console
43+
console.log(`Wrote ${name}.actual.tsx`);
44+
}

scripts/update-fixtures.mjs

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
import { readdir, readFile, writeFile } from "node:fs/promises";
2+
import { join, dirname } from "node:path";
3+
import { fileURLToPath } from "node:url";
4+
import { applyTransform } from "jscodeshift/src/testUtils.js";
5+
import { format } from "oxfmt";
6+
import transform from "../dist/transform.mjs";
7+
import { defineAdapter } from "../dist/index.mjs";
8+
9+
const __dirname = dirname(fileURLToPath(import.meta.url));
10+
const repoRoot = join(__dirname, "..");
11+
const testCasesDir = join(repoRoot, "test-cases");
12+
13+
const fixtureAdapter = defineAdapter({
14+
shouldSupportExternalStyles(ctx) {
15+
return (
16+
ctx.filePath.includes("external-styles-support") && ctx.componentName === "ExportedButton"
17+
);
18+
},
19+
resolveValue(ctx) {
20+
if (ctx.kind === "theme") {
21+
if (ctx.path === "color") {
22+
return {
23+
expr: "themeVars",
24+
imports: [
25+
{
26+
from: { kind: "specifier", value: "./tokens.stylex" },
27+
names: [{ imported: "themeVars" }],
28+
},
29+
],
30+
};
31+
}
32+
const lastSegment = ctx.path.split(".").pop();
33+
return {
34+
expr: `themeVars.${lastSegment}`,
35+
imports: [
36+
{
37+
from: { kind: "specifier", value: "./tokens.stylex" },
38+
names: [{ imported: "themeVars" }],
39+
},
40+
],
41+
};
42+
}
43+
44+
if (ctx.kind === "call") {
45+
if (ctx.calleeImportedName !== "transitionSpeed") {
46+
return null;
47+
}
48+
if (ctx.calleeSource.kind !== "absolutePath") {
49+
return null;
50+
}
51+
const src = ctx.calleeSource.value;
52+
if (
53+
!src.endsWith("/test-cases/lib/helpers.ts") &&
54+
!src.endsWith("\\test-cases\\lib\\helpers.ts")
55+
) {
56+
return null;
57+
}
58+
const arg0 = ctx.args[0];
59+
const key = arg0?.kind === "literal" && typeof arg0.value === "string" ? arg0.value : null;
60+
if (
61+
key !== "highlightFadeIn" &&
62+
key !== "highlightFadeOut" &&
63+
key !== "quickTransition" &&
64+
key !== "regularTransition" &&
65+
key !== "slowTransition"
66+
) {
67+
return null;
68+
}
69+
return {
70+
expr: `transitionSpeedVars.${key}`,
71+
imports: [
72+
{
73+
from: { kind: "specifier", value: "./lib/helpers.stylex" },
74+
names: [{ imported: "transitionSpeed", local: "transitionSpeedVars" }],
75+
},
76+
],
77+
};
78+
}
79+
80+
if (ctx.kind === "cssVariable") {
81+
const { name, definedValue } = ctx;
82+
if (name === "--base-size") {
83+
return {
84+
expr: "calcVars.baseSize",
85+
imports: [
86+
{
87+
from: { kind: "specifier", value: "./css-calc.stylex" },
88+
names: [{ imported: "calcVars" }],
89+
},
90+
],
91+
...(definedValue === "16px" ? { dropDefinition: true } : {}),
92+
};
93+
}
94+
95+
const combinedImport = {
96+
from: { kind: "specifier", value: "./css-variables.stylex" },
97+
names: [{ imported: "vars" }, { imported: "textVars" }],
98+
};
99+
const varsMap = {
100+
"--color-primary": "colorPrimary",
101+
"--color-secondary": "colorSecondary",
102+
"--spacing-sm": "spacingSm",
103+
"--spacing-md": "spacingMd",
104+
"--spacing-lg": "spacingLg",
105+
"--border-radius": "borderRadius",
106+
};
107+
const textVarsMap = {
108+
"--text-color": "textColor",
109+
"--font-size": "fontSize",
110+
"--line-height": "lineHeight",
111+
};
112+
113+
const v = varsMap[name];
114+
if (v) {
115+
return { expr: `vars.${v}`, imports: [combinedImport] };
116+
}
117+
const t = textVarsMap[name];
118+
if (t) {
119+
return { expr: `textVars.${t}`, imports: [combinedImport] };
120+
}
121+
}
122+
123+
return null;
124+
},
125+
});
126+
127+
async function normalizeCode(code) {
128+
const { code: formatted } = await format("test.tsx", code);
129+
// Remove extra blank line before return statements in tiny wrapper components:
130+
// const { ... } = props;
131+
//
132+
// return (...)
133+
const cleaned = formatted.replace(
134+
/\n(\s*(?:const|let|var)\s+[^\n]+;\n)\n(\s*return\b)/g,
135+
"\n$1$2",
136+
);
137+
return cleaned.trimEnd() + "\n";
138+
}
139+
140+
async function listFixtureNames() {
141+
const files = await readdir(testCasesDir);
142+
const inputNames = files
143+
.filter(
144+
(f) =>
145+
f.endsWith(".input.tsx") && !f.startsWith("_unsupported.") && !f.startsWith("unsupported-"),
146+
)
147+
.map((f) => f.replace(".input.tsx", ""));
148+
return inputNames.sort();
149+
}
150+
151+
async function updateFixture(name) {
152+
const inputPath = join(testCasesDir, `${name}.input.tsx`);
153+
const outputPath = join(testCasesDir, `${name}.output.tsx`);
154+
const input = await readFile(inputPath, "utf-8");
155+
156+
const result = applyTransform(
157+
transform,
158+
{ adapter: fixtureAdapter },
159+
{ source: input, path: inputPath },
160+
{ parser: "tsx" },
161+
);
162+
const out = result || input;
163+
await writeFile(outputPath, await normalizeCode(out), "utf-8");
164+
return outputPath;
165+
}
166+
167+
const args = new Set(process.argv.slice(2));
168+
const only = args.has("--only") ? process.argv[process.argv.indexOf("--only") + 1] : null;
169+
170+
const targetNames = (() => {
171+
if (only) {
172+
return only
173+
.split(",")
174+
.map((s) => s.trim())
175+
.filter(Boolean);
176+
}
177+
// Default: update all fixtures that have outputs (excluding unsupported).
178+
return null;
179+
})();
180+
181+
const names = targetNames ?? (await listFixtureNames());
182+
for (const name of names) {
183+
// Skip when output file doesn't exist (should only happen for unsupported fixtures).
184+
const outPath = join(testCasesDir, `${name}.output.tsx`);
185+
try {
186+
await readFile(outPath, "utf-8");
187+
} catch {
188+
continue;
189+
}
190+
await updateFixture(name);
191+
}

src/internal/collect-styled-decls.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -628,6 +628,46 @@ export function collectStyledDecls(args: {
628628
return;
629629
}
630630

631+
// styled(Component).attrs(...)`...` - component with attrs
632+
if (
633+
tag.type === "CallExpression" &&
634+
tag.callee.type === "MemberExpression" &&
635+
tag.callee.property.type === "Identifier" &&
636+
tag.callee.property.name === "attrs" &&
637+
tag.callee.object.type === "CallExpression" &&
638+
tag.callee.object.callee.type === "Identifier" &&
639+
tag.callee.object.callee.name === styledDefaultImport &&
640+
tag.callee.object.arguments.length === 1 &&
641+
tag.callee.object.arguments[0]?.type === "Identifier"
642+
) {
643+
const localName = id.name;
644+
const ident = tag.callee.object.arguments[0].name;
645+
const styleKey = localName === `Styled${ident}` ? toStyleKey(ident) : toStyleKey(localName);
646+
const template = init.quasi;
647+
const parsed = parseStyledTemplateLiteral(template);
648+
const rules = normalizeStylisAstToIR(parsed.stylisAst, parsed.slots, {
649+
rawCss: parsed.rawCss,
650+
});
651+
if (hasUniversalSelectorInRules(rules)) {
652+
noteUniversalSelector(template);
653+
}
654+
const attrsInfo = parseAttrsArg(tag.arguments[0]);
655+
656+
styledDecls.push({
657+
...placementHints,
658+
localName,
659+
base: { kind: "component", ident },
660+
styleKey,
661+
rules,
662+
templateExpressions: parsed.slots.map((s) => s.expression),
663+
rawCss: parsed.rawCss,
664+
...(attrsInfo ? { attrsInfo } : {}),
665+
...(propsType ? { propsType } : {}),
666+
...(leadingComments ? { leadingComments } : {}),
667+
});
668+
return;
669+
}
670+
631671
// styled(Component) - where Component is an Identifier
632672
if (
633673
tag.type === "CallExpression" &&

src/internal/css-prop-mapping.ts

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,19 @@ export function cssDeclarationToStylexDeclarations(decl: CssDeclarationIR): Styl
2626
if (decl.value.kind === "interpolated") {
2727
return [{ prop: "border", value: decl.value }];
2828
}
29-
return borderShorthandToStylex(raw);
29+
return borderShorthandToStylex(raw, "");
30+
}
31+
32+
// Handle directional border shorthands: border-top, border-right, border-bottom, border-left
33+
const borderDirectionMatch = prop.match(/^border-(top|right|bottom|left)$/);
34+
if (borderDirectionMatch) {
35+
const direction = borderDirectionMatch[1]!;
36+
const directionCapitalized = direction.charAt(0).toUpperCase() + direction.slice(1);
37+
const raw = decl.valueRaw.trim();
38+
if (decl.value.kind === "interpolated") {
39+
return [{ prop: cssPropertyToStylexProp(prop), value: decl.value }];
40+
}
41+
return borderShorthandToStylex(raw, directionCapitalized);
3042
}
3143

3244
return [{ prop: cssPropertyToStylexProp(prop), value: decl.value }];
@@ -39,12 +51,23 @@ export function cssPropertyToStylexProp(prop: string): string {
3951
return prop.replace(/-([a-z])/g, (_, ch: string) => ch.toUpperCase());
4052
}
4153

42-
function borderShorthandToStylex(valueRaw: string): StylexPropDecl[] {
54+
/**
55+
* Expands a border shorthand value into separate width/style/color properties.
56+
* @param valueRaw - The raw CSS value like "1px solid red"
57+
* @param direction - Optional direction suffix like "Top", "Right", "Bottom", "Left"
58+
* Empty string for the base "border" property
59+
*/
60+
function borderShorthandToStylex(valueRaw: string, direction: string): StylexPropDecl[] {
4361
const v = valueRaw.trim();
62+
const widthProp = `border${direction}Width`;
63+
const styleProp = `border${direction}Style`;
64+
const colorProp = `border${direction}Color`;
65+
const baseProp = direction ? `border${direction}` : "border";
66+
4467
if (v === "none") {
4568
return [
46-
{ prop: "borderWidth", value: { kind: "static", value: "0" } },
47-
{ prop: "borderStyle", value: { kind: "static", value: "none" } },
69+
{ prop: widthProp, value: { kind: "static", value: "0" } },
70+
{ prop: styleProp, value: { kind: "static", value: "none" } },
4871
];
4972
}
5073

@@ -69,16 +92,16 @@ function borderShorthandToStylex(valueRaw: string): StylexPropDecl[] {
6992
const color = colorParts.join(" ").trim();
7093
const out: StylexPropDecl[] = [];
7194
if (width) {
72-
out.push({ prop: "borderWidth", value: { kind: "static", value: width } });
95+
out.push({ prop: widthProp, value: { kind: "static", value: width } });
7396
}
7497
if (style) {
75-
out.push({ prop: "borderStyle", value: { kind: "static", value: style } });
98+
out.push({ prop: styleProp, value: { kind: "static", value: style } });
7699
}
77100
if (color) {
78-
out.push({ prop: "borderColor", value: { kind: "static", value: color } });
101+
out.push({ prop: colorProp, value: { kind: "static", value: color } });
79102
}
80103
if (out.length === 0) {
81-
return [{ prop: "border", value: { kind: "static", value: v } }];
104+
return [{ prop: baseProp, value: { kind: "static", value: v } }];
82105
}
83106
return out;
84107
}

src/internal/emit-wrappers.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -452,6 +452,20 @@ export function emitWrappers(args: {
452452
return null;
453453
};
454454

455+
// Check if a type/interface with the given name already exists in the file
456+
const typeExistsInFile = (typeName: string): boolean => {
457+
const typeAliases = root.find(j.TSTypeAliasDeclaration, {
458+
id: { type: "Identifier", name: typeName },
459+
} as any);
460+
if (typeAliases.size() > 0) {
461+
return true;
462+
}
463+
const interfaces = root.find(j.TSInterfaceDeclaration, {
464+
id: { type: "Identifier", name: typeName },
465+
} as any);
466+
return interfaces.size() > 0;
467+
};
468+
455469
/**
456470
* Emits a named props type alias and returns whether it was emitted.
457471
* Returns false if the type would shadow an existing type with the same name.
@@ -461,6 +475,10 @@ export function emitWrappers(args: {
461475
return false;
462476
}
463477
const typeName = propsTypeNameFor(localName);
478+
// Skip if a type/interface with this name already exists in the file
479+
if (typeExistsInFile(typeName)) {
480+
return false;
481+
}
464482
// Skip if the type expression is the same as the type name, or if it
465483
// contains a reference to the type name (which would create shadowing issues
466484
// if an interface/type with the same name already exists in the file).

0 commit comments

Comments
 (0)