@treelocator/runtime 0.1.8 → 0.3.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/dist/adapters/createTreeNode.js +41 -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/findDebugSource.d.ts +13 -0
- package/dist/adapters/react/findDebugSource.js +37 -0
- package/dist/adapters/react/findFiberByHtmlElement.js +23 -1
- package/dist/adapters/react/getFiberLabel.js +2 -1
- package/dist/adapters/react/resolveSourceMap.d.ts +29 -0
- package/dist/adapters/react/resolveSourceMap.js +236 -0
- package/dist/browserApi.d.ts +4 -4
- package/dist/browserApi.js +13 -15
- package/dist/components/MaybeOutline.js +65 -3
- package/dist/components/Runtime.js +13 -0
- package/dist/functions/enrichAncestrySourceMaps.d.ts +7 -0
- package/dist/functions/enrichAncestrySourceMaps.js +80 -0
- 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 +44 -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/findDebugSource.ts +40 -0
- package/src/adapters/react/findFiberByHtmlElement.ts +26 -1
- package/src/adapters/react/getFiberLabel.ts +2 -1
- package/src/adapters/react/reactAdapter.ts +2 -1
- package/src/adapters/react/resolveSourceMap.ts +316 -0
- package/src/browserApi.ts +27 -25
- package/src/components/MaybeOutline.tsx +63 -4
- package/src/components/Runtime.tsx +15 -0
- package/src/functions/enrichAncestrySourceMaps.ts +103 -0
- 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
- package/.turbo/turbo-build.log +0 -30
- package/.turbo/turbo-test.log +0 -19
- package/.turbo/turbo-ts.log +0 -4
- package/LICENSE +0 -22
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
import { Fiber, Source } from "@locator/shared";
|
|
2
|
+
import {
|
|
3
|
+
resolveSourceFromDebugStack,
|
|
4
|
+
parseDebugStack,
|
|
5
|
+
} from "./resolveSourceMap";
|
|
2
6
|
|
|
3
7
|
export function findDebugSource(
|
|
4
8
|
fiber: Fiber
|
|
5
9
|
): { fiber: Fiber; source: Source } | null {
|
|
6
10
|
let current: Fiber | null = fiber;
|
|
7
11
|
while (current) {
|
|
12
|
+
// React 18 and earlier: _debugSource is a structured object
|
|
8
13
|
if (current._debugSource) {
|
|
9
14
|
return { fiber: current, source: current._debugSource };
|
|
10
15
|
}
|
|
@@ -13,3 +18,38 @@ export function findDebugSource(
|
|
|
13
18
|
|
|
14
19
|
return null;
|
|
15
20
|
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Async version of findDebugSource that supports React 19's _debugStack.
|
|
24
|
+
* Falls back to synchronous _debugSource check first (React 18).
|
|
25
|
+
* If that fails, parses _debugStack and resolves via source maps.
|
|
26
|
+
*/
|
|
27
|
+
export async function findDebugSourceAsync(
|
|
28
|
+
fiber: Fiber
|
|
29
|
+
): Promise<{ fiber: Fiber; source: Source } | null> {
|
|
30
|
+
// Try synchronous path first (React 18)
|
|
31
|
+
const syncResult = findDebugSource(fiber);
|
|
32
|
+
if (syncResult) return syncResult;
|
|
33
|
+
|
|
34
|
+
// React 19: try resolving via _debugStack + source maps
|
|
35
|
+
let current: Fiber | null = fiber;
|
|
36
|
+
while (current) {
|
|
37
|
+
const debugStack = (current as any)._debugStack;
|
|
38
|
+
if (debugStack?.stack) {
|
|
39
|
+
const source = await resolveSourceFromDebugStack(debugStack);
|
|
40
|
+
if (source) {
|
|
41
|
+
return { fiber: current, source };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
current = current._debugOwner || null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Check if this is a React 19+ environment (has _debugStack but not _debugSource).
|
|
52
|
+
*/
|
|
53
|
+
export function isReact19Fiber(fiber: Fiber): boolean {
|
|
54
|
+
return !fiber._debugSource && !!(fiber as any)._debugStack;
|
|
55
|
+
}
|
|
@@ -1,12 +1,26 @@
|
|
|
1
1
|
import { Fiber, Renderer } from "@locator/shared";
|
|
2
2
|
import { findDebugSource } from "./findDebugSource";
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* Find the React fiber key on a DOM element (e.g., "__reactFiber$abc123").
|
|
6
|
+
* Works across all React versions that attach fibers to DOM nodes.
|
|
7
|
+
*/
|
|
8
|
+
function findFiberFromDOMElement(element: HTMLElement): Fiber | null {
|
|
9
|
+
const fiberKey = Object.keys(element).find((k) =>
|
|
10
|
+
k.startsWith("__reactFiber$")
|
|
11
|
+
);
|
|
12
|
+
if (fiberKey) {
|
|
13
|
+
return (element as any)[fiberKey] as Fiber;
|
|
14
|
+
}
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
|
|
4
18
|
export function findFiberByHtmlElement(
|
|
5
19
|
target: HTMLElement,
|
|
6
20
|
shouldHaveDebugSource: boolean
|
|
7
21
|
): Fiber | null {
|
|
22
|
+
// Try via DevTools renderers first (available when React DevTools extension is installed)
|
|
8
23
|
const renderers = window.__REACT_DEVTOOLS_GLOBAL_HOOK__?.renderers;
|
|
9
|
-
// console.log("RENDERERS: ", renderers);
|
|
10
24
|
const renderersValues = renderers?.values();
|
|
11
25
|
if (renderersValues) {
|
|
12
26
|
for (const renderer of Array.from(renderersValues) as Renderer[]) {
|
|
@@ -23,5 +37,16 @@ export function findFiberByHtmlElement(
|
|
|
23
37
|
}
|
|
24
38
|
}
|
|
25
39
|
}
|
|
40
|
+
|
|
41
|
+
// Fallback: read fiber directly from DOM element's __reactFiber$ property.
|
|
42
|
+
// This works without the React DevTools extension and across React 16-19.
|
|
43
|
+
const fiber = findFiberFromDOMElement(target);
|
|
44
|
+
if (fiber) {
|
|
45
|
+
if (shouldHaveDebugSource) {
|
|
46
|
+
return findDebugSource(fiber)?.fiber || null;
|
|
47
|
+
}
|
|
48
|
+
return fiber;
|
|
49
|
+
}
|
|
50
|
+
|
|
26
51
|
return null;
|
|
27
52
|
}
|
|
@@ -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,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { findDebugSource } from "./findDebugSource";
|
|
1
|
+
import { findDebugSource, findDebugSourceAsync, isReact19Fiber } from "./findDebugSource";
|
|
2
2
|
import { findFiberByHtmlElement } from "./findFiberByHtmlElement";
|
|
3
|
+
import { resolveSourceFromDebugStack } from "./resolveSourceMap";
|
|
3
4
|
import { getFiberLabel } from "./getFiberLabel";
|
|
4
5
|
import { getAllWrappingParents } from "./getAllWrappingParents";
|
|
5
6
|
import { deduplicateLabels } from "../../functions/deduplicateLabels";
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
import { Source } from "@locator/shared";
|
|
2
|
+
|
|
3
|
+
interface SourceMapSection {
|
|
4
|
+
offset: { line: number; column: number };
|
|
5
|
+
map: {
|
|
6
|
+
version: number;
|
|
7
|
+
sources: string[];
|
|
8
|
+
mappings: string;
|
|
9
|
+
sourcesContent?: (string | null)[];
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface IndexedSourceMap {
|
|
14
|
+
version: number;
|
|
15
|
+
sections: SourceMapSection[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface BasicSourceMap {
|
|
19
|
+
version: number;
|
|
20
|
+
sources: string[];
|
|
21
|
+
mappings: string;
|
|
22
|
+
sourcesContent?: (string | null)[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
type SourceMap = IndexedSourceMap | BasicSourceMap;
|
|
26
|
+
|
|
27
|
+
// Cache: bundled script URL -> source map
|
|
28
|
+
const sourceMapCache = new Map<string, Promise<SourceMap | null>>();
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Parse the React 19 _debugStack Error.stack to extract the caller's location.
|
|
32
|
+
*
|
|
33
|
+
* Stack format:
|
|
34
|
+
* Error: react-stack-top-frame
|
|
35
|
+
* at exports.jsxDEV (http://localhost:3000/_next/.../chunk.js:410:33)
|
|
36
|
+
* at Home (http://localhost:3000/_next/.../chunk.js:8789:416)
|
|
37
|
+
* at Object.react_stack_bottom_frame (...)
|
|
38
|
+
*
|
|
39
|
+
* The second frame (after jsxDEV/jsx) is the component that created the element.
|
|
40
|
+
*/
|
|
41
|
+
export function parseDebugStack(
|
|
42
|
+
stack: string
|
|
43
|
+
): { url: string; line: number; column: number } | null {
|
|
44
|
+
const lines = stack.split("\n");
|
|
45
|
+
|
|
46
|
+
// Find the component caller frame (skip react-stack-top-frame and jsx factory)
|
|
47
|
+
for (let i = 1; i < lines.length; i++) {
|
|
48
|
+
const line = lines[i]?.trim();
|
|
49
|
+
if (!line) continue;
|
|
50
|
+
|
|
51
|
+
// Skip react internal frames
|
|
52
|
+
if (
|
|
53
|
+
line.includes("react-stack-top-frame") ||
|
|
54
|
+
line.includes("react_stack_bottom_frame")
|
|
55
|
+
) {
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Skip JSX factory frames (jsxDEV, jsx, jsxs)
|
|
60
|
+
if (
|
|
61
|
+
line.includes("jsxDEV") ||
|
|
62
|
+
line.includes("exports.jsx ") ||
|
|
63
|
+
line.includes("exports.jsxs ")
|
|
64
|
+
) {
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Parse "at Name (url:line:col)" or "at url:line:col"
|
|
69
|
+
const match =
|
|
70
|
+
line.match(/at\s+(?:\S+\s+)?\(?(https?:\/\/.+?):(\d+):(\d+)\)?/) ||
|
|
71
|
+
line.match(/at\s+(https?:\/\/.+?):(\d+):(\d+)/);
|
|
72
|
+
|
|
73
|
+
if (match && match[1] && match[2] && match[3]) {
|
|
74
|
+
return {
|
|
75
|
+
url: match[1],
|
|
76
|
+
line: parseInt(match[2], 10),
|
|
77
|
+
column: parseInt(match[3], 10),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function fetchSourceMap(scriptUrl: string): Promise<SourceMap | null> {
|
|
86
|
+
if (sourceMapCache.has(scriptUrl)) {
|
|
87
|
+
return sourceMapCache.get(scriptUrl)!;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const promise = (async () => {
|
|
91
|
+
try {
|
|
92
|
+
// Fetch the script to find sourceMappingURL
|
|
93
|
+
const scriptResp = await fetch(scriptUrl);
|
|
94
|
+
if (!scriptResp.ok) return null;
|
|
95
|
+
|
|
96
|
+
const scriptText = await scriptResp.text();
|
|
97
|
+
const match = scriptText.match(
|
|
98
|
+
/\/\/[#@]\s*sourceMappingURL=(.+?)(?:\s|$)/
|
|
99
|
+
);
|
|
100
|
+
if (!match || !match[1]) return null;
|
|
101
|
+
|
|
102
|
+
// Resolve the source map URL relative to the script URL
|
|
103
|
+
let mapUrl = match[1];
|
|
104
|
+
if (!mapUrl.startsWith("http")) {
|
|
105
|
+
const base = scriptUrl.substring(0, scriptUrl.lastIndexOf("/") + 1);
|
|
106
|
+
mapUrl = base + mapUrl;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const mapResp = await fetch(mapUrl);
|
|
110
|
+
if (!mapResp.ok) return null;
|
|
111
|
+
|
|
112
|
+
return (await mapResp.json()) as SourceMap;
|
|
113
|
+
} catch {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
})();
|
|
117
|
+
|
|
118
|
+
sourceMapCache.set(scriptUrl, promise);
|
|
119
|
+
return promise;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Decode a single VLQ value from a mappings string, returns [value, charsConsumed]
|
|
123
|
+
const VLQ_BASE_SHIFT = 5;
|
|
124
|
+
const VLQ_BASE = 1 << VLQ_BASE_SHIFT; // 32
|
|
125
|
+
const VLQ_BASE_MASK = VLQ_BASE - 1; // 31
|
|
126
|
+
const VLQ_CONTINUATION_BIT = VLQ_BASE; // 32
|
|
127
|
+
const BASE64_CHARS =
|
|
128
|
+
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
129
|
+
const base64Map = new Map<string, number>();
|
|
130
|
+
for (let i = 0; i < BASE64_CHARS.length; i++) {
|
|
131
|
+
base64Map.set(BASE64_CHARS[i]!, i);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function decodeVLQ(str: string, index: number): [number, number] {
|
|
135
|
+
let result = 0;
|
|
136
|
+
let shift = 0;
|
|
137
|
+
let continuation: boolean;
|
|
138
|
+
let i = index;
|
|
139
|
+
|
|
140
|
+
do {
|
|
141
|
+
const char = str[i];
|
|
142
|
+
if (!char) return [0, i];
|
|
143
|
+
const digit = base64Map.get(char);
|
|
144
|
+
if (digit === undefined) return [0, i];
|
|
145
|
+
i++;
|
|
146
|
+
continuation = (digit & VLQ_CONTINUATION_BIT) !== 0;
|
|
147
|
+
result += (digit & VLQ_BASE_MASK) << shift;
|
|
148
|
+
shift += VLQ_BASE_SHIFT;
|
|
149
|
+
} while (continuation);
|
|
150
|
+
|
|
151
|
+
// Convert from VLQ signed
|
|
152
|
+
const isNegative = (result & 1) === 1;
|
|
153
|
+
result >>= 1;
|
|
154
|
+
return [isNegative ? -result : result, i];
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Find the original source location for a generated line/column in a basic source map.
|
|
159
|
+
*/
|
|
160
|
+
function resolveInBasicMap(
|
|
161
|
+
map: BasicSourceMap,
|
|
162
|
+
targetLine: number,
|
|
163
|
+
targetColumn: number
|
|
164
|
+
): Source | null {
|
|
165
|
+
const mappings = map.mappings;
|
|
166
|
+
if (!mappings) return null;
|
|
167
|
+
|
|
168
|
+
let generatedLine = 1;
|
|
169
|
+
let generatedColumn = 0;
|
|
170
|
+
let sourceIndex = 0;
|
|
171
|
+
let sourceLine = 0;
|
|
172
|
+
let sourceColumn = 0;
|
|
173
|
+
|
|
174
|
+
let bestSource: Source | null = null;
|
|
175
|
+
let i = 0;
|
|
176
|
+
|
|
177
|
+
while (i < mappings.length) {
|
|
178
|
+
const char = mappings[i];
|
|
179
|
+
|
|
180
|
+
if (char === ";") {
|
|
181
|
+
generatedLine++;
|
|
182
|
+
generatedColumn = 0;
|
|
183
|
+
i++;
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (char === ",") {
|
|
188
|
+
i++;
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Decode segment
|
|
193
|
+
let colDelta: number;
|
|
194
|
+
[colDelta, i] = decodeVLQ(mappings, i);
|
|
195
|
+
generatedColumn += colDelta;
|
|
196
|
+
|
|
197
|
+
// Check if there's source info (segments can be 1, 4, or 5 fields)
|
|
198
|
+
if (i < mappings.length && mappings[i] !== "," && mappings[i] !== ";") {
|
|
199
|
+
let srcDelta: number, lineDelta: number, srcColDelta: number;
|
|
200
|
+
[srcDelta, i] = decodeVLQ(mappings, i);
|
|
201
|
+
sourceIndex += srcDelta;
|
|
202
|
+
[lineDelta, i] = decodeVLQ(mappings, i);
|
|
203
|
+
sourceLine += lineDelta;
|
|
204
|
+
[srcColDelta, i] = decodeVLQ(mappings, i);
|
|
205
|
+
sourceColumn += srcColDelta;
|
|
206
|
+
|
|
207
|
+
// Skip optional name index
|
|
208
|
+
if (i < mappings.length && mappings[i] !== "," && mappings[i] !== ";") {
|
|
209
|
+
[, i] = decodeVLQ(mappings, i);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Check if this mapping is at or before our target
|
|
213
|
+
if (
|
|
214
|
+
generatedLine === targetLine &&
|
|
215
|
+
generatedColumn <= targetColumn
|
|
216
|
+
) {
|
|
217
|
+
const fileName = map.sources[sourceIndex];
|
|
218
|
+
if (fileName) {
|
|
219
|
+
bestSource = {
|
|
220
|
+
fileName: cleanSourcePath(fileName),
|
|
221
|
+
lineNumber: sourceLine + 1, // source maps are 0-indexed
|
|
222
|
+
columnNumber: sourceColumn,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// If we've passed the target line, we can stop
|
|
228
|
+
if (generatedLine > targetLine) {
|
|
229
|
+
break;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return bestSource;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Clean up source file paths from source maps.
|
|
239
|
+
* Strips file:// protocol and common project root prefixes.
|
|
240
|
+
*/
|
|
241
|
+
function cleanSourcePath(filePath: string): string {
|
|
242
|
+
// Strip file:// protocol
|
|
243
|
+
let cleaned = filePath.replace(/^file:\/\//, "");
|
|
244
|
+
|
|
245
|
+
// Strip webpack/turbopack internal prefixes
|
|
246
|
+
cleaned = cleaned.replace(/^\[project\]\//, "");
|
|
247
|
+
cleaned = cleaned.replace(/^webpack:\/\/[^/]*\//, "");
|
|
248
|
+
|
|
249
|
+
return cleaned;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Resolve a bundled location to its original source using source maps.
|
|
254
|
+
* Returns null if resolution fails.
|
|
255
|
+
*/
|
|
256
|
+
export async function resolveSourceLocation(
|
|
257
|
+
url: string,
|
|
258
|
+
line: number,
|
|
259
|
+
column: number
|
|
260
|
+
): Promise<Source | null> {
|
|
261
|
+
const sourceMap = await fetchSourceMap(url);
|
|
262
|
+
if (!sourceMap) return null;
|
|
263
|
+
|
|
264
|
+
// Handle indexed/sectioned source maps (used by Turbopack)
|
|
265
|
+
if ("sections" in sourceMap && sourceMap.sections) {
|
|
266
|
+
const sections = sourceMap.sections;
|
|
267
|
+
|
|
268
|
+
// Find the section that contains our target line
|
|
269
|
+
let targetSection: SourceMapSection | null = null;
|
|
270
|
+
for (let i = sections.length - 1; i >= 0; i--) {
|
|
271
|
+
const section = sections[i];
|
|
272
|
+
if (!section) continue;
|
|
273
|
+
if (section.offset.line < line) {
|
|
274
|
+
targetSection = section;
|
|
275
|
+
break;
|
|
276
|
+
}
|
|
277
|
+
if (section.offset.line === line && section.offset.column <= column) {
|
|
278
|
+
targetSection = section;
|
|
279
|
+
break;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (!targetSection) return null;
|
|
284
|
+
|
|
285
|
+
// Resolve within the section's map (adjust line/column relative to section offset)
|
|
286
|
+
const relLine = line - targetSection.offset.line;
|
|
287
|
+
const relCol =
|
|
288
|
+
line === targetSection.offset.line
|
|
289
|
+
? column - targetSection.offset.column
|
|
290
|
+
: column;
|
|
291
|
+
|
|
292
|
+
return resolveInBasicMap(
|
|
293
|
+
targetSection.map as BasicSourceMap,
|
|
294
|
+
relLine,
|
|
295
|
+
relCol
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Handle basic source maps
|
|
300
|
+
return resolveInBasicMap(sourceMap as BasicSourceMap, line, column);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Given a React 19 fiber's _debugStack, resolve the source location
|
|
305
|
+
* by parsing the stack trace and looking up source maps.
|
|
306
|
+
*/
|
|
307
|
+
export async function resolveSourceFromDebugStack(
|
|
308
|
+
debugStack: { stack?: string }
|
|
309
|
+
): Promise<Source | null> {
|
|
310
|
+
if (!debugStack?.stack) return null;
|
|
311
|
+
|
|
312
|
+
const parsed = parseDebugStack(debugStack.stack);
|
|
313
|
+
if (!parsed) return null;
|
|
314
|
+
|
|
315
|
+
return resolveSourceLocation(parsed.url, parsed.line, parsed.column);
|
|
316
|
+
}
|
package/src/browserApi.ts
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
formatAncestryChain,
|
|
6
6
|
AncestryItem,
|
|
7
7
|
} from "./functions/formatAncestryChain";
|
|
8
|
+
import { enrichAncestryWithSourceMaps } from "./functions/enrichAncestrySourceMaps";
|
|
8
9
|
|
|
9
10
|
export interface LocatorJSAPI {
|
|
10
11
|
/**
|
|
@@ -33,7 +34,7 @@ export interface LocatorJSAPI {
|
|
|
33
34
|
* });
|
|
34
35
|
* console.log(path);
|
|
35
36
|
*/
|
|
36
|
-
getPath(elementOrSelector: HTMLElement | string): string | null
|
|
37
|
+
getPath(elementOrSelector: HTMLElement | string): Promise<string | null>;
|
|
37
38
|
|
|
38
39
|
/**
|
|
39
40
|
* Get raw ancestry data for an element.
|
|
@@ -61,7 +62,7 @@ export interface LocatorJSAPI {
|
|
|
61
62
|
* return ancestry?.map(item => item.componentName).filter(Boolean);
|
|
62
63
|
* });
|
|
63
64
|
*/
|
|
64
|
-
getAncestry(elementOrSelector: HTMLElement | string): AncestryItem[] | null
|
|
65
|
+
getAncestry(elementOrSelector: HTMLElement | string): Promise<AncestryItem[] | null>;
|
|
65
66
|
|
|
66
67
|
/**
|
|
67
68
|
* Get both formatted path and raw ancestry data in a single call.
|
|
@@ -88,7 +89,7 @@ export interface LocatorJSAPI {
|
|
|
88
89
|
*/
|
|
89
90
|
getPathData(
|
|
90
91
|
elementOrSelector: HTMLElement | string
|
|
91
|
-
): { path: string; ancestry: AncestryItem[] } | null
|
|
92
|
+
): Promise<{ path: string; ancestry: AncestryItem[] } | null>;
|
|
92
93
|
|
|
93
94
|
/**
|
|
94
95
|
* Display help information about the LocatorJS API.
|
|
@@ -128,6 +129,14 @@ function getAncestryForElement(element: HTMLElement): AncestryItem[] | null {
|
|
|
128
129
|
return collectAncestry(treeNode);
|
|
129
130
|
}
|
|
130
131
|
|
|
132
|
+
async function getEnrichedAncestryForElement(
|
|
133
|
+
element: HTMLElement
|
|
134
|
+
): Promise<AncestryItem[] | null> {
|
|
135
|
+
const ancestry = getAncestryForElement(element);
|
|
136
|
+
if (!ancestry) return null;
|
|
137
|
+
return enrichAncestryWithSourceMaps(ancestry, element);
|
|
138
|
+
}
|
|
139
|
+
|
|
131
140
|
const HELP_TEXT = `
|
|
132
141
|
╔═══════════════════════════════════════════════════════════════════════════╗
|
|
133
142
|
║ TreeLocatorJS Browser API ║
|
|
@@ -233,46 +242,39 @@ export function createBrowserAPI(
|
|
|
233
242
|
adapterId = adapterIdParam;
|
|
234
243
|
|
|
235
244
|
return {
|
|
236
|
-
getPath(elementOrSelector: HTMLElement | string): string | null {
|
|
245
|
+
getPath(elementOrSelector: HTMLElement | string): Promise<string | null> {
|
|
237
246
|
const element = resolveElement(elementOrSelector);
|
|
238
247
|
if (!element) {
|
|
239
|
-
return null;
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
const ancestry = getAncestryForElement(element);
|
|
243
|
-
if (!ancestry) {
|
|
244
|
-
return null;
|
|
248
|
+
return Promise.resolve(null);
|
|
245
249
|
}
|
|
246
250
|
|
|
247
|
-
return
|
|
251
|
+
return getEnrichedAncestryForElement(element).then((ancestry) =>
|
|
252
|
+
ancestry ? formatAncestryChain(ancestry) : null
|
|
253
|
+
);
|
|
248
254
|
},
|
|
249
255
|
|
|
250
|
-
getAncestry(
|
|
256
|
+
getAncestry(
|
|
257
|
+
elementOrSelector: HTMLElement | string
|
|
258
|
+
): Promise<AncestryItem[] | null> {
|
|
251
259
|
const element = resolveElement(elementOrSelector);
|
|
252
260
|
if (!element) {
|
|
253
|
-
return null;
|
|
261
|
+
return Promise.resolve(null);
|
|
254
262
|
}
|
|
255
263
|
|
|
256
|
-
return
|
|
264
|
+
return getEnrichedAncestryForElement(element);
|
|
257
265
|
},
|
|
258
266
|
|
|
259
267
|
getPathData(
|
|
260
268
|
elementOrSelector: HTMLElement | string
|
|
261
|
-
): { path: string; ancestry: AncestryItem[] } | null {
|
|
269
|
+
): Promise<{ path: string; ancestry: AncestryItem[] } | null> {
|
|
262
270
|
const element = resolveElement(elementOrSelector);
|
|
263
271
|
if (!element) {
|
|
264
|
-
return null;
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
const ancestry = getAncestryForElement(element);
|
|
268
|
-
if (!ancestry) {
|
|
269
|
-
return null;
|
|
272
|
+
return Promise.resolve(null);
|
|
270
273
|
}
|
|
271
274
|
|
|
272
|
-
return
|
|
273
|
-
path: formatAncestryChain(ancestry),
|
|
274
|
-
|
|
275
|
-
};
|
|
275
|
+
return getEnrichedAncestryForElement(element).then((ancestry) =>
|
|
276
|
+
ancestry ? { path: formatAncestryChain(ancestry), ancestry } : null
|
|
277
|
+
);
|
|
276
278
|
},
|
|
277
279
|
|
|
278
280
|
help(): string {
|
|
@@ -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.getAttribute('class') ? `.${props.currentElement.getAttribute('class')!.split(" ")[0]}` : ""}
|
|
53
112
|
</div>
|
|
54
113
|
</div>
|
|
55
114
|
)}
|
|
@@ -8,6 +8,7 @@ import { MaybeOutline } from "./MaybeOutline";
|
|
|
8
8
|
import { isLocatorsOwnElement } from "../functions/isLocatorsOwnElement";
|
|
9
9
|
import { Toast } from "./Toast";
|
|
10
10
|
import { collectAncestry, formatAncestryChain } from "../functions/formatAncestryChain";
|
|
11
|
+
import { enrichAncestryWithSourceMaps } from "../functions/enrichAncestrySourceMaps";
|
|
11
12
|
import { createTreeNode } from "../adapters/createTreeNode";
|
|
12
13
|
import treeIconUrl from "../_generated_tree_icon";
|
|
13
14
|
|
|
@@ -153,10 +154,24 @@ function Runtime(props: RuntimeProps) {
|
|
|
153
154
|
const treeNode = createTreeNode(element as HTMLElement, props.adapterId);
|
|
154
155
|
if (treeNode) {
|
|
155
156
|
const ancestry = collectAncestry(treeNode);
|
|
157
|
+
|
|
158
|
+
// Write immediately with component names (preserves user gesture for clipboard API)
|
|
156
159
|
const formatted = formatAncestryChain(ancestry);
|
|
157
160
|
navigator.clipboard.writeText(formatted).then(() => {
|
|
158
161
|
setToastMessage("Copied to clipboard");
|
|
159
162
|
});
|
|
163
|
+
|
|
164
|
+
// For React 19+: try to enrich with source map file paths and re-copy
|
|
165
|
+
enrichAncestryWithSourceMaps(ancestry, element as HTMLElement).then(
|
|
166
|
+
(enriched) => {
|
|
167
|
+
const enrichedFormatted = formatAncestryChain(enriched);
|
|
168
|
+
if (enrichedFormatted !== formatted) {
|
|
169
|
+
navigator.clipboard.writeText(enrichedFormatted).then(() => {
|
|
170
|
+
setToastMessage("Copied to clipboard");
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
);
|
|
160
175
|
}
|
|
161
176
|
|
|
162
177
|
// Deactivate toggle after click
|