fixdog 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Element detector - handles Alt+click detection and source resolution using Bippy
3
+ */
4
+
5
+ import {
6
+ getSourceFromElement,
7
+ getComponentNameFromFiber,
8
+ getFiberFromHostInstance,
9
+ } from "./instrument";
10
+ import { isSourceFile } from "./source-resolver";
11
+ import type { ElementDetectorOptions, SourceLocation } from "./types";
12
+
13
+ let detectorCleanup: (() => void) | null = null;
14
+ let isSetup = false;
15
+
16
+ /**
17
+ * Setup element detection with Alt+click (or other modifier)
18
+ */
19
+ export function setupElementDetector(
20
+ options: ElementDetectorOptions
21
+ ): () => void {
22
+ const { onElementSelected, modifier = "alt" } = options;
23
+
24
+ // Cleanup any existing detector
25
+ if (detectorCleanup) {
26
+ detectorCleanup();
27
+ }
28
+
29
+ isSetup = true;
30
+
31
+ const handleClick = async (event: MouseEvent) => {
32
+ // Check if modifier key is pressed
33
+ const modifierPressed =
34
+ (modifier === "alt" && event.altKey) ||
35
+ (modifier === "ctrl" && event.ctrlKey) ||
36
+ (modifier === "meta" && event.metaKey) ||
37
+ (modifier === "shift" && event.shiftKey);
38
+
39
+ if (!modifierPressed) return;
40
+
41
+ event.preventDefault();
42
+ event.stopPropagation();
43
+
44
+ const target = event.target as Element;
45
+
46
+ try {
47
+ // Get source location using Bippy
48
+ const source = await getSourceFromElement(target);
49
+
50
+ if (source && source.fileName && isSourceFile(source.fileName)) {
51
+ // Get component name
52
+ const fiber = getFiberFromHostInstance(target);
53
+ const componentName = fiber
54
+ ? getComponentNameFromFiber(fiber)
55
+ : "Unknown";
56
+
57
+ const enrichedSource: SourceLocation = {
58
+ ...source,
59
+ functionName: source.functionName || componentName,
60
+ };
61
+
62
+ onElementSelected(enrichedSource, target);
63
+ } else {
64
+ console.info(
65
+ "[UiDog Next] Could not find source for element. Falling back to DOM snapshot (likely server component or library code)."
66
+ );
67
+ onElementSelected(null, target);
68
+ }
69
+ } catch (error) {
70
+ console.warn("[UiDog Next] Error detecting element source:", error);
71
+ }
72
+ };
73
+
74
+ // Handle mouse movement for hover highlighting (optional enhancement)
75
+ const handleMouseMove = (event: MouseEvent) => {
76
+ // Check if modifier key is pressed
77
+ const modifierPressed =
78
+ (modifier === "alt" && event.altKey) ||
79
+ (modifier === "ctrl" && event.ctrlKey) ||
80
+ (modifier === "meta" && event.metaKey) ||
81
+ (modifier === "shift" && event.shiftKey);
82
+
83
+ if (!modifierPressed) {
84
+ removeHighlight();
85
+ return;
86
+ }
87
+
88
+ const target = event.target as Element;
89
+ highlightElement(target);
90
+ };
91
+
92
+ // Handle key up to remove highlight when modifier is released
93
+ const handleKeyUp = (event: KeyboardEvent) => {
94
+ const relevantKey =
95
+ (modifier === "alt" && event.key === "Alt") ||
96
+ (modifier === "ctrl" && event.key === "Control") ||
97
+ (modifier === "meta" && event.key === "Meta") ||
98
+ (modifier === "shift" && event.key === "Shift");
99
+
100
+ if (relevantKey) {
101
+ removeHighlight();
102
+ }
103
+ };
104
+
105
+ // Use capture phase to intercept before other handlers
106
+ document.addEventListener("click", handleClick, true);
107
+ document.addEventListener("mousemove", handleMouseMove, true);
108
+ document.addEventListener("keyup", handleKeyUp, true);
109
+
110
+ detectorCleanup = () => {
111
+ document.removeEventListener("click", handleClick, true);
112
+ document.removeEventListener("mousemove", handleMouseMove, true);
113
+ document.removeEventListener("keyup", handleKeyUp, true);
114
+ removeHighlight();
115
+ isSetup = false;
116
+ };
117
+
118
+ return detectorCleanup;
119
+ }
120
+
121
+ // Highlight state
122
+ let currentHighlight: HTMLElement | null = null;
123
+ let highlightOverlay: HTMLElement | null = null;
124
+
125
+ /**
126
+ * Highlight an element on hover when modifier is pressed
127
+ */
128
+ function highlightElement(element: Element): void {
129
+ // Don't highlight our own overlay
130
+ if (element === highlightOverlay || highlightOverlay?.contains(element)) {
131
+ return;
132
+ }
133
+
134
+ // Create overlay if it doesn't exist
135
+ if (!highlightOverlay) {
136
+ highlightOverlay = document.createElement("div");
137
+ highlightOverlay.id = "uidog-highlight-overlay";
138
+ highlightOverlay.style.cssText = `
139
+ position: fixed;
140
+ pointer-events: none;
141
+ background: rgba(59, 130, 246, 0.2);
142
+ border: 2px solid rgba(59, 130, 246, 0.8);
143
+ border-radius: 4px;
144
+ z-index: 999998;
145
+ transition: all 0.1s ease-out;
146
+ `;
147
+ document.body.appendChild(highlightOverlay);
148
+ }
149
+
150
+ // Update position
151
+ const rect = element.getBoundingClientRect();
152
+ highlightOverlay.style.top = `${rect.top}px`;
153
+ highlightOverlay.style.left = `${rect.left}px`;
154
+ highlightOverlay.style.width = `${rect.width}px`;
155
+ highlightOverlay.style.height = `${rect.height}px`;
156
+ highlightOverlay.style.display = "block";
157
+
158
+ currentHighlight = element as HTMLElement;
159
+ }
160
+
161
+ /**
162
+ * Remove highlight overlay
163
+ */
164
+ function removeHighlight(): void {
165
+ if (highlightOverlay) {
166
+ highlightOverlay.style.display = "none";
167
+ }
168
+ currentHighlight = null;
169
+ }
170
+
171
+ /**
172
+ * Check if element detector is set up
173
+ */
174
+ export function isElementDetectorSetup(): boolean {
175
+ return isSetup;
176
+ }
177
+
178
+ /**
179
+ * Cleanup element detector
180
+ */
181
+ export function cleanupElementDetector(): void {
182
+ if (detectorCleanup) {
183
+ detectorCleanup();
184
+ detectorCleanup = null;
185
+ }
186
+ }
package/src/index.ts ADDED
@@ -0,0 +1,228 @@
1
+ /**
2
+ * UiDog SDK for Next.js - Main entry point
3
+ *
4
+ * Uses Bippy to hook into React's internals for element source detection,
5
+ * providing a seamless developer experience for inspecting component locations.
6
+ */
7
+
8
+ import { setupBippyInstrumentation } from "./instrument";
9
+ import {
10
+ setupElementDetector,
11
+ cleanupElementDetector,
12
+ } from "./element-detector";
13
+ import {
14
+ initializeSidebar,
15
+ openSidebar,
16
+ closeSidebar,
17
+ cleanupSidebar,
18
+ } from "./sidebar-initializer";
19
+ import {
20
+ buildEditorUrl,
21
+ normalizeFileName,
22
+ getShortFileName,
23
+ } from "./source-resolver";
24
+ import type {
25
+ UiDogNextOptions,
26
+ SourceLocation,
27
+ ElementInfo,
28
+ EditorType,
29
+ } from "./types";
30
+
31
+ let isInitialized = false;
32
+
33
+ /**
34
+ * Initialize UiDog for Next.js
35
+ *
36
+ * This function sets up Bippy instrumentation, element detection, and the sidebar UI.
37
+ * It should be called early in your application's lifecycle, ideally via
38
+ * instrumentation-client.ts (Next.js 15.3+) or at the top of _app.tsx.
39
+ */
40
+ export function initializeUiDogNext(options: UiDogNextOptions = {}): void {
41
+ if (typeof window === "undefined") return;
42
+ if (isInitialized) {
43
+ console.warn("[UiDog Next] Already initialized");
44
+ return;
45
+ }
46
+
47
+ // Check if in development mode
48
+ if (process.env.NODE_ENV === "production") {
49
+ console.warn(
50
+ "[UiDog Next] Running in production mode. Element source detection may not work as _debugSource is stripped in production builds."
51
+ );
52
+ }
53
+
54
+ const {
55
+ editor = "cursor",
56
+ projectPath = "",
57
+ modifier = "alt",
58
+ enableSidebar = true,
59
+ apiEndpoint = "https://api.ui.dog",
60
+ } = options;
61
+
62
+ isInitialized = true;
63
+ window.__UIDOG_NEXT_INITIALIZED__ = true;
64
+
65
+ // Setup Bippy instrumentation (hooks into React DevTools API)
66
+ setupBippyInstrumentation();
67
+
68
+ // Initialize sidebar if enabled
69
+ if (enableSidebar) {
70
+ initializeSidebar({ apiEndpoint });
71
+ }
72
+
73
+ // Setup element detection (Alt+click to select elements)
74
+ setupElementDetector({
75
+ modifier,
76
+ onElementSelected: (source: SourceLocation | null, element: Element) => {
77
+ const rect = element.getBoundingClientRect
78
+ ? element.getBoundingClientRect()
79
+ : null;
80
+
81
+ const buildDomSnapshot = () => {
82
+ const outerHTML = element.outerHTML || "";
83
+ const text = (element.textContent || "").trim();
84
+ const attributes: Record<string, string> = {};
85
+
86
+ Array.from(element.attributes || []).forEach((attr) => {
87
+ attributes[attr.name] = attr.value;
88
+ });
89
+
90
+ return {
91
+ outerHTML:
92
+ outerHTML.length > 4000
93
+ ? `${outerHTML.slice(0, 4000)}…`
94
+ : outerHTML,
95
+ text: text.length > 1000 ? `${text.slice(0, 1000)}…` : text,
96
+ attributes,
97
+ };
98
+ };
99
+
100
+ if (source) {
101
+ const editorUrl = buildEditorUrl(source, editor, projectPath);
102
+
103
+ console.info(
104
+ "[UiDog Next] Source detected:",
105
+ normalizeFileName(source.fileName),
106
+ "line",
107
+ source.lineNumber,
108
+ "column",
109
+ source.columnNumber
110
+ );
111
+
112
+ if (enableSidebar) {
113
+ const elementInfo: ElementInfo = {
114
+ kind: "source",
115
+ componentName: source.functionName || "Unknown",
116
+ filePath: normalizeFileName(source.fileName),
117
+ line: source.lineNumber,
118
+ column: source.columnNumber,
119
+ box: rect
120
+ ? {
121
+ x: rect.x,
122
+ y: rect.y,
123
+ width: rect.width,
124
+ height: rect.height,
125
+ }
126
+ : undefined,
127
+ };
128
+
129
+ openSidebar(elementInfo, editorUrl);
130
+ } else {
131
+ console.log("[UiDog Next] Element selected:");
132
+ console.log(" Component:", source.functionName || "Unknown");
133
+ console.log(" File:", source.fileName);
134
+ console.log(" Line:", source.lineNumber);
135
+ console.log(" Column:", source.columnNumber);
136
+ console.log(" Editor URL:", editorUrl);
137
+ }
138
+ } else {
139
+ const domSnapshot = buildDomSnapshot();
140
+
141
+ console.info("[UiDog Next] DOM snapshot (no source):", {
142
+ outerHTML: domSnapshot.outerHTML,
143
+ text: domSnapshot.text,
144
+ attributes: domSnapshot.attributes,
145
+ box: rect
146
+ ? {
147
+ x: rect.x,
148
+ y: rect.y,
149
+ width: rect.width,
150
+ height: rect.height,
151
+ }
152
+ : undefined,
153
+ });
154
+
155
+ const elementInfo: ElementInfo = {
156
+ kind: "dom",
157
+ componentName: "Server-rendered element",
158
+ domSnapshot,
159
+ box: rect
160
+ ? {
161
+ x: rect.x,
162
+ y: rect.y,
163
+ width: rect.width,
164
+ height: rect.height,
165
+ }
166
+ : undefined,
167
+ };
168
+
169
+ if (enableSidebar) {
170
+ openSidebar(elementInfo, "");
171
+ } else {
172
+ console.log("[UiDog Next] Element selected (DOM snapshot):");
173
+ console.log(" outerHTML:", domSnapshot.outerHTML);
174
+ console.log(" text:", domSnapshot.text);
175
+ }
176
+ }
177
+ },
178
+ });
179
+
180
+ console.log(
181
+ `[UiDog Next] Initialized (${modifier}+click to select elements)`
182
+ );
183
+ }
184
+
185
+ /**
186
+ * Cleanup UiDog - removes all event listeners and UI
187
+ */
188
+ export function cleanupUiDogNext(): void {
189
+ cleanupElementDetector();
190
+ cleanupSidebar();
191
+ isInitialized = false;
192
+
193
+ if (typeof window !== "undefined") {
194
+ window.__UIDOG_NEXT_INITIALIZED__ = false;
195
+ }
196
+ }
197
+
198
+ /**
199
+ * Check if UiDog is initialized
200
+ */
201
+ export function isUiDogNextInitialized(): boolean {
202
+ return isInitialized;
203
+ }
204
+
205
+ // Re-export types and utilities
206
+ export type {
207
+ UiDogNextOptions,
208
+ SourceLocation,
209
+ ElementInfo,
210
+ EditorType,
211
+ SidebarConfig,
212
+ EditRequest,
213
+ EditResponse,
214
+ } from "./types";
215
+
216
+ export {
217
+ buildEditorUrl,
218
+ normalizeFileName,
219
+ getShortFileName,
220
+ } from "./source-resolver";
221
+
222
+ export { openSidebar, closeSidebar } from "./sidebar-initializer";
223
+
224
+ export {
225
+ setupBippyInstrumentation,
226
+ getSourceFromElement,
227
+ getComponentNameFromFiber,
228
+ } from "./instrument";
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Bippy instrumentation setup for React fiber inspection
3
+ *
4
+ * Bippy hooks into React's DevTools API (window.__REACT_DEVTOOLS_GLOBAL_HOOK__)
5
+ * to intercept fiber tree data and extract source location information.
6
+ */
7
+
8
+ import {
9
+ instrument,
10
+ secure,
11
+ isCompositeFiber,
12
+ isHostFiber,
13
+ traverseFiber,
14
+ getDisplayName,
15
+ getFiberFromHostInstance,
16
+ } from "bippy";
17
+ import { getSource } from "bippy/source";
18
+ import type { SourceLocation } from "./types";
19
+
20
+ let isInstrumented = false;
21
+
22
+ /**
23
+ * Setup Bippy instrumentation to hook into React's internals
24
+ * This must be called BEFORE React loads (via instrumentation-client.ts)
25
+ */
26
+ export function setupBippyInstrumentation(): void {
27
+ if (isInstrumented) return;
28
+ if (typeof window === "undefined") return;
29
+
30
+ isInstrumented = true;
31
+
32
+ instrument(
33
+ secure({
34
+ onCommitFiberRoot: (_rendererID, _fiberRoot) => {
35
+ // Called when React commits a render
36
+ // We can use this for additional tracking if needed
37
+ },
38
+ })
39
+ );
40
+ }
41
+
42
+ /**
43
+ * Get source location from a DOM element by traversing its React fiber
44
+ */
45
+ export async function getSourceFromElement(
46
+ element: Element
47
+ ): Promise<SourceLocation | null> {
48
+ try {
49
+ // Get the fiber associated with this DOM element
50
+ const fiber = getFiberFromHostInstance(element);
51
+
52
+ if (!fiber) {
53
+ return null;
54
+ }
55
+
56
+ // Try to get source from the fiber
57
+ const source = await getSource(fiber);
58
+
59
+ if (source && source.fileName) {
60
+ // Filter out node_modules and internal files
61
+ if (
62
+ source.fileName.includes("node_modules") ||
63
+ source.fileName.includes("react-dom") ||
64
+ source.fileName.includes("react/")
65
+ ) {
66
+ // Try parent fibers to find user code
67
+ return await findUserSourceFromFiber(fiber);
68
+ }
69
+
70
+ return {
71
+ fileName: source.fileName,
72
+ lineNumber: source.lineNumber ?? 1,
73
+ columnNumber: source.columnNumber ?? 1,
74
+ functionName: source.functionName ?? undefined,
75
+ };
76
+ }
77
+
78
+ // Fallback: traverse up the fiber tree to find source
79
+ return await findUserSourceFromFiber(fiber);
80
+ } catch (error) {
81
+ console.warn("[UiDog Next] Error getting source from element:", error);
82
+ return null;
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Traverse up the fiber tree to find the first user-code source
88
+ */
89
+ async function findUserSourceFromFiber(
90
+ startFiber: any
91
+ ): Promise<SourceLocation | null> {
92
+ let result: SourceLocation | null = null;
93
+
94
+ // Traverse upward through the fiber tree
95
+ traverseFiber(
96
+ startFiber,
97
+ async (fiber) => {
98
+ // Only check composite fibers (components, not host elements)
99
+ if (isCompositeFiber(fiber)) {
100
+ try {
101
+ const source = await getSource(fiber);
102
+
103
+ if (source && source.fileName) {
104
+ // Skip internal/library code
105
+ if (
106
+ !source.fileName.includes("node_modules") &&
107
+ !source.fileName.includes("react-dom") &&
108
+ !source.fileName.includes("react/")
109
+ ) {
110
+ result = {
111
+ fileName: source.fileName,
112
+ lineNumber: source.lineNumber ?? 1,
113
+ columnNumber: source.columnNumber ?? 1,
114
+ functionName: source.functionName ?? getDisplayName(fiber) ?? undefined,
115
+ };
116
+ return true; // Stop traversal
117
+ }
118
+ }
119
+ } catch {
120
+ // Continue to next fiber
121
+ }
122
+ }
123
+ return false; // Continue traversal
124
+ },
125
+ true // Traverse upward (toward root)
126
+ );
127
+
128
+ return result;
129
+ }
130
+
131
+ /**
132
+ * Get component name from a fiber
133
+ */
134
+ export function getComponentNameFromFiber(fiber: any): string {
135
+ if (!fiber) return "Unknown";
136
+
137
+ // Try to get display name directly
138
+ const displayName = getDisplayName(fiber);
139
+ if (displayName && displayName !== "Unknown") {
140
+ return displayName;
141
+ }
142
+
143
+ // Traverse up to find the nearest named component
144
+ let componentName = "Unknown";
145
+
146
+ traverseFiber(
147
+ fiber,
148
+ (f) => {
149
+ if (isCompositeFiber(f)) {
150
+ const name = getDisplayName(f);
151
+ if (name && name !== "Unknown") {
152
+ componentName = name;
153
+ return true; // Stop traversal
154
+ }
155
+ }
156
+ return false;
157
+ },
158
+ true // Traverse upward
159
+ );
160
+
161
+ return componentName;
162
+ }
163
+
164
+ // Re-export useful bippy utilities
165
+ export {
166
+ isCompositeFiber,
167
+ isHostFiber,
168
+ traverseFiber,
169
+ getDisplayName,
170
+ getFiberFromHostInstance,
171
+ };