import type {
ParsedComponent,
ComponentChild,
ComparisonResult,
TextChange,
ChildChange,
PropChange,
StyleChange,
StyleEntry,
PropEntry
} from '../types/index.js';
import { extractAllChildren } from '../parsers/jsx-parser.js';
function compareProps(
oldProps: PropEntry[],
newProps: PropEntry[]
): PropChange[] {
const changes: PropChange[] = [];
const oldPropsMap = new Map(oldProps.map(p => [p.name, p]));
const newPropsMap = new Map(newProps.map(p => [p.name, p]));
// Check for removed and modified props
for (const [name, oldProp] of oldPropsMap) {
const newProp = newPropsMap.get(name);
if (!newProp) {
changes.push({
name,
oldValue: oldProp.value,
newValue: null,
type: 'removed'
});
} else if (oldProp.value !== newProp.value) {
changes.push({
name,
oldValue: oldProp.value,
newValue: newProp.value,
type: 'modified'
});
}
}
// Check for added props
for (const [name, newProp] of newPropsMap) {
if (!oldPropsMap.has(name)) {
changes.push({
name,
oldValue: null,
newValue: newProp.value,
type: 'added'
});
}
}
return changes;
}
function compareStyles(
oldStyles: StyleEntry[],
newStyles: StyleEntry[]
): StyleChange[] {
const changes: StyleChange[] = [];
// Create maps keyed by property+source for comparison
const oldStylesMap = new Map(
oldStyles.map(s => [`${s.property}|${s.source}|${s.raw}`, s])
);
const newStylesMap = new Map(
newStyles.map(s => [`${s.property}|${s.source}|${s.raw}`, s])
);
// Check for removed and modified styles
for (const [key, oldStyle] of oldStylesMap) {
const newStyle = newStylesMap.get(key);
if (!newStyle) {
// Check if there's a style with same property but different value
const samePropertyStyle = Array.from(newStylesMap.values()).find(
s => s.property === oldStyle.property && s.source === oldStyle.source
);
if (samePropertyStyle) {
changes.push({
property: oldStyle.property,
oldValue: oldStyle.raw || oldStyle.value,
newValue: samePropertyStyle.raw || samePropertyStyle.value,
type: 'modified'
});
} else {
changes.push({
property: oldStyle.property,
oldValue: oldStyle.raw || oldStyle.value,
newValue: null,
type: 'removed'
});
}
}
}
// Check for added styles
for (const [key, newStyle] of newStylesMap) {
if (!oldStylesMap.has(key)) {
// Only add if not already handled as modified
const wasModified = changes.some(
c => c.property === newStyle.property && c.type === 'modified'
);
if (!wasModified) {
const hadOldStyle = Array.from(oldStylesMap.values()).some(
s => s.property === newStyle.property && s.source === newStyle.source
);
if (!hadOldStyle) {
changes.push({
property: newStyle.property,
oldValue: null,
newValue: newStyle.raw || newStyle.value,
type: 'added'
});
}
}
}
}
return changes;
}
function findChildBySignature(
children: ComponentChild[],
signature: string
): ComponentChild | undefined {
return children.find(c => getChildSignature(c) === signature);
}
function getChildSignature(child: ComponentChild): string {
// Create a signature based on name and key props
const keyProp = child.props.find(p => p.name === 'key');
const idProp = child.props.find(p => p.name === 'id');
const testIdProp = child.props.find(p => p.name === 'data-testid');
if (keyProp) return `${child.name}[key=${keyProp.value}]`;
if (idProp) return `${child.name}[id=${idProp.value}]`;
if (testIdProp) return `${child.name}[data-testid=${testIdProp.value}]`;
return `${child.name}@${child.location.start.line}`;
}
function compareChildren(
oldChildren: ComponentChild[],
newChildren: ComponentChild[]
): ChildChange[] {
const changes: ChildChange[] = [];
const processedNew = new Set<string>();
// Check each old child
for (const oldChild of oldChildren) {
const oldSig = getChildSignature(oldChild);
const newChild = findChildBySignature(newChildren, oldSig);
if (!newChild) {
// Child was removed
changes.push({
componentName: oldChild.name,
location: `line ${oldChild.location.start.line}`,
type: 'removed'
});
} else {
processedNew.add(getChildSignature(newChild));
// Check if props changed
const propChanges = compareProps(oldChild.props, newChild.props);
if (propChanges.length > 0) {
changes.push({
componentName: oldChild.name,
location: `line ${oldChild.location.start.line}`,
type: 'modified',
props: propChanges
});
}
}
}
// Check for new children
for (const newChild of newChildren) {
const sig = getChildSignature(newChild);
if (!processedNew.has(sig)) {
const oldChild = findChildBySignature(oldChildren, sig);
if (!oldChild) {
changes.push({
componentName: newChild.name,
location: `line ${newChild.location.start.line}`,
type: 'added'
});
}
}
}
return changes;
}
function compareTextContent(
oldChildren: ComponentChild[],
newChildren: ComponentChild[]
): TextChange[] {
const changes: TextChange[] = [];
const processedNew = new Set<string>();
for (const oldChild of oldChildren) {
const oldSig = getChildSignature(oldChild);
const newChild = findChildBySignature(newChildren, oldSig);
if (newChild) {
processedNew.add(getChildSignature(newChild));
if (oldChild.textContent !== newChild.textContent) {
if (!oldChild.textContent && newChild.textContent) {
changes.push({
location: `${oldChild.name} at line ${oldChild.location.start.line}`,
oldText: null,
newText: newChild.textContent,
type: 'added'
});
} else if (oldChild.textContent && !newChild.textContent) {
changes.push({
location: `${oldChild.name} at line ${oldChild.location.start.line}`,
oldText: oldChild.textContent,
newText: null,
type: 'removed'
});
} else {
changes.push({
location: `${oldChild.name} at line ${oldChild.location.start.line}`,
oldText: oldChild.textContent!,
newText: newChild.textContent!,
type: 'modified'
});
}
}
}
}
return changes;
}
function generateSummary(result: Omit<ComparisonResult, 'summary'>): string {
const parts: string[] = [];
const textCount = result.changes.text.length;
const childCount = result.changes.children.length;
const propCount = result.changes.props.length;
const colorCount = result.changes.styles.colors.length;
const spacingCount = result.changes.styles.spacing.length;
const typographyCount = result.changes.styles.typography.length;
if (textCount > 0) {
parts.push(`${textCount} text change${textCount > 1 ? 's' : ''}`);
}
if (childCount > 0) {
const added = result.changes.children.filter(c => c.type === 'added').length;
const removed = result.changes.children.filter(c => c.type === 'removed').length;
const modified = result.changes.children.filter(c => c.type === 'modified').length;
const childParts: string[] = [];
if (added > 0) childParts.push(`${added} added`);
if (removed > 0) childParts.push(`${removed} removed`);
if (modified > 0) childParts.push(`${modified} modified`);
parts.push(`${childCount} child component change${childCount > 1 ? 's' : ''} (${childParts.join(', ')})`);
}
if (propCount > 0) {
parts.push(`${propCount} prop change${propCount > 1 ? 's' : ''}`);
}
if (colorCount > 0) {
parts.push(`${colorCount} color change${colorCount > 1 ? 's' : ''}`);
}
if (spacingCount > 0) {
parts.push(`${spacingCount} spacing change${spacingCount > 1 ? 's' : ''}`);
}
if (typographyCount > 0) {
parts.push(`${typographyCount} typography change${typographyCount > 1 ? 's' : ''}`);
}
if (parts.length === 0) {
return 'No significant changes detected';
}
return parts.join(', ');
}
export function compareComponents(
oldComponent: ParsedComponent,
newComponent: ParsedComponent
): ComparisonResult {
const oldChildren = extractAllChildren(oldComponent);
const newChildren = extractAllChildren(newComponent);
// Compare root props
const rootPropChanges = compareProps(oldComponent.props, newComponent.props);
// Compare children (added/removed/modified components)
const childChanges = compareChildren(oldChildren, newChildren);
// Compare text content
const textChanges = compareTextContent(oldChildren, newChildren);
// Compare styles by category
const colorChanges = compareStyles(
oldComponent.styles.colors,
newComponent.styles.colors
);
const spacingChanges = compareStyles(
oldComponent.styles.spacing,
newComponent.styles.spacing
);
const typographyChanges = compareStyles(
oldComponent.styles.typography,
newComponent.styles.typography
);
const result = {
changes: {
text: textChanges,
children: childChanges,
props: rootPropChanges,
styles: {
colors: colorChanges,
spacing: spacingChanges,
typography: typographyChanges
}
}
};
return {
summary: generateSummary(result),
...result
};
}