@treelocator/runtime 0.1.8 → 0.2.0

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.
Files changed (40) hide show
  1. package/.turbo/turbo-build.log +8 -6
  2. package/.turbo/turbo-dev.log +32 -0
  3. package/.turbo/turbo-test.log +54 -10
  4. package/dist/adapters/createTreeNode.js +32 -4
  5. package/dist/adapters/nextjs/parseNextjsDataAttributes.d.ts +31 -0
  6. package/dist/adapters/nextjs/parseNextjsDataAttributes.js +106 -0
  7. package/dist/adapters/phoenix/__tests__/parsePhoenixComments.test.d.ts +4 -0
  8. package/dist/adapters/phoenix/__tests__/parsePhoenixComments.test.js +218 -0
  9. package/dist/adapters/phoenix/detectPhoenix.d.ts +11 -0
  10. package/dist/adapters/phoenix/detectPhoenix.js +38 -0
  11. package/dist/adapters/phoenix/index.d.ts +10 -0
  12. package/dist/adapters/phoenix/index.js +9 -0
  13. package/dist/adapters/phoenix/parsePhoenixComments.d.ts +35 -0
  14. package/dist/adapters/phoenix/parsePhoenixComments.js +131 -0
  15. package/dist/adapters/phoenix/types.d.ts +16 -0
  16. package/dist/adapters/phoenix/types.js +1 -0
  17. package/dist/adapters/react/getFiberLabel.js +2 -1
  18. package/dist/components/MaybeOutline.js +65 -3
  19. package/dist/functions/formatAncestryChain.d.ts +3 -0
  20. package/dist/functions/formatAncestryChain.js +104 -15
  21. package/dist/functions/formatAncestryChain.test.js +26 -20
  22. package/dist/functions/normalizeFilePath.d.ts +14 -0
  23. package/dist/functions/normalizeFilePath.js +40 -0
  24. package/dist/output.css +87 -15
  25. package/dist/types/ServerComponentInfo.d.ts +14 -0
  26. package/dist/types/ServerComponentInfo.js +1 -0
  27. package/package.json +4 -3
  28. package/src/adapters/createTreeNode.ts +35 -3
  29. package/src/adapters/nextjs/parseNextjsDataAttributes.ts +112 -0
  30. package/src/adapters/phoenix/__tests__/parsePhoenixComments.test.ts +264 -0
  31. package/src/adapters/phoenix/detectPhoenix.ts +44 -0
  32. package/src/adapters/phoenix/index.ts +11 -0
  33. package/src/adapters/phoenix/parsePhoenixComments.ts +140 -0
  34. package/src/adapters/phoenix/types.ts +16 -0
  35. package/src/adapters/react/getFiberLabel.ts +2 -1
  36. package/src/components/MaybeOutline.tsx +63 -4
  37. package/src/functions/formatAncestryChain.test.ts +26 -20
  38. package/src/functions/formatAncestryChain.ts +121 -15
  39. package/src/functions/normalizeFilePath.ts +41 -0
  40. package/src/types/ServerComponentInfo.ts +14 -0
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Regex patterns for Phoenix LiveView debug annotations.
3
+ *
4
+ * These comments are added by Phoenix LiveView when configured with:
5
+ * config :phoenix_live_view, debug_heex_annotations: true
6
+ *
7
+ * Pattern 1 (Caller): <!-- @caller lib/app_web/home_live.ex:20 -->
8
+ * Pattern 2 (Component): <!-- <AppWeb.CoreComponents.header> lib/app_web/core_components.ex:123 -->
9
+ * Pattern 3 (Closing): <!-- </AppWeb.CoreComponents.header> -->
10
+ */
11
+ const PHOENIX_CALLER_PATTERN = /^@caller\s+(.+):(\d+)$/;
12
+ const PHOENIX_COMPONENT_PATTERN = /^<([^>]+)>\s+(.+):(\d+)$/;
13
+ const PHOENIX_CLOSING_PATTERN = /^<\/([^>]+)>$/;
14
+
15
+ /**
16
+ * Parse a single HTML comment node for Phoenix debug annotations.
17
+ * Returns null if the comment doesn't match Phoenix patterns.
18
+ */
19
+ function parseCommentNode(commentNode) {
20
+ const text = commentNode.textContent?.trim();
21
+ if (!text) return null;
22
+
23
+ // Check for @caller pattern: <!-- @caller lib/app_web/home_live.ex:20 -->
24
+ const callerMatch = text.match(PHOENIX_CALLER_PATTERN);
25
+ if (callerMatch && callerMatch[1] && callerMatch[2]) {
26
+ return {
27
+ commentNode,
28
+ name: "@caller",
29
+ filePath: callerMatch[1],
30
+ line: parseInt(callerMatch[2], 10),
31
+ type: "caller"
32
+ };
33
+ }
34
+
35
+ // Check for component opening pattern: <!-- <AppWeb.CoreComponents.header> lib/app_web/core_components.ex:123 -->
36
+ const componentMatch = text.match(PHOENIX_COMPONENT_PATTERN);
37
+ if (componentMatch && componentMatch[1] && componentMatch[2] && componentMatch[3]) {
38
+ return {
39
+ commentNode,
40
+ name: componentMatch[1],
41
+ filePath: componentMatch[2],
42
+ line: parseInt(componentMatch[3], 10),
43
+ type: "component"
44
+ };
45
+ }
46
+
47
+ // Closing tags are ignored (we only care about opening tags)
48
+ // Example: <!-- </AppWeb.CoreComponents.header> -->
49
+ if (text.match(PHOENIX_CLOSING_PATTERN)) {
50
+ return null;
51
+ }
52
+
53
+ // Not a Phoenix comment
54
+ return null;
55
+ }
56
+
57
+ /**
58
+ * Find all Phoenix comment annotations immediately preceding an element.
59
+ * Walks backward through previous siblings until hitting a non-comment node.
60
+ *
61
+ * Returns array ordered from outermost to innermost (matching Phoenix nesting order).
62
+ * Example: [@caller, CoreComponents.button] where @caller is outermost.
63
+ *
64
+ * Example DOM structure:
65
+ * ```html
66
+ * <!-- @caller lib/app_web/home_live.ex:48 -->
67
+ * <!-- <AppWeb.CoreComponents.button> lib/app_web/core_components.ex:456 -->
68
+ * <button>Click Me</button>
69
+ * ```
70
+ *
71
+ * Would return: [
72
+ * { name: "@caller", filePath: "lib/app_web/home_live.ex", line: 48, type: "caller" },
73
+ * { name: "AppWeb.CoreComponents.button", filePath: "lib/app_web/core_components.ex", line: 456, type: "component" }
74
+ * ]
75
+ */
76
+ export function findPrecedingPhoenixComments(element) {
77
+ const matches = [];
78
+ let node = element.previousSibling;
79
+
80
+ // Walk backward through siblings, collecting comment nodes
81
+ while (node) {
82
+ if (node.nodeType === Node.COMMENT_NODE) {
83
+ const match = parseCommentNode(node);
84
+ if (match) {
85
+ matches.push(match);
86
+ }
87
+ // Continue even if this comment didn't match - keep looking for more Phoenix comments
88
+ } else if (node.nodeType === Node.TEXT_NODE) {
89
+ // Skip whitespace text nodes
90
+ const text = node.textContent?.trim();
91
+ if (text && text.length > 0) {
92
+ // Hit non-whitespace text, stop searching
93
+ break;
94
+ }
95
+ } else {
96
+ // Hit another element, stop searching
97
+ break;
98
+ }
99
+ node = node.previousSibling;
100
+ }
101
+
102
+ // Reverse so outermost comes first (matches Phoenix nesting order)
103
+ // This makes the array order match the visual hierarchy: [@caller, Component]
104
+ return matches.reverse();
105
+ }
106
+
107
+ /**
108
+ * Convert PhoenixCommentMatch[] to ServerComponentInfo[].
109
+ * Filters and transforms matches into the format expected by AncestryItem.
110
+ */
111
+ export function phoenixMatchesToServerComponents(matches) {
112
+ return matches.map(match => ({
113
+ name: match.name,
114
+ filePath: match.filePath,
115
+ line: match.line,
116
+ type: match.type
117
+ }));
118
+ }
119
+
120
+ /**
121
+ * Main entry point: extract server component info from element.
122
+ * Returns null if no Phoenix annotations found.
123
+ *
124
+ * This function is called during ancestry collection to enrich each AncestryItem
125
+ * with server-side component information.
126
+ */
127
+ export function parsePhoenixServerComponents(element) {
128
+ const matches = findPrecedingPhoenixComments(element);
129
+ if (matches.length === 0) return null;
130
+ return phoenixMatchesToServerComponents(matches);
131
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Represents a parsed Phoenix LiveView debug annotation HTML comment.
3
+ * Phoenix LiveView adds these comments when debug_heex_annotations: true is configured.
4
+ */
5
+ export interface PhoenixCommentMatch {
6
+ /** The actual HTML Comment node from the DOM */
7
+ commentNode: Comment;
8
+ /** Component name (e.g., "AppWeb.CoreComponents.button") or "@caller" */
9
+ name: string;
10
+ /** File path (e.g., "lib/app_web/core_components.ex") */
11
+ filePath: string;
12
+ /** Line number */
13
+ line: number;
14
+ /** Type of annotation */
15
+ type: "component" | "caller";
16
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -1,10 +1,11 @@
1
1
  import { getUsableName } from "../../functions/getUsableName";
2
+ import { normalizeFilePath } from "../../functions/normalizeFilePath";
2
3
  export function getFiberLabel(fiber, source) {
3
4
  const name = getUsableName(fiber);
4
5
  const label = {
5
6
  label: name,
6
7
  link: source ? {
7
- filePath: source.fileName,
8
+ filePath: normalizeFilePath(source.fileName),
8
9
  projectPath: "",
9
10
  line: source.lineNumber,
10
11
  column: source.columnNumber || 0
@@ -1,14 +1,35 @@
1
1
  import { template as _$template } from "solid-js/web";
2
2
  import { effect as _$effect } from "solid-js/web";
3
+ import { insert as _$insert } from "solid-js/web";
3
4
  import { setStyleProperty as _$setStyleProperty } from "solid-js/web";
4
5
  import { createComponent as _$createComponent } from "solid-js/web";
5
6
  import { memo as _$memo } from "solid-js/web";
6
- var _tmpl$ = /*#__PURE__*/_$template(`<div><div class="fixed rounded border border-solid border-amber-500"style=z-index:2></div><div class="fixed text-xs font-medium rounded-md"style="z-index:3;box-shadow:0 4px 16px rgba(0, 0, 0, 0.2);font-family:ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Monaco, Consolas, monospace;letter-spacing:0.01em">No source found`);
7
+ var _tmpl$ = /*#__PURE__*/_$template(`<div><div class="fixed rounded border border-solid border-amber-500"style=z-index:2></div><div class="fixed text-xs font-medium rounded-md"style="z-index:3;box-shadow:0 4px 16px rgba(0, 0, 0, 0.2);font-family:ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Monaco, Consolas, monospace;letter-spacing:0.01em">`),
8
+ _tmpl$2 = /*#__PURE__*/_$template(`<div><div class="fixed rounded border border-solid border-gray-500"style=z-index:2></div><div class="fixed text-xs font-medium rounded-md"style="z-index:3;box-shadow:0 4px 16px rgba(0, 0, 0, 0.2);font-family:ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Monaco, Consolas, monospace;letter-spacing:0.01em">`);
7
9
  import { createMemo } from "solid-js";
8
10
  import { getElementInfo } from "../adapters/getElementInfo";
9
11
  import { Outline } from "./Outline";
12
+ import { parsePhoenixServerComponents } from "../adapters/phoenix/parsePhoenixComments";
10
13
  export function MaybeOutline(props) {
11
14
  const elInfo = createMemo(() => getElementInfo(props.currentElement, props.adapterId));
15
+
16
+ // Check for Phoenix server components when client framework data is not available
17
+ const phoenixInfo = createMemo(() => {
18
+ if (elInfo()) return null; // Client framework takes precedence
19
+
20
+ // Try current element first
21
+ let serverComponents = parsePhoenixServerComponents(props.currentElement);
22
+ if (serverComponents) return serverComponents;
23
+
24
+ // Walk up the tree to find the nearest parent with Phoenix components
25
+ let parent = props.currentElement.parentElement;
26
+ while (parent && parent !== document.body) {
27
+ serverComponents = parsePhoenixServerComponents(parent);
28
+ if (serverComponents) return serverComponents;
29
+ parent = parent.parentElement;
30
+ }
31
+ return null;
32
+ });
12
33
  const box = () => props.currentElement.getBoundingClientRect();
13
34
  return _$memo(() => _$memo(() => !!elInfo())() ? _$createComponent(Outline, {
14
35
  get element() {
@@ -17,14 +38,15 @@ export function MaybeOutline(props) {
17
38
  get targets() {
18
39
  return props.targets;
19
40
  }
20
- }) : (() => {
41
+ }) : _$memo(() => !!phoenixInfo())() ? (() => {
21
42
  var _el$ = _tmpl$(),
22
43
  _el$2 = _el$.firstChild,
23
44
  _el$3 = _el$2.nextSibling;
24
45
  _$setStyleProperty(_el$3, "padding", "4px 10px");
25
- _$setStyleProperty(_el$3, "background", "rgba(120, 53, 15, 0.85)");
46
+ _$setStyleProperty(_el$3, "background", "rgba(79, 70, 229, 0.85)");
26
47
  _$setStyleProperty(_el$3, "color", "#fff");
27
48
  _$setStyleProperty(_el$3, "border", "1px solid rgba(255, 255, 255, 0.15)");
49
+ _$insert(_el$3, () => phoenixInfo().filter(sc => sc.type === "component").map(sc => sc.name.split(".").pop()).join(" > "));
28
50
  _$effect(_p$ => {
29
51
  var _v$ = box().x + "px",
30
52
  _v$2 = box().y + "px",
@@ -48,5 +70,45 @@ export function MaybeOutline(props) {
48
70
  n: undefined
49
71
  });
50
72
  return _el$;
73
+ })() : (() => {
74
+ var _el$4 = _tmpl$2(),
75
+ _el$5 = _el$4.firstChild,
76
+ _el$6 = _el$5.nextSibling;
77
+ _$setStyleProperty(_el$6, "padding", "4px 10px");
78
+ _$setStyleProperty(_el$6, "background", "rgba(75, 85, 99, 0.85)");
79
+ _$setStyleProperty(_el$6, "color", "#fff");
80
+ _$setStyleProperty(_el$6, "border", "1px solid rgba(255, 255, 255, 0.15)");
81
+ _$insert(_el$6, () => props.currentElement.tagName.toLowerCase(), null);
82
+ _$insert(_el$6, (() => {
83
+ var _c$ = _$memo(() => !!props.currentElement.id);
84
+ return () => _c$() ? `#${props.currentElement.id}` : "";
85
+ })(), null);
86
+ _$insert(_el$6, (() => {
87
+ var _c$2 = _$memo(() => !!props.currentElement.className);
88
+ return () => _c$2() ? `.${props.currentElement.className.split(" ")[0]}` : "";
89
+ })(), null);
90
+ _$effect(_p$ => {
91
+ var _v$7 = box().x + "px",
92
+ _v$8 = box().y + "px",
93
+ _v$9 = box().width + "px",
94
+ _v$0 = box().height + "px",
95
+ _v$1 = box().x + 4 + "px",
96
+ _v$10 = box().y + 4 + "px";
97
+ _v$7 !== _p$.e && _$setStyleProperty(_el$5, "left", _p$.e = _v$7);
98
+ _v$8 !== _p$.t && _$setStyleProperty(_el$5, "top", _p$.t = _v$8);
99
+ _v$9 !== _p$.a && _$setStyleProperty(_el$5, "width", _p$.a = _v$9);
100
+ _v$0 !== _p$.o && _$setStyleProperty(_el$5, "height", _p$.o = _v$0);
101
+ _v$1 !== _p$.i && _$setStyleProperty(_el$6, "left", _p$.i = _v$1);
102
+ _v$10 !== _p$.n && _$setStyleProperty(_el$6, "top", _p$.n = _v$10);
103
+ return _p$;
104
+ }, {
105
+ e: undefined,
106
+ t: undefined,
107
+ a: undefined,
108
+ o: undefined,
109
+ i: undefined,
110
+ n: undefined
111
+ });
112
+ return _el$4;
51
113
  })());
52
114
  }
@@ -1,4 +1,5 @@
1
1
  import { TreeNode } from "../types/TreeNode";
2
+ import { ServerComponentInfo } from "../types/ServerComponentInfo";
2
3
  export interface OwnerComponentInfo {
3
4
  name: string;
4
5
  filePath?: string;
@@ -13,6 +14,8 @@ export interface AncestryItem {
13
14
  nthChild?: number;
14
15
  /** All owner components from outermost (Sidebar) to innermost (GlassPanel) */
15
16
  ownerComponents?: OwnerComponentInfo[];
17
+ /** Server-side components (Phoenix LiveView, Rails, Next.js RSC, etc.) */
18
+ serverComponents?: ServerComponentInfo[];
16
19
  }
17
20
  export declare function collectAncestry(node: TreeNode): AncestryItem[];
18
21
  export declare function formatAncestryChain(items: AncestryItem[]): string;
@@ -1,3 +1,6 @@
1
+ import { parsePhoenixServerComponents } from "../adapters/phoenix/parsePhoenixComments";
2
+ import { parseNextjsServerComponents } from "../adapters/nextjs/parseNextjsDataAttributes";
3
+ import { normalizeFilePath } from "./normalizeFilePath";
1
4
  // Elements to exclude from ancestry (not useful for debugging)
2
5
  const EXCLUDED_ELEMENTS = new Set(["html", "body", "head"]);
3
6
  function isTreeNodeElement(node) {
@@ -22,7 +25,7 @@ function getNthChildIfAmbiguous(element) {
22
25
  function treeNodeComponentToOwnerInfo(comp) {
23
26
  return {
24
27
  name: comp.label,
25
- filePath: comp.callLink?.fileName,
28
+ filePath: comp.callLink?.fileName ? normalizeFilePath(comp.callLink.fileName) : undefined,
26
29
  line: comp.callLink?.lineNumber
27
30
  };
28
31
  }
@@ -51,6 +54,19 @@ export function collectAncestry(node) {
51
54
  if (nthChild !== undefined) {
52
55
  item.nthChild = nthChild;
53
56
  }
57
+
58
+ // Parse server components from various sources
59
+ // 1. Phoenix LiveView (HTML comments)
60
+ const phoenixComponents = parsePhoenixServerComponents(element);
61
+
62
+ // 2. Next.js Server Components (data-locatorjs attributes)
63
+ const nextjsComponents = parseNextjsServerComponents(element);
64
+
65
+ // Combine all server components
66
+ const allServerComponents = [...(phoenixComponents || []), ...(nextjsComponents || [])];
67
+ if (allServerComponents.length > 0) {
68
+ item.serverComponents = allServerComponents;
69
+ }
54
70
  }
55
71
  }
56
72
 
@@ -62,7 +78,7 @@ export function collectAncestry(node) {
62
78
  // Use outermost component as the primary component name
63
79
  item.componentName = outermost.label;
64
80
  if (outermost.callLink) {
65
- item.filePath = outermost.callLink.fileName;
81
+ item.filePath = normalizeFilePath(outermost.callLink.fileName);
66
82
  item.line = outermost.callLink.lineNumber;
67
83
  }
68
84
  } else {
@@ -71,18 +87,18 @@ export function collectAncestry(node) {
71
87
  if (component) {
72
88
  item.componentName = component.label;
73
89
  if (component.callLink) {
74
- item.filePath = component.callLink.fileName;
90
+ item.filePath = normalizeFilePath(component.callLink.fileName);
75
91
  item.line = component.callLink.lineNumber;
76
92
  }
77
93
  }
78
94
  }
79
95
  if (!item.filePath && source) {
80
- item.filePath = source.fileName;
96
+ item.filePath = normalizeFilePath(source.fileName);
81
97
  item.line = source.lineNumber;
82
98
  }
83
99
 
84
- // Only include items that have useful info (component name or file path)
85
- if (item.componentName || item.filePath) {
100
+ // Only include items that have useful info (component name, file path, or server components)
101
+ if (item.componentName || item.filePath || item.serverComponents) {
86
102
  items.push(item);
87
103
  }
88
104
  current = current.getParent();
@@ -101,8 +117,35 @@ export function formatAncestryChain(items) {
101
117
  const indent = " ".repeat(index);
102
118
  const prefix = index === 0 ? "" : "└─ ";
103
119
 
104
- // Build element selector: elementName:nth-child(n)#id
105
- let selector = item.elementName;
120
+ // Get the previous item's component to detect component boundaries
121
+ const prevItem = index > 0 ? reversed[index - 1] : null;
122
+ const prevComponentName = prevItem?.componentName || prevItem?.ownerComponents?.[prevItem.ownerComponents.length - 1]?.name;
123
+
124
+ // Get current item's innermost component
125
+ const currentComponentName = item.ownerComponents?.[item.ownerComponents.length - 1]?.name || item.componentName;
126
+
127
+ // Determine the display name for the element
128
+ // Use component name ONLY when crossing a component boundary (root element of a component)
129
+ // This prevents "App -> App:nth-child(5)" when both are just elements inside App
130
+ let displayName = item.elementName;
131
+ let outerComponents = [];
132
+ const isComponentBoundary = currentComponentName && currentComponentName !== prevComponentName;
133
+ if (isComponentBoundary) {
134
+ if (item.ownerComponents && item.ownerComponents.length > 0) {
135
+ // Use innermost component as display name, show outer ones in "in X > Y"
136
+ const innermost = item.ownerComponents[item.ownerComponents.length - 1];
137
+ if (innermost) {
138
+ displayName = innermost.name;
139
+ // Outer components (excluding innermost)
140
+ outerComponents = item.ownerComponents.slice(0, -1).map(c => c.name);
141
+ }
142
+ } else if (item.componentName) {
143
+ displayName = item.componentName;
144
+ }
145
+ }
146
+
147
+ // Build element selector: displayName:nth-child(n)#id
148
+ let selector = displayName;
106
149
  if (item.nthChild !== undefined) {
107
150
  selector += `:nth-child(${item.nthChild})`;
108
151
  }
@@ -111,14 +154,60 @@ export function formatAncestryChain(items) {
111
154
  }
112
155
  let description = selector;
113
156
 
114
- // Show all owner components if available (Sidebar > GlassPanel)
115
- if (item.ownerComponents && item.ownerComponents.length > 0) {
116
- const componentChain = item.ownerComponents.map(c => c.name).join(" > ");
117
- description = `${selector} in ${componentChain}`;
118
- } else if (item.componentName) {
119
- description = `${selector} in ${item.componentName}`;
157
+ // Build component description parts
158
+ const parts = [];
159
+
160
+ // Server components (Phoenix/Next.js/Rails/etc.)
161
+ if (item.serverComponents && item.serverComponents.length > 0) {
162
+ // Group server components by framework (detected by file extension)
163
+ const phoenixComponents = item.serverComponents.filter(sc => sc.filePath.match(/\.(ex|exs|heex)$/));
164
+ const nextjsComponents = item.serverComponents.filter(sc => sc.filePath.match(/\.(tsx?|jsx?)$/));
165
+
166
+ // Format Phoenix components
167
+ if (phoenixComponents.length > 0) {
168
+ const names = phoenixComponents.filter(sc => sc.type === "component").map(sc => sc.name);
169
+ if (names.length > 0) {
170
+ parts.push(`[Phoenix: ${names.join(" > ")}]`);
171
+ }
172
+ }
173
+
174
+ // Format Next.js components
175
+ if (nextjsComponents.length > 0) {
176
+ const names = nextjsComponents.filter(sc => sc.type === "component").map(sc => sc.name);
177
+ if (names.length > 0) {
178
+ parts.push(`[Next.js: ${names.join(" > ")}]`);
179
+ }
180
+ }
181
+ }
182
+
183
+ // Client components - show outer components for context (if any)
184
+ if (outerComponents.length > 0) {
185
+ parts.push(`in ${outerComponents.join(" > ")}`);
186
+ }
187
+ if (parts.length > 0) {
188
+ description = `${selector} ${parts.join(" ")}`;
189
+ }
190
+
191
+ // Build location string
192
+ const locationParts = [];
193
+
194
+ // Server component locations
195
+ if (item.serverComponents && item.serverComponents.length > 0) {
196
+ item.serverComponents.forEach(sc => {
197
+ const prefix = sc.type === "caller" ? " (called from)" : "";
198
+ locationParts.push(`${sc.filePath}:${sc.line}${prefix}`);
199
+ });
200
+ }
201
+
202
+ // Client component location (only add if different from server components)
203
+ if (item.filePath) {
204
+ const clientLocation = `${item.filePath}:${item.line}`;
205
+ // Only add if this location isn't already in the list
206
+ if (!locationParts.includes(clientLocation)) {
207
+ locationParts.push(clientLocation);
208
+ }
120
209
  }
121
- const location = item.filePath ? ` at ${item.filePath}:${item.line}` : "";
210
+ const location = locationParts.length > 0 ? ` at ${locationParts.join(", ")}` : "";
122
211
  lines.push(`${indent}${prefix}${description}${location}`);
123
212
  });
124
213
  return lines.join("\n");
@@ -1,7 +1,7 @@
1
1
  import { describe, it, expect } from "vitest";
2
2
  import { formatAncestryChain } from "./formatAncestryChain";
3
3
  describe("formatAncestryChain", () => {
4
- it("formats basic ancestry without ID or nth-child", () => {
4
+ it("uses component name only at component boundaries", () => {
5
5
  const items = [{
6
6
  elementName: "button",
7
7
  componentName: "Button"
@@ -10,23 +10,25 @@ describe("formatAncestryChain", () => {
10
10
  componentName: "App"
11
11
  }];
12
12
  const result = formatAncestryChain(items);
13
- expect(result).toBe(`div in App
14
- └─ button in Button`);
13
+ // App is root (component boundary), Button is different component (boundary)
14
+ expect(result).toBe(`App
15
+ └─ Button`);
15
16
  });
16
- it("includes ID when present", () => {
17
+ it("shows element name when same component as parent", () => {
17
18
  const items = [{
18
19
  elementName: "button",
19
- componentName: "Button",
20
+ componentName: "App",
20
21
  id: "submit-btn"
21
22
  }, {
22
23
  elementName: "div",
23
24
  componentName: "App"
24
25
  }];
25
26
  const result = formatAncestryChain(items);
26
- expect(result).toBe(`div in App
27
- └─ button#submit-btn in Button`);
27
+ // Both are in App, so second item shows element name not component name
28
+ expect(result).toBe(`App
29
+ └─ button#submit-btn`);
28
30
  });
29
- it("includes nth-child when present", () => {
31
+ it("includes nth-child with component name at boundary", () => {
30
32
  const items = [{
31
33
  elementName: "li",
32
34
  componentName: "ListItem",
@@ -36,10 +38,11 @@ describe("formatAncestryChain", () => {
36
38
  componentName: "List"
37
39
  }];
38
40
  const result = formatAncestryChain(items);
39
- expect(result).toBe(`ul in List
40
- └─ li:nth-child(3) in ListItem`);
41
+ // Different components = boundaries
42
+ expect(result).toBe(`List
43
+ └─ ListItem:nth-child(3)`);
41
44
  });
42
- it("includes both nth-child and ID when present", () => {
45
+ it("includes both nth-child and ID at component boundary", () => {
43
46
  const items = [{
44
47
  elementName: "li",
45
48
  componentName: "ListItem",
@@ -50,10 +53,10 @@ describe("formatAncestryChain", () => {
50
53
  componentName: "List"
51
54
  }];
52
55
  const result = formatAncestryChain(items);
53
- expect(result).toBe(`ul in List
54
- └─ li:nth-child(2)#special-item in ListItem`);
56
+ expect(result).toBe(`List
57
+ └─ ListItem:nth-child(2)#special-item`);
55
58
  });
56
- it("includes file location when present", () => {
59
+ it("includes file location at component boundary", () => {
57
60
  const items = [{
58
61
  elementName: "button",
59
62
  componentName: "Button",
@@ -62,7 +65,8 @@ describe("formatAncestryChain", () => {
62
65
  line: 42
63
66
  }];
64
67
  const result = formatAncestryChain(items);
65
- expect(result).toBe("button#save in Button at src/Button.tsx:42");
68
+ // First item is always a boundary (no previous item)
69
+ expect(result).toBe("Button#save at src/Button.tsx:42");
66
70
  });
67
71
  it("formats element without component name", () => {
68
72
  const items = [{
@@ -78,7 +82,7 @@ describe("formatAncestryChain", () => {
78
82
  const result = formatAncestryChain([]);
79
83
  expect(result).toBe("");
80
84
  });
81
- it("shows all owner components when ownerComponents is provided", () => {
85
+ it("uses innermost component as display name with outer components in chain", () => {
82
86
  const items = [{
83
87
  elementName: "div",
84
88
  id: "sidebar-panel",
@@ -101,10 +105,11 @@ describe("formatAncestryChain", () => {
101
105
  line: 104
102
106
  }];
103
107
  const result = formatAncestryChain(items);
104
- expect(result).toBe(`div in App at src/App.jsx:104
105
- └─ div#sidebar-panel in Sidebar > GlassPanel at src/components/game/Sidebar.jsx:78`);
108
+ // GlassPanel (innermost) is the display name, Sidebar (outer) shown in "in"
109
+ expect(result).toBe(`App at src/App.jsx:104
110
+ └─ GlassPanel#sidebar-panel in Sidebar at src/components/game/Sidebar.jsx:78`);
106
111
  });
107
- it("shows single owner component without arrow when only one in chain", () => {
112
+ it("uses component name as display name when only one in chain", () => {
108
113
  const items = [{
109
114
  elementName: "button",
110
115
  componentName: "Button",
@@ -117,6 +122,7 @@ describe("formatAncestryChain", () => {
117
122
  line: 10
118
123
  }];
119
124
  const result = formatAncestryChain(items);
120
- expect(result).toBe("button in Button at src/Button.tsx:10");
125
+ // Single component becomes the display name, no "in X" needed
126
+ expect(result).toBe("Button at src/Button.tsx:10");
121
127
  });
122
128
  });
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Convert absolute file paths to relative paths for display.
3
+ *
4
+ * This ensures consistent path display across different sources:
5
+ * - React _debugSource may provide absolute paths
6
+ * - Next.js data-locatorjs attributes may have absolute paths
7
+ * - Phoenix comments use relative paths
8
+ *
9
+ * Examples:
10
+ * - "/Users/name/project/src/App.tsx" → "src/App.tsx"
11
+ * - "/workspace/apps/next-16/app/page.tsx" → "app/page.tsx"
12
+ * - "src/components/Button.tsx" → "src/components/Button.tsx" (unchanged)
13
+ */
14
+ export declare function normalizeFilePath(filePath: string): string;
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Convert absolute file paths to relative paths for display.
3
+ *
4
+ * This ensures consistent path display across different sources:
5
+ * - React _debugSource may provide absolute paths
6
+ * - Next.js data-locatorjs attributes may have absolute paths
7
+ * - Phoenix comments use relative paths
8
+ *
9
+ * Examples:
10
+ * - "/Users/name/project/src/App.tsx" → "src/App.tsx"
11
+ * - "/workspace/apps/next-16/app/page.tsx" → "app/page.tsx"
12
+ * - "src/components/Button.tsx" → "src/components/Button.tsx" (unchanged)
13
+ */
14
+ export function normalizeFilePath(filePath) {
15
+ // If it's already relative (doesn't start with /), return as-is
16
+ if (!filePath.startsWith("/")) {
17
+ return filePath;
18
+ }
19
+
20
+ // Find common project indicators to trim the path
21
+ const indicators = ["/app/", "/src/", "/pages/", "/components/", "/lib/"];
22
+ for (const indicator of indicators) {
23
+ const index = filePath.indexOf(indicator);
24
+ if (index !== -1) {
25
+ // Return from the indicator onwards (remove leading slash)
26
+ return filePath.substring(index + 1);
27
+ }
28
+ }
29
+
30
+ // If no indicator found, try to get just the last few path segments
31
+ const parts = filePath.split("/");
32
+
33
+ // Return last 3-4 segments if available (e.g., "apps/next-16/app/page.tsx")
34
+ if (parts.length > 3) {
35
+ return parts.slice(-4).join("/");
36
+ }
37
+
38
+ // Last resort: return as-is
39
+ return filePath;
40
+ }