@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.
- package/.turbo/turbo-build.log +8 -6
- package/.turbo/turbo-dev.log +32 -0
- package/.turbo/turbo-test.log +54 -10
- package/dist/adapters/createTreeNode.js +32 -4
- package/dist/adapters/nextjs/parseNextjsDataAttributes.d.ts +31 -0
- package/dist/adapters/nextjs/parseNextjsDataAttributes.js +106 -0
- package/dist/adapters/phoenix/__tests__/parsePhoenixComments.test.d.ts +4 -0
- package/dist/adapters/phoenix/__tests__/parsePhoenixComments.test.js +218 -0
- package/dist/adapters/phoenix/detectPhoenix.d.ts +11 -0
- package/dist/adapters/phoenix/detectPhoenix.js +38 -0
- package/dist/adapters/phoenix/index.d.ts +10 -0
- package/dist/adapters/phoenix/index.js +9 -0
- package/dist/adapters/phoenix/parsePhoenixComments.d.ts +35 -0
- package/dist/adapters/phoenix/parsePhoenixComments.js +131 -0
- package/dist/adapters/phoenix/types.d.ts +16 -0
- package/dist/adapters/phoenix/types.js +1 -0
- package/dist/adapters/react/getFiberLabel.js +2 -1
- package/dist/components/MaybeOutline.js +65 -3
- package/dist/functions/formatAncestryChain.d.ts +3 -0
- package/dist/functions/formatAncestryChain.js +104 -15
- package/dist/functions/formatAncestryChain.test.js +26 -20
- package/dist/functions/normalizeFilePath.d.ts +14 -0
- package/dist/functions/normalizeFilePath.js +40 -0
- package/dist/output.css +87 -15
- package/dist/types/ServerComponentInfo.d.ts +14 -0
- package/dist/types/ServerComponentInfo.js +1 -0
- package/package.json +4 -3
- package/src/adapters/createTreeNode.ts +35 -3
- package/src/adapters/nextjs/parseNextjsDataAttributes.ts +112 -0
- package/src/adapters/phoenix/__tests__/parsePhoenixComments.test.ts +264 -0
- package/src/adapters/phoenix/detectPhoenix.ts +44 -0
- package/src/adapters/phoenix/index.ts +11 -0
- package/src/adapters/phoenix/parsePhoenixComments.ts +140 -0
- package/src/adapters/phoenix/types.ts +16 -0
- package/src/adapters/react/getFiberLabel.ts +2 -1
- package/src/components/MaybeOutline.tsx +63 -4
- package/src/functions/formatAncestryChain.test.ts +26 -20
- package/src/functions/formatAncestryChain.ts +121 -15
- package/src/functions/normalizeFilePath.ts +41 -0
- package/src/types/ServerComponentInfo.ts +14 -0
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detect if Phoenix LiveView is present on the page.
|
|
3
|
+
*
|
|
4
|
+
* Checks for multiple signals:
|
|
5
|
+
* 1. window.liveSocket - Phoenix LiveView JS client
|
|
6
|
+
* 2. data-phx-* attributes in the DOM
|
|
7
|
+
* 3. Phoenix debug comment patterns
|
|
8
|
+
*
|
|
9
|
+
* Returns true if any signal indicates Phoenix LiveView is running.
|
|
10
|
+
*/
|
|
11
|
+
export function detectPhoenix(): boolean {
|
|
12
|
+
// Check 1: Look for LiveView socket (most reliable)
|
|
13
|
+
if (typeof window !== "undefined" && (window as any).liveSocket) {
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Check 2: Look for Phoenix data attributes in the DOM
|
|
18
|
+
if (typeof document !== "undefined") {
|
|
19
|
+
// Phoenix LiveView adds data-phx-main or data-phx-session to the main LiveView container
|
|
20
|
+
if (document.querySelector("[data-phx-main], [data-phx-session]")) {
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Check 3: Quick scan for Phoenix debug comments (first 50 comments)
|
|
25
|
+
// This is useful when debug_heex_annotations is enabled
|
|
26
|
+
const walker = document.createTreeWalker(
|
|
27
|
+
document.body,
|
|
28
|
+
NodeFilter.SHOW_COMMENT,
|
|
29
|
+
null
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
let count = 0;
|
|
33
|
+
while (walker.nextNode() && count < 50) {
|
|
34
|
+
const text = walker.currentNode.textContent;
|
|
35
|
+
// Look for Phoenix-specific comment patterns
|
|
36
|
+
if (text?.includes("@caller") || text?.includes("<App")) {
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
count++;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phoenix LiveView adapter for TreeLocatorJS.
|
|
3
|
+
*
|
|
4
|
+
* Parses server-side component information from Phoenix LiveView debug annotations.
|
|
5
|
+
* Requires Phoenix LiveView v1.1+ with debug_heex_annotations: true in config.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export { parsePhoenixServerComponents, findPrecedingPhoenixComments } from "./parsePhoenixComments";
|
|
9
|
+
export { detectPhoenix } from "./detectPhoenix";
|
|
10
|
+
export type { ServerComponentInfo } from "../../types/ServerComponentInfo";
|
|
11
|
+
export type { PhoenixCommentMatch } from "./types";
|
|
@@ -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
|
-
{/*
|
|
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(
|
|
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
|
-
|
|
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
|
)}
|
|
@@ -6,46 +6,49 @@ import {
|
|
|
6
6
|
} from "./formatAncestryChain";
|
|
7
7
|
|
|
8
8
|
describe("formatAncestryChain", () => {
|
|
9
|
-
it("
|
|
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
|
-
`
|
|
18
|
-
└─
|
|
18
|
+
`App
|
|
19
|
+
└─ Button`
|
|
19
20
|
);
|
|
20
21
|
});
|
|
21
22
|
|
|
22
|
-
it("
|
|
23
|
+
it("shows element name when same component as parent", () => {
|
|
23
24
|
const items: AncestryItem[] = [
|
|
24
|
-
{ elementName: "button", componentName: "
|
|
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
|
-
`
|
|
31
|
-
└─ button#submit-btn
|
|
32
|
+
`App
|
|
33
|
+
└─ button#submit-btn`
|
|
32
34
|
);
|
|
33
35
|
});
|
|
34
36
|
|
|
35
|
-
it("includes nth-child
|
|
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
|
-
`
|
|
44
|
-
└─
|
|
46
|
+
`List
|
|
47
|
+
└─ ListItem:nth-child(3)`
|
|
45
48
|
);
|
|
46
49
|
});
|
|
47
50
|
|
|
48
|
-
it("includes both nth-child and ID
|
|
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
|
-
`
|
|
62
|
-
└─
|
|
64
|
+
`List
|
|
65
|
+
└─ ListItem:nth-child(2)#special-item`
|
|
63
66
|
);
|
|
64
67
|
});
|
|
65
68
|
|
|
66
|
-
it("includes file location
|
|
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
|
-
|
|
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("
|
|
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
|
-
`
|
|
122
|
-
└─
|
|
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("
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
142
|
-
|
|
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
|
-
//
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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 =
|
|
266
|
+
const location = locationParts.length > 0 ? ` at ${locationParts.join(", ")}` : "";
|
|
161
267
|
|
|
162
268
|
lines.push(`${indent}${prefix}${description}${location}`);
|
|
163
269
|
});
|