Skip to content

Commit c547f68

Browse files
authored
feat: new babel plugin with component metadata (#1)
This PR adds a Babel plugin that injects metadata into `registerPreview()` calls in Rozenite Preview. It captures file path, line, column, and other details, enabling better tracking and tooling support. Key changes: * **New Babel plugin** (`preview-babel-plugin-metadata.cjs`) adds metadata automatically. * **DevTools panel** now shows file locations and adds a "View in VSCode" button. * **`registerPreview`** updated to accept injected metadata by babel and warn if called inside a React component. * **README updated** with babel configuration and HMR limitations. * **Type and config updates** to support the new plugin and metadata handling.
1 parent e79797a commit c547f68

File tree

11 files changed

+485
-65
lines changed

11 files changed

+485
-65
lines changed

README.md

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,17 @@ npm install --save-dev rozenite-preview@alpha
2222
yarn add -D rozenite-preview@alpha
2323
```
2424

25+
## Configuration
26+
27+
Add the following to your `babel.config.js` to get more insights and metadata in your previews:
28+
29+
```js
30+
module.exports = {
31+
presets: ["module:metro-react-native-babel-preset"],
32+
plugins: ["rozenite-preview/babel-plugin"]
33+
};
34+
```
35+
2536
## Usage
2637

2738
1. **Register your components for preview:**
@@ -39,20 +50,16 @@ registerPreview("UserCard", UserCard);
3950
import { PreviewHost } from "rozenite-preview";
4051

4152
export default function App() {
42-
return (
43-
<PreviewHost>
44-
{/* your app */}
45-
</PreviewHost>
46-
);
53+
return <PreviewHost>{/* your app */}</PreviewHost>;
4754
}
4855
```
4956

5057
3. **Open React Native DevTools and use the "Preview" panel**
51-
Select and interact with your registered components in real time.
58+
Select and interact with your registered components in real time.
5259

53-
## Configuration
60+
## Known Issues
5461

55-
No additional configuration is required. The plugin is automatically discovered by Rozenite when installed.
62+
- **HMR Support**: Hot Module Replacement (HMR) is not fully supported yet. You need to refresh the DevTools to see changes in deleted previews. Adding or modifying previews works for now.
5663

5764
## API
5865

@@ -61,8 +68,8 @@ No additional configuration is required. The plugin is automatically discovered
6168

6269
## Requirements
6370

64-
- React Native 0.76+
65-
- React 18+
71+
- React Native 0.79+
72+
- React 19+
6673

6774
## License
6875

Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
const path = require("path");
2+
const crypto = require("crypto");
3+
const { isInsideReactComponent } = require("./react-helper.cjs");
4+
5+
/**
6+
* Babel plugin that automatically injects the file path and other metadata
7+
* to registerPreview() calls imported from "rozenite-preview"
8+
*/
9+
const ROZENITE_PREVIEW_MODULE = "rozenite-preview";
10+
const TARGET_FUNCTION = "registerPreview";
11+
const EXPECTED_ARGS_COUNT = 2;
12+
13+
const cache = {};
14+
15+
function getOrCreateId(file, line) {
16+
const key = `${file}:${line}`;
17+
if (cache[key]) return cache[key];
18+
19+
const hash = crypto.createHash("md5").update(key).digest("hex").slice(0, 6);
20+
const id = `${path
21+
.basename(file, path.extname(file))
22+
.toLowerCase()}_${line}_${hash}`;
23+
cache[key] = id;
24+
return id;
25+
}
26+
27+
module.exports = function ({ types: t }) {
28+
return {
29+
visitor: {
30+
Program(path, state) {
31+
const rozenitePreviewImports = collectRozenitePreviewImports(path, t);
32+
33+
if (rozenitePreviewImports.size === 0) {
34+
return;
35+
}
36+
37+
injectFilePathIntoRegisterPreviewCalls(
38+
path,
39+
state,
40+
rozenitePreviewImports,
41+
t
42+
);
43+
},
44+
},
45+
};
46+
};
47+
48+
/**
49+
* Collects all identifiers imported from "rozenite-preview"
50+
* @param {Object} programPath - The program AST path
51+
* @param {Object} t - Babel types helper
52+
* @returns {Set} Set of imported identifier names
53+
*/
54+
function collectRozenitePreviewImports(programPath, t) {
55+
const imports = new Set();
56+
57+
programPath.get("body").forEach((statement) => {
58+
if (!isRozenitePreviewImportDeclaration(statement)) {
59+
return;
60+
}
61+
62+
statement.node.specifiers.forEach((specifier) => {
63+
if (isValidImportSpecifier(specifier, t)) {
64+
imports.add(specifier.local.name);
65+
}
66+
});
67+
});
68+
69+
return imports;
70+
}
71+
72+
/**
73+
* Checks if a statement is an import from "rozenite-preview"
74+
* @param {Object} statement - AST statement node
75+
* @returns {boolean}
76+
*/
77+
function isRozenitePreviewImportDeclaration(statement) {
78+
return (
79+
statement.isImportDeclaration() &&
80+
statement.node.source.value === ROZENITE_PREVIEW_MODULE
81+
);
82+
}
83+
84+
/**
85+
* Checks if a specifier is a valid import specifier (named or default)
86+
* @param {Object} specifier - Import specifier node
87+
* @param {Object} t - Babel types helper
88+
* @returns {boolean}
89+
*/
90+
function isValidImportSpecifier(specifier, t) {
91+
return (
92+
t.isImportSpecifier(specifier) || t.isImportDefaultSpecifier(specifier)
93+
);
94+
}
95+
96+
/**
97+
* Traverses the AST and injects file paths into registerPreview calls
98+
* @param {Object} programPath - The program AST path
99+
* @param {Object} state - Babel plugin state
100+
* @param {Set} rozenitePreviewImports - Set of imported identifiers from rozenite-preview
101+
* @param {Object} t - Babel types helper
102+
*/
103+
function injectFilePathIntoRegisterPreviewCalls(
104+
programPath,
105+
state,
106+
rozenitePreviewImports,
107+
t
108+
) {
109+
const filename = state.file.opts.filename || "";
110+
const relativeFilename = path.relative(process.cwd(), filename);
111+
112+
programPath.traverse({
113+
CallExpression(callPath) {
114+
if (isTargetRegisterPreviewCall(callPath, rozenitePreviewImports)) {
115+
const metadata = getMetadata(callPath);
116+
const filePath = t.stringLiteral(filename);
117+
const id = getOrCreateId(relativeFilename, metadata.line);
118+
const argument = t.objectExpression([
119+
t.objectProperty(t.identifier("id"), t.stringLiteral(id)),
120+
t.objectProperty(t.identifier("filePath"), filePath),
121+
t.objectProperty(
122+
t.identifier("relativeFilename"),
123+
t.stringLiteral(relativeFilename)
124+
),
125+
t.objectProperty(
126+
t.identifier("isInsideReactComponent"),
127+
t.booleanLiteral(isInsideReactComponent(callPath))
128+
),
129+
t.objectProperty(
130+
t.identifier("name"),
131+
t.stringLiteral(metadata.name)
132+
),
133+
t.objectProperty(
134+
t.identifier("nameType"),
135+
t.stringLiteral(metadata.nameType)
136+
),
137+
t.objectProperty(
138+
t.identifier("callId"),
139+
t.stringLiteral(metadata.callId)
140+
),
141+
t.objectProperty(
142+
t.identifier("componentType"),
143+
t.stringLiteral(metadata.componentType)
144+
),
145+
t.objectProperty(
146+
t.identifier("line"),
147+
t.numericLiteral(metadata.line)
148+
),
149+
t.objectProperty(
150+
t.identifier("column"),
151+
t.numericLiteral(metadata.column)
152+
),
153+
]);
154+
callPath.node.arguments.push(argument);
155+
}
156+
},
157+
});
158+
}
159+
160+
/**
161+
* Determines the component type from the second argument
162+
* @param {Object} componentArg - Component argument AST node
163+
* @returns {string} Component type description
164+
*/
165+
function getComponentType(componentArg) {
166+
if (!componentArg) return "unknown";
167+
168+
switch (componentArg.type) {
169+
case "Identifier":
170+
return `component:${componentArg.name}`;
171+
case "ArrowFunctionExpression":
172+
case "FunctionExpression":
173+
return "inline-function";
174+
case "CallExpression":
175+
return "call-expression";
176+
default:
177+
return componentArg.type.toLowerCase();
178+
}
179+
}
180+
181+
/**
182+
* Extracts detailed information from a registerPreview call
183+
* @param {Object} callPath - The call expression AST path
184+
* @returns {Object} Preview details including name, location, and arguments
185+
*/
186+
function getMetadata(callPath) {
187+
const firstArg = callPath.node.arguments[0];
188+
const secondArg = callPath.node.arguments[1];
189+
const loc = callPath.node.loc;
190+
191+
let name = null;
192+
let nameType = "unknown";
193+
194+
if (firstArg && firstArg.type === "StringLiteral") {
195+
name = firstArg.value;
196+
nameType = "literal";
197+
} else if (firstArg && firstArg.type === "Identifier") {
198+
name = `<dynamic:${firstArg.name}>`;
199+
nameType = "identifier";
200+
}
201+
202+
// Create a unique identifier for this specific call
203+
const callId = `${name}:${loc?.start?.line || 0}:${loc?.start?.column || 0}`;
204+
205+
return {
206+
name,
207+
nameType,
208+
callId,
209+
line: loc?.start?.line || 0,
210+
column: loc?.start?.column || 0,
211+
componentType: getComponentType(secondArg),
212+
};
213+
}
214+
215+
/**
216+
* Determines if a call expression is a registerPreview call that needs modification
217+
* @param {Object} callPath - The call expression AST path
218+
* @param {Set} rozenitePreviewImports - Set of imported identifiers from rozenite-preview
219+
* @returns {boolean}
220+
*/
221+
function isTargetRegisterPreviewCall(callPath, rozenitePreviewImports) {
222+
const callee = callPath.get("callee");
223+
224+
return (
225+
callee.isIdentifier() &&
226+
callee.node.name === TARGET_FUNCTION &&
227+
rozenitePreviewImports.has(callee.node.name) &&
228+
callPath.node.arguments.length === EXPECTED_ARGS_COUNT
229+
);
230+
}

babel-plugin/react-helper.cjs

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
function hasJSXInFunction(functionPath) {
2+
let hasJSX = false;
3+
functionPath.traverse({
4+
JSXElement() {
5+
hasJSX = true;
6+
},
7+
JSXFragment() {
8+
hasJSX = true;
9+
},
10+
});
11+
return hasJSX;
12+
}
13+
14+
function isInsideJSXExpression(path) {
15+
return (
16+
path.findParent(
17+
(parent) =>
18+
parent.isJSXExpressionContainer() ||
19+
parent.isJSXElement() ||
20+
parent.isJSXFragment()
21+
) !== null
22+
);
23+
}
24+
25+
function isInsideReactComponent(path) {
26+
if (!path.isCallExpression()) return false;
27+
28+
// Check if inside JSX
29+
if (isInsideJSXExpression(path)) return true;
30+
31+
const functionParent = path.getFunctionParent();
32+
if (!functionParent) return false;
33+
34+
// React hooks pattern
35+
if (isInsideReactHook(path)) return true;
36+
37+
// Class component lifecycle methods
38+
if (functionParent.isClassMethod()) {
39+
const methodName = functionParent.node.key?.name;
40+
const reactMethods = [
41+
"render",
42+
"componentDidMount",
43+
"componentDidUpdate",
44+
"componentWillUnmount",
45+
"getSnapshotBeforeUpdate",
46+
"componentDidCatch",
47+
"getDerivedStateFromError",
48+
"shouldComponentUpdate",
49+
"getInitialState",
50+
];
51+
return reactMethods.includes(methodName);
52+
}
53+
54+
// Functional component (function that returns JSX)
55+
if (functionParent.isFunction()) {
56+
return isLikelyFunctionalComponent(functionParent);
57+
}
58+
59+
return false;
60+
}
61+
62+
function isInsideReactHook(path) {
63+
return (
64+
path.findParent((parent) => {
65+
if (!parent.isCallExpression()) return false;
66+
const callee = parent.node.callee;
67+
if (callee.type !== "Identifier") return false;
68+
return /^use[A-Z]/.test(callee.name);
69+
}) !== null
70+
);
71+
}
72+
73+
function isLikelyFunctionalComponent(functionPath) {
74+
// Check if function name starts with capital letter (component convention)
75+
const functionName = functionPath.node.id?.name;
76+
if (functionName && /^[A-Z]/.test(functionName)) {
77+
return hasJSXInFunction(functionPath);
78+
}
79+
80+
// Check if assigned to a variable with capital letter
81+
if (
82+
functionPath.isArrowFunctionExpression() ||
83+
functionPath.isFunctionExpression()
84+
) {
85+
const parent = functionPath.parent;
86+
if (
87+
parent.type === "VariableDeclarator" &&
88+
parent.id.name &&
89+
/^[A-Z]/.test(parent.id.name)
90+
) {
91+
return hasJSXInFunction(functionPath);
92+
}
93+
}
94+
95+
return false;
96+
}
97+
98+
module.exports = {
99+
isInsideReactComponent,
100+
};

0 commit comments

Comments
 (0)