Visual editor (Pro only) (#1828)

<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
Prototype visual editing mode for the preview app. Toggle the mode, pick
elements (single or multiple), and edit margin, padding, border,
background, static text, and text styles with live updates, then save
changes back to code.

- **New Features**
- Pen tool button to enable/disable visual editing in the preview and
toggle single/multi select; pro-only.
- Inline toolbar anchored to the selected element for Margin (X/Y),
Padding (X/Y), Border (width/radius/color), Background color, Edit Text
(when static), and Text Style (font size/weight/color/font family).
- Reads computed styles from the iframe and applies changes in real
time; auto-appends px; overlay updates on scroll/resize.
- Save/Discard dialog batches edits and writes Tailwind classes to
source files via IPC; uses AST/recast to update className and text,
replacing conflicting classes by prefix; supports multiple components.
- New visual editor worker to get/apply styles and enable inline text
editing via postMessage; selector client updated for coordinates
streaming and highlight/deselect.
- Proxy injects the visual editor client; new atoms track selected
component, coordinates, and pending changes; component analysis flags
dynamic styling and static text.
  - Uses runtimeId to correctly target and edit duplicate components.

- **Dependencies**
  - Added @babel/parser for AST-based text updates.
  - Added recast for safer code transformations.

<sup>Written for commit cdd50d33387a29103864f4743ae7570d64d61e93.
Summary will update automatically on new commits.</sup>

<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Mohamed Aziz Mejri
2025-12-09 22:09:19 +01:00
committed by GitHub
parent c174778d5f
commit 352d4330ed
28 changed files with 3455 additions and 65 deletions

View File

@@ -0,0 +1,125 @@
import { ipcMain } from "electron";
import fs from "node:fs";
import { promises as fsPromises } from "node:fs";
import path from "path";
import { db } from "../../../../db";
import { apps } from "../../../../db/schema";
import { eq } from "drizzle-orm";
import { getDyadAppPath } from "../../../../paths/paths";
import {
stylesToTailwind,
extractClassPrefixes,
} from "../../../../utils/style-utils";
import git from "isomorphic-git";
import { gitCommit } from "../../../../ipc/utils/git_utils";
import { safeJoin } from "@/ipc/utils/path_utils";
import {
AnalyseComponentParams,
ApplyVisualEditingChangesParams,
} from "@/ipc/ipc_types";
import {
transformContent,
analyzeComponent,
} from "../../utils/visual_editing_utils";
export function registerVisualEditingHandlers() {
ipcMain.handle(
"apply-visual-editing-changes",
async (_event, params: ApplyVisualEditingChangesParams) => {
const { appId, changes } = params;
try {
if (changes.length === 0) return;
// Get the app to find its path
const app = await db.query.apps.findFirst({
where: eq(apps.id, appId),
});
if (!app) {
throw new Error(`App not found: ${appId}`);
}
const appPath = getDyadAppPath(app.path);
const fileChanges = new Map<
string,
Map<
number,
{ classes: string[]; prefixes: string[]; textContent?: string }
>
>();
// Group changes by file and line
for (const change of changes) {
if (!fileChanges.has(change.relativePath)) {
fileChanges.set(change.relativePath, new Map());
}
const tailwindClasses = stylesToTailwind(change.styles);
const changePrefixes = extractClassPrefixes(tailwindClasses);
fileChanges.get(change.relativePath)!.set(change.lineNumber, {
classes: tailwindClasses,
prefixes: changePrefixes,
...(change.textContent !== undefined && {
textContent: change.textContent,
}),
});
}
// Apply changes to each file
for (const [relativePath, lineChanges] of fileChanges) {
const filePath = safeJoin(appPath, relativePath);
const content = await fsPromises.readFile(filePath, "utf-8");
const transformedContent = transformContent(content, lineChanges);
await fsPromises.writeFile(filePath, transformedContent, "utf-8");
// Check if git repository exists and commit the change
if (fs.existsSync(path.join(appPath, ".git"))) {
await git.add({
fs,
dir: appPath,
filepath: relativePath,
});
await gitCommit({
path: appPath,
message: `Updated ${relativePath}`,
});
}
}
} catch (error) {
throw new Error(`Failed to apply visual editing changes: ${error}`);
}
},
);
ipcMain.handle(
"analyze-component",
async (_event, analyseComponentParams: AnalyseComponentParams) => {
const { appId, componentId } = analyseComponentParams;
try {
const [filePath, lineStr] = componentId.split(":");
const line = parseInt(lineStr, 10);
if (!filePath || isNaN(line)) {
return { isDynamic: false, hasStaticText: false };
}
// Get the app to find its path
const app = await db.query.apps.findFirst({
where: eq(apps.id, appId),
});
if (!app) {
throw new Error(`App not found: ${appId}`);
}
const appPath = getDyadAppPath(app.path);
const fullPath = safeJoin(appPath, filePath);
const content = await fsPromises.readFile(fullPath, "utf-8");
return analyzeComponent(content, line);
} catch (error) {
console.error("Failed to analyze component:", error);
return { isDynamic: false, hasStaticText: false };
}
},
);
}

View File

@@ -0,0 +1,617 @@
import { describe, it, expect } from "vitest";
import { transformContent, analyzeComponent } from "./visual_editing_utils";
describe("transformContent", () => {
describe("className manipulation", () => {
it("should add className attribute when none exists", () => {
const content = `
function Component() {
return <div>Hello</div>;
}`;
const changes = new Map([
[3, { classes: ["bg-[#ff0000]", "p-[16px]"], prefixes: ["bg-", "p-"] }],
]);
const result = transformContent(content, changes);
expect(result).toContain('className="bg-[#ff0000] p-[16px]"');
});
it("should append classes to existing className", () => {
const content = `
function Component() {
return <div className="existing-class">Hello</div>;
}`;
const changes = new Map([
[3, { classes: ["bg-[#0000ff]"], prefixes: ["bg-"] }],
]);
const result = transformContent(content, changes);
expect(result).toContain("existing-class");
expect(result).toContain("bg-[#0000ff]");
});
it("should remove classes with matching prefixes", () => {
const content = `
function Component() {
return <div className="bg-[#ff0000] p-[16px] text-[18px]">Hello</div>;
}`;
const changes = new Map([
[3, { classes: ["bg-[#0000ff]"], prefixes: ["bg-"] }],
]);
const result = transformContent(content, changes);
expect(result).not.toContain("bg-[#ff0000]");
expect(result).toContain("bg-[#0000ff]");
expect(result).toContain("p-[16px]");
expect(result).toContain("text-[18px]");
});
it("should handle font-weight classes correctly", () => {
const content = `
function Component() {
return <div className="font-[600] text-lg">Hello</div>;
}`;
const changes = new Map([
[3, { classes: ["font-[700]"], prefixes: ["font-weight-"] }],
]);
const result = transformContent(content, changes);
expect(result).not.toContain("font-[600]");
expect(result).toContain("font-[700]");
expect(result).toContain("text-lg");
});
it("should handle font-family classes without removing font-weight", () => {
const content = `
function Component() {
return <div className="font-[600] font-[Inter] text-lg">Hello</div>;
}`;
const changes = new Map([
[3, { classes: ["font-[Roboto]"], prefixes: ["font-family-"] }],
]);
const result = transformContent(content, changes);
expect(result).toContain("font-[600]");
expect(result).not.toContain("font-[Inter]");
expect(result).toContain("font-[Roboto]");
});
it("should handle text-size classes without removing text-color or text-align", () => {
const content = `
function Component() {
return <div className="text-[18px] text-[center] text-[#ff0000]">Hello</div>;
}`;
const changes = new Map([
[3, { classes: ["text-[24px]"], prefixes: ["text-size-"] }],
]);
const result = transformContent(content, changes);
expect(result).not.toContain("text-[18px]");
expect(result).toContain("text-[24px]");
expect(result).toContain("text-[center]");
expect(result).toContain("text-[#ff0000]");
});
it("should handle arbitrary text-size values", () => {
const content = `
function Component() {
return <div className="text-[44px] text-center">Hello</div>;
}`;
const changes = new Map([
[3, { classes: ["text-[32px]"], prefixes: ["text-size-"] }],
]);
const result = transformContent(content, changes);
expect(result).not.toContain("text-[44px]");
expect(result).toContain("text-[32px]");
expect(result).toContain("text-center");
});
it("should remove mt-, mb-, my- when applying my- prefix", () => {
const content = `
function Component() {
return <div className="mt-[16px] mb-[8px] mx-[24px]">Hello</div>;
}`;
const changes = new Map([
[3, { classes: ["my-[20px]"], prefixes: ["my-"] }],
]);
const result = transformContent(content, changes);
expect(result).not.toContain("mt-[16px]");
expect(result).not.toContain("mb-[8px]");
expect(result).toContain("my-[20px]");
expect(result).toContain("mx-[24px]");
});
it("should remove ml-, mr-, mx- when applying mx- prefix", () => {
const content = `
function Component() {
return <div className="ml-[16px] mr-[8px] my-[24px]">Hello</div>;
}`;
const changes = new Map([
[3, { classes: ["mx-[20px]"], prefixes: ["mx-"] }],
]);
const result = transformContent(content, changes);
expect(result).not.toContain("ml-[16px]");
expect(result).not.toContain("mr-[8px]");
expect(result).toContain("mx-[20px]");
expect(result).toContain("my-[24px]");
});
it("should handle padding classes similarly to margin", () => {
const content = `
function Component() {
return <div className="pt-[16px] pb-[8px] px-[24px]">Hello</div>;
}`;
const changes = new Map([
[3, { classes: ["py-[20px]"], prefixes: ["py-"] }],
]);
const result = transformContent(content, changes);
expect(result).not.toContain("pt-[16px]");
expect(result).not.toContain("pb-[8px]");
expect(result).toContain("py-[20px]");
expect(result).toContain("px-[24px]");
});
});
describe("text content manipulation", () => {
it("should update text content for elements with only text", () => {
const content = `
function Component() {
return <div>Old text</div>;
}`;
const changes = new Map([
[
3,
{
classes: [],
prefixes: [],
textContent: "New text",
},
],
]);
const result = transformContent(content, changes);
expect(result).not.toContain("Old text");
expect(result).toContain("New text");
});
it("should not update text content when element has nested JSX", () => {
const content = `
function Component() {
return <div>Old text <span>nested</span></div>;
}`;
const changes = new Map([
[
3,
{
classes: [],
prefixes: [],
textContent: "New text",
},
],
]);
const result = transformContent(content, changes);
expect(result).toContain("Old text");
expect(result).toContain("<span>nested</span>");
});
it("should update text content and classes together", () => {
const content = `
function Component() {
return <div className="text-[18px]">Old text</div>;
}`;
const changes = new Map([
[
3,
{
classes: ["text-[24px]"],
prefixes: ["text-size-"],
textContent: "New text",
},
],
]);
const result = transformContent(content, changes);
expect(result).toContain("text-[24px]");
expect(result).not.toContain("text-[18px]");
expect(result).toContain("New text");
expect(result).not.toContain("Old text");
});
});
describe("spacing edge cases", () => {
it("should split m-[] into my-[] when adding mx-[]", () => {
const content = `
function Component() {
return <div className="m-[20px]">Content</div>;
}`;
const changes = new Map([
[
3,
{
classes: ["mx-[10px]"],
prefixes: ["mx-"],
},
],
]);
const result = transformContent(content, changes);
expect(result).not.toContain("m-[20px]");
expect(result).toContain("my-[20px]");
expect(result).toContain("mx-[10px]");
});
it("should split m-[] into mx-[] when adding my-[]", () => {
const content = `
function Component() {
return <div className="m-[20px]">Content</div>;
}`;
const changes = new Map([
[
3,
{
classes: ["my-[10px]"],
prefixes: ["my-"],
},
],
]);
const result = transformContent(content, changes);
expect(result).not.toContain("m-[20px]");
expect(result).toContain("mx-[20px]");
expect(result).toContain("my-[10px]");
});
it("should split p-[] into py-[] when adding px-[]", () => {
const content = `
function Component() {
return <div className="p-[16px]">Content</div>;
}`;
const changes = new Map([
[
3,
{
classes: ["px-[8px]"],
prefixes: ["px-"],
},
],
]);
const result = transformContent(content, changes);
expect(result).not.toContain("p-[16px]");
expect(result).toContain("py-[16px]");
expect(result).toContain("px-[8px]");
});
it("should split p-[] into px-[] when adding py-[]", () => {
const content = `
function Component() {
return <div className="p-[16px]">Content</div>;
}`;
const changes = new Map([
[
3,
{
classes: ["py-[8px]"],
prefixes: ["py-"],
},
],
]);
const result = transformContent(content, changes);
expect(result).not.toContain("p-[16px]");
expect(result).toContain("px-[16px]");
expect(result).toContain("py-[8px]");
});
it("should not add complementary class when both directional classes are added", () => {
const content = `
function Component() {
return <div className="m-[20px]">Content</div>;
}`;
const changes = new Map([
[
3,
{
classes: ["mx-[10px]", "my-[15px]"],
prefixes: ["mx-", "my-"],
},
],
]);
const result = transformContent(content, changes);
expect(result).not.toContain("m-[20px]");
expect(result).toContain("mx-[10px]");
expect(result).toContain("my-[15px]");
// Should not have added an extra mx- or my- with the original value
expect(result.match(/mx-/g)?.length).toBe(1);
expect(result.match(/my-/g)?.length).toBe(1);
});
});
describe("multiple changes", () => {
it("should apply changes to multiple lines", () => {
const content = `
function Component() {
return (
<div>
<h1 className="text-[18px]">Title</h1>
<p className="text-[14px]">Paragraph</p>
</div>
);
}`;
const changes = new Map([
[5, { classes: ["text-[32px]"], prefixes: ["text-size-"] }],
[6, { classes: ["text-[16px]"], prefixes: ["text-size-"] }],
]);
const result = transformContent(content, changes);
expect(result).toContain("text-[32px]");
expect(result).not.toContain("text-[18px]");
expect(result).toContain("text-[16px]");
expect(result).not.toContain("text-[14px]");
});
});
describe("edge cases", () => {
it("should handle empty changes map", () => {
const content = `
function Component() {
return <div className="text-[18px]">Hello</div>;
}`;
const changes = new Map();
const result = transformContent(content, changes);
expect(result).toContain("text-[18px]");
expect(result).toContain("Hello");
});
it("should preserve code formatting", () => {
const content = `
function Component() {
return (
<div className="text-[18px]">
Hello
</div>
);
}`;
const changes = new Map([
[4, { classes: ["text-[24px]"], prefixes: ["text-size-"] }],
]);
const result = transformContent(content, changes);
expect(result).toContain("text-[24px]");
// Recast should preserve overall structure
expect(result).toMatch(/return\s*\(/);
});
});
});
describe("analyzeComponent", () => {
describe("dynamic styling detection", () => {
it("should detect conditional className", () => {
const content = `
function Component() {
return <div className={isActive ? "active" : "inactive"}>Hello</div>;
}`;
const result = analyzeComponent(content, 3);
expect(result.isDynamic).toBe(true);
});
it("should detect logical expression className", () => {
const content = `
function Component() {
return <div className={isActive && "active"}>Hello</div>;
}`;
const result = analyzeComponent(content, 3);
expect(result.isDynamic).toBe(true);
});
it("should detect template literal className", () => {
const content = `
function Component() {
return <div className={\`base-class \${dynamicClass}\`}>Hello</div>;
}`;
const result = analyzeComponent(content, 3);
expect(result.isDynamic).toBe(true);
});
it("should detect identifier className", () => {
const content = `
function Component() {
return <div className={styles.container}>Hello</div>;
}`;
const result = analyzeComponent(content, 3);
expect(result.isDynamic).toBe(true);
});
it("should detect function call className", () => {
const content = `
function Component() {
return <div className={cn("base", { active: isActive })}>Hello</div>;
}`;
const result = analyzeComponent(content, 3);
expect(result.isDynamic).toBe(true);
});
it("should detect dynamic style attribute", () => {
const content = `
function Component() {
return <div style={{ color: isActive ? "red" : "blue" }}>Hello</div>;
}`;
const result = analyzeComponent(content, 3);
expect(result.isDynamic).toBe(true);
});
it("should not detect static className", () => {
const content = `
function Component() {
return <div className="static-class">Hello</div>;
}`;
const result = analyzeComponent(content, 3);
expect(result.isDynamic).toBe(false);
});
it("should not detect when no className or style", () => {
const content = `
function Component() {
return <div>Hello</div>;
}`;
const result = analyzeComponent(content, 3);
expect(result.isDynamic).toBe(false);
});
});
describe("static text detection", () => {
it("should detect static text content", () => {
const content = `
function Component() {
return <div>Static text content</div>;
}`;
const result = analyzeComponent(content, 3);
expect(result.hasStaticText).toBe(true);
});
it("should detect string literal in expression container", () => {
const content = `
function Component() {
return <div>{"Static text"}</div>;
}`;
const result = analyzeComponent(content, 3);
expect(result.hasStaticText).toBe(true);
});
it("should not detect static text when element has nested JSX", () => {
const content = `
function Component() {
return <div>Text <span>nested</span></div>;
}`;
const result = analyzeComponent(content, 3);
expect(result.hasStaticText).toBe(false);
});
it("should not detect static text when empty", () => {
const content = `
function Component() {
return <div></div>;
}`;
const result = analyzeComponent(content, 3);
expect(result.hasStaticText).toBe(false);
});
it("should ignore whitespace-only text", () => {
const content = `
function Component() {
return <div> </div>;
}`;
const result = analyzeComponent(content, 3);
expect(result.hasStaticText).toBe(false);
});
it("should not detect static text with dynamic expression", () => {
const content = `
function Component() {
return <div>{dynamicText}</div>;
}`;
const result = analyzeComponent(content, 3);
expect(result.hasStaticText).toBe(false);
});
});
describe("combined analysis", () => {
it("should detect both dynamic styling and static text", () => {
const content = `
function Component() {
return <div className={isActive ? "active" : "inactive"}>Static text</div>;
}`;
const result = analyzeComponent(content, 3);
expect(result.isDynamic).toBe(true);
expect(result.hasStaticText).toBe(true);
});
it("should return false for both when element not found", () => {
const content = `
function Component() {
return <div>Hello</div>;
}`;
const result = analyzeComponent(content, 999);
expect(result.isDynamic).toBe(false);
expect(result.hasStaticText).toBe(false);
});
});
describe("nested elements", () => {
it("should analyze correct element on specified line", () => {
const content = `
function Component() {
return (
<div className="w-[100px]">
<span className={dynamicClass}>Inner</span>
</div>
);
}`;
const outerResult = analyzeComponent(content, 4);
expect(outerResult.isDynamic).toBe(false);
expect(outerResult.hasStaticText).toBe(false);
const innerResult = analyzeComponent(content, 5);
expect(innerResult.isDynamic).toBe(true);
expect(innerResult.hasStaticText).toBe(true);
});
});
describe("TypeScript support", () => {
it("should handle TypeScript syntax", () => {
const content = `
function Component(): JSX.Element {
const props: Props = { active: true };
return <div className={props.active ? "active" : "inactive"}>Hello</div>;
}`;
const result = analyzeComponent(content, 4);
expect(result.isDynamic).toBe(true);
expect(result.hasStaticText).toBe(true);
});
});
});

View File

@@ -0,0 +1,361 @@
import { parse } from "@babel/parser";
import * as recast from "recast";
import traverse from "@babel/traverse";
interface ContentChange {
classes: string[];
prefixes: string[];
textContent?: string;
}
interface ComponentAnalysis {
isDynamic: boolean;
hasStaticText: boolean;
}
/**
* Pure function that transforms JSX/TSX content by applying style and text changes
* @param content - The source code content to transform
* @param changes - Map of line numbers to their changes
* @returns The transformed source code
*/
export function transformContent(
content: string,
changes: Map<number, ContentChange>,
): string {
// Parse with babel for compatibility with JSX/TypeScript
const ast = parse(content, {
sourceType: "module",
plugins: ["jsx", "typescript"],
});
// Track which lines have been processed to avoid modifying nested elements
const processedLines = new Set<number>();
traverse(ast, {
JSXElement(path) {
const line = path.node.openingElement.loc?.start.line;
// Only process if we have changes for this line and haven't processed it yet
if (line && changes.has(line) && !processedLines.has(line)) {
processedLines.add(line);
const change = changes.get(line)!;
// Check if this element has any nested JSX elements as direct children
const hasNestedJSX = path.node.children.some(
(child: any) => child.type === "JSXElement",
);
// Skip text content modification if there are nested elements
const shouldModifyText =
"textContent" in change &&
change.textContent !== undefined &&
!hasNestedJSX;
// Update className if there are style changes
if (change.classes.length > 0) {
const attributes = path.node.openingElement.attributes;
let classNameAttr = attributes.find(
(attr: any) =>
attr.type === "JSXAttribute" && attr.name.name === "className",
) as any;
if (classNameAttr) {
// Get existing classes
let existingClasses: string[] = [];
if (
classNameAttr.value &&
classNameAttr.value.type === "StringLiteral"
) {
existingClasses = classNameAttr.value.value
.split(/\s+/)
.filter(Boolean);
}
// Filter out classes with matching prefixes
const shouldRemoveClass = (cls: string, prefixes: string[]) => {
return prefixes.some((prefix) => {
// Handle font-weight vs font-family distinction
if (prefix === "font-weight-") {
// Remove font-[numeric] classes
const match = cls.match(/^font-\[(\d+)\]$/);
return match !== null;
} else if (prefix === "font-family-") {
// Remove font-[non-numeric] classes
const match = cls.match(/^font-\[([^\]]+)\]$/);
if (match) {
// Check if it's NOT purely numeric (i.e., it's a font-family)
return !/^\d+$/.test(match[1]);
}
return false;
} else if (prefix === "text-size-") {
// Remove only text-size classes (text-xs, text-3xl, text-[44px], etc.)
// but NOT text-center, text-left, text-red-500, etc.
const sizeMatch = cls.match(
/^text-(xs|sm|base|lg|xl|2xl|3xl|4xl|5xl|6xl|7xl|8xl|9xl)$/,
);
if (sizeMatch) return true;
// Also match arbitrary text sizes like text-[44px]
if (cls.match(/^text-\[[\d.]+[a-z]+\]$/)) return true;
return false;
} else if (prefix === "my-" || prefix === "py-") {
// When applying vertical spacing (my/py), remove mt-, mb-, my-/py-, and m-/p- (all sides)
const type = prefix[0]; // 'm' or 'p'
return (
cls.startsWith(`${type}t-`) ||
cls.startsWith(`${type}b-`) ||
cls.startsWith(`${type}y-`) ||
cls.match(new RegExp(`^${type}-\\[`)) // Match m-[...] or p-[...]
);
} else if (prefix === "mx-" || prefix === "px-") {
// When applying horizontal spacing (mx/px), remove ml-, mr-, mx-/px-, and m-/p- (all sides)
const type = prefix[0]; // 'm' or 'p'
return (
cls.startsWith(`${type}l-`) ||
cls.startsWith(`${type}r-`) ||
cls.startsWith(`${type}x-`) ||
cls.match(new RegExp(`^${type}-\\[`)) // Match m-[...] or p-[...]
);
} else {
// For other prefixes, use simple startsWith
return cls.startsWith(prefix);
}
});
};
let filteredClasses = existingClasses.filter(
(cls) => !shouldRemoveClass(cls, change.prefixes),
);
// Special case: When adding mx-/px- or my-/py-, check if we need to preserve complementary spacing
// If we're removing m-[value]/p-[value], we should add the complementary directional class
// BUT only if we're not already adding both directional classes
const addedClasses: string[] = [];
// Check for each spacing type (margin and padding)
["m", "p"].forEach((type) => {
const hasDirectionalX = change.prefixes.some(
(p) => p === `${type}x-`,
);
const hasDirectionalY = change.prefixes.some(
(p) => p === `${type}y-`,
);
// Only process if we're adding at least one directional class for this type
if (!hasDirectionalX && !hasDirectionalY) {
return; // Skip this type
}
// Find if there was an all-sides class (m-[...] or p-[...])
const allSidesClass = existingClasses.find((cls) =>
cls.match(new RegExp(`^${type}-\\[([^\\]]+)\\]$`)),
);
if (allSidesClass) {
// Remove the omni-directional class from filtered classes
filteredClasses = filteredClasses.filter(
(cls) => cls !== allSidesClass,
);
// Extract the value
const valueMatch = allSidesClass.match(/\[([^\]]+)\]/);
if (valueMatch) {
const omnidirectionalValue = valueMatch[1];
// Only add complementary class if we're not adding both directions
if (hasDirectionalX && !hasDirectionalY) {
// Adding mx-[], so preserve the value as my-[]
addedClasses.push(`${type}y-[${omnidirectionalValue}]`);
} else if (hasDirectionalY && !hasDirectionalX) {
// Adding my-[], so preserve the value as mx-[]
addedClasses.push(`${type}x-[${omnidirectionalValue}]`);
}
// If both are being added, we don't need to preserve anything
}
}
});
// Combine filtered, preserved, and new classes
const updatedClasses = [
...filteredClasses,
...addedClasses,
...change.classes,
].join(" ");
// Update the className value
classNameAttr.value = {
type: "StringLiteral",
value: updatedClasses,
};
} else {
// Add className attribute
attributes.push({
type: "JSXAttribute",
name: { type: "JSXIdentifier", name: "className" },
value: {
type: "StringLiteral",
value: change.classes.join(" "),
},
});
}
}
if (shouldModifyText) {
// Check if all children are text nodes (no nested JSX elements)
const hasOnlyTextChildren = path.node.children.every((child: any) => {
// JSXElement means there's a nested component/element
if (child.type === "JSXElement") return false;
return (
child.type === "JSXText" ||
(child.type === "JSXExpressionContainer" &&
child.expression.type === "StringLiteral")
);
});
// Only replace children if there are no nested JSX elements
if (hasOnlyTextChildren) {
path.node.children = [
{
type: "JSXText",
value: change.textContent,
} as any,
];
}
}
}
},
});
// Use recast to generate code with preserved formatting
const output = recast.print(ast);
return output.code;
}
/**
* Analyzes a JSX/TSX component at a specific line to determine:
* - Whether it has dynamic styling (className/style with expressions)
* - Whether it contains static text content
*/
export function analyzeComponent(
content: string,
line: number,
): ComponentAnalysis {
const ast = parse(content, {
sourceType: "module",
plugins: ["jsx", "typescript"],
});
let foundElement: any = null;
// Simple recursive walker to find JSXElement
const walk = (node: any): void => {
if (!node) return;
if (
node.type === "JSXElement" &&
node.openingElement?.loc?.start.line === line
) {
foundElement = node;
return;
}
// Handle arrays (like body of a program or block)
if (Array.isArray(node)) {
for (const child of node) {
walk(child);
if (foundElement) return;
}
return;
}
// Handle objects
for (const key in node) {
if (
key !== "loc" &&
key !== "start" &&
key !== "end" &&
node[key] &&
typeof node[key] === "object"
) {
walk(node[key]);
if (foundElement) return;
}
}
};
walk(ast);
if (!foundElement) {
return { isDynamic: false, hasStaticText: false };
}
let dynamic = false;
let staticText = false;
// Check attributes for dynamic styling
if (foundElement.openingElement.attributes) {
foundElement.openingElement.attributes.forEach((attr: any) => {
if (attr.type === "JSXAttribute" && attr.name && attr.name.name) {
const attrName = attr.name.name;
if (attrName === "style" || attrName === "className") {
if (attr.value && attr.value.type === "JSXExpressionContainer") {
const expr = attr.value.expression;
// Check for conditional/logical/template
if (
expr.type === "ConditionalExpression" ||
expr.type === "LogicalExpression" ||
expr.type === "TemplateLiteral"
) {
dynamic = true;
}
// Check for identifiers (variables)
if (
expr.type === "Identifier" ||
expr.type === "MemberExpression"
) {
dynamic = true;
}
// Check for CallExpression (function calls)
if (expr.type === "CallExpression") {
dynamic = true;
}
// Check for ObjectExpression (inline objects like style={{...}})
if (expr.type === "ObjectExpression") {
dynamic = true;
}
}
}
}
});
}
// Check children for static text
let allChildrenAreText = true;
let hasText = false;
if (foundElement.children && foundElement.children.length > 0) {
foundElement.children.forEach((child: any) => {
if (child.type === "JSXText") {
// It's text (could be whitespace)
if (child.value.trim().length > 0) hasText = true;
} else if (
child.type === "JSXExpressionContainer" &&
child.expression.type === "StringLiteral"
) {
hasText = true;
} else {
// If it's not text (e.g. another Element), mark as not text-only
allChildrenAreText = false;
}
});
} else {
// No children
allChildrenAreText = true;
}
if (hasText && allChildrenAreText) {
staticText = true;
}
return { isDynamic: dynamic, hasStaticText: staticText };
}