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