@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,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"
|
|
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(
|
|
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
|
|
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
|
-
//
|
|
105
|
-
|
|
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
|
-
//
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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 =
|
|
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("
|
|
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
|
-
|
|
14
|
-
|
|
13
|
+
// App is root (component boundary), Button is different component (boundary)
|
|
14
|
+
expect(result).toBe(`App
|
|
15
|
+
└─ Button`);
|
|
15
16
|
});
|
|
16
|
-
it("
|
|
17
|
+
it("shows element name when same component as parent", () => {
|
|
17
18
|
const items = [{
|
|
18
19
|
elementName: "button",
|
|
19
|
-
componentName: "
|
|
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
|
-
|
|
27
|
-
|
|
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
|
|
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
|
-
|
|
40
|
-
|
|
41
|
+
// Different components = boundaries
|
|
42
|
+
expect(result).toBe(`List
|
|
43
|
+
└─ ListItem:nth-child(3)`);
|
|
41
44
|
});
|
|
42
|
-
it("includes both nth-child and ID
|
|
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(`
|
|
54
|
-
└─
|
|
56
|
+
expect(result).toBe(`List
|
|
57
|
+
└─ ListItem:nth-child(2)#special-item`);
|
|
55
58
|
});
|
|
56
|
-
it("includes file location
|
|
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
|
-
|
|
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("
|
|
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
|
-
|
|
105
|
-
|
|
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("
|
|
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
|
-
|
|
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
|
+
}
|