@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.
- package/.turbo/turbo-build.log +15 -13
- package/.turbo/turbo-dev.log +32 -0
- package/.turbo/turbo-test.log +54 -10
- package/dist/adapters/adapterApi.d.ts +1 -1
- package/dist/adapters/createTreeNode.js +32 -4
- package/dist/adapters/jsx/getExpressionData.d.ts +1 -1
- package/dist/adapters/jsx/getExpressionData.js +8 -4
- package/dist/adapters/jsx/getJSXComponentBoundingBox.d.ts +1 -1
- package/dist/adapters/jsx/getJSXComponentBoundingBox.js +11 -6
- package/dist/adapters/jsx/jsxAdapter.js +13 -8
- 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/components/Runtime.js +1 -1
- 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/adapterApi.ts +1 -1
- package/src/adapters/createTreeNode.ts +35 -3
- package/src/adapters/jsx/getExpressionData.ts +9 -5
- package/src/adapters/jsx/getJSXComponentBoundingBox.ts +13 -8
- package/src/adapters/jsx/jsxAdapter.ts +14 -12
- 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/components/Runtime.tsx +3 -1
- 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
|
@@ -2,12 +2,16 @@ import type { ExpressionInfo, FileStorage } from "@locator/shared";
|
|
|
2
2
|
import { parseDataId, parseDataPath } from "../../functions/parseDataId";
|
|
3
3
|
|
|
4
4
|
export function getExpressionData(
|
|
5
|
-
target:
|
|
5
|
+
target: Element,
|
|
6
6
|
fileData: FileStorage | null
|
|
7
7
|
): ExpressionInfo | null {
|
|
8
|
+
// Use getAttribute instead of dataset to support both HTML and SVG elements
|
|
9
|
+
const dataLocatorjs = target.getAttribute("data-locatorjs");
|
|
10
|
+
const dataLocatorjsId = target.getAttribute("data-locatorjs-id");
|
|
11
|
+
|
|
8
12
|
// First check for data-locatorjs (path-based, for server components)
|
|
9
|
-
if (
|
|
10
|
-
const parsed = parseDataPath(
|
|
13
|
+
if (dataLocatorjs) {
|
|
14
|
+
const parsed = parseDataPath(dataLocatorjs);
|
|
11
15
|
if (parsed) {
|
|
12
16
|
const [, line, column] = parsed;
|
|
13
17
|
|
|
@@ -35,8 +39,8 @@ export function getExpressionData(
|
|
|
35
39
|
}
|
|
36
40
|
|
|
37
41
|
// Fall back to data-locatorjs-id (ID-based, traditional approach)
|
|
38
|
-
if (
|
|
39
|
-
const [, id] = parseDataId(
|
|
42
|
+
if (dataLocatorjsId && fileData) {
|
|
43
|
+
const [, id] = parseDataId(dataLocatorjsId);
|
|
40
44
|
const expData = fileData.expressions[Number(id)];
|
|
41
45
|
if (expData) {
|
|
42
46
|
return expData;
|
|
@@ -5,32 +5,37 @@ import type { SimpleDOMRect } from "../../types/types";
|
|
|
5
5
|
import { getExpressionData } from "./getExpressionData";
|
|
6
6
|
|
|
7
7
|
export function getJSXComponentBoundingBox(
|
|
8
|
-
found:
|
|
8
|
+
found: Element,
|
|
9
9
|
locatorData: { [filename: string]: FileStorage },
|
|
10
10
|
componentFolder: string,
|
|
11
11
|
componentId: number
|
|
12
12
|
): SimpleDOMRect {
|
|
13
13
|
let composedBox: SimpleDOMRect = found.getBoundingClientRect();
|
|
14
14
|
// Currently it works well only for components with one root element, but for components with multiple root elements we would need to track instance ids.
|
|
15
|
-
function goParent(current:
|
|
15
|
+
function goParent(current: Element) {
|
|
16
16
|
const parent = current.parentNode;
|
|
17
17
|
if (!parent) {
|
|
18
18
|
return;
|
|
19
19
|
}
|
|
20
|
-
|
|
20
|
+
// Support both HTMLElement and SVGElement
|
|
21
|
+
if (parent instanceof HTMLElement || parent instanceof SVGElement) {
|
|
22
|
+
// Use getAttribute instead of dataset to support both HTML and SVG elements
|
|
23
|
+
const dataLocatorjs = parent.getAttribute("data-locatorjs");
|
|
24
|
+
const dataLocatorjsId = parent.getAttribute("data-locatorjs-id");
|
|
25
|
+
|
|
21
26
|
// Check for either data-locatorjs (path-based) or data-locatorjs-id (ID-based)
|
|
22
|
-
if (
|
|
27
|
+
if (dataLocatorjs || dataLocatorjsId) {
|
|
23
28
|
let fileFullPath: string;
|
|
24
29
|
|
|
25
|
-
if (
|
|
26
|
-
const parsed = parseDataPath(
|
|
30
|
+
if (dataLocatorjs) {
|
|
31
|
+
const parsed = parseDataPath(dataLocatorjs);
|
|
27
32
|
if (!parsed) {
|
|
28
33
|
goParent(parent);
|
|
29
34
|
return;
|
|
30
35
|
}
|
|
31
36
|
[fileFullPath] = parsed;
|
|
32
|
-
} else if (
|
|
33
|
-
[fileFullPath] = parseDataId(
|
|
37
|
+
} else if (dataLocatorjsId) {
|
|
38
|
+
[fileFullPath] = parseDataId(dataLocatorjsId);
|
|
34
39
|
} else {
|
|
35
40
|
goParent(parent);
|
|
36
41
|
return;
|
|
@@ -20,17 +20,17 @@ import { getJSXComponentBoundingBox } from "./getJSXComponentBoundingBox";
|
|
|
20
20
|
export function getElementInfo(target: HTMLElement): FullElementInfo | null {
|
|
21
21
|
const found = target.closest("[data-locatorjs-id], [data-locatorjs]");
|
|
22
22
|
|
|
23
|
+
// Support both HTMLElement and SVGElement
|
|
24
|
+
// SVG elements don't have dataset, so use getAttribute instead
|
|
25
|
+
const dataId = found?.getAttribute("data-locatorjs-id");
|
|
26
|
+
const dataPath = found?.getAttribute("data-locatorjs");
|
|
27
|
+
const styledDataId = found?.getAttribute("data-locatorjs-styled");
|
|
28
|
+
|
|
23
29
|
if (
|
|
24
30
|
found &&
|
|
25
|
-
found instanceof HTMLElement &&
|
|
26
|
-
|
|
27
|
-
(found.dataset.locatorjsId ||
|
|
28
|
-
found.dataset.locatorjs ||
|
|
29
|
-
found.dataset.locatorjsStyled)
|
|
31
|
+
(found instanceof HTMLElement || found instanceof SVGElement) &&
|
|
32
|
+
(dataId || dataPath || styledDataId)
|
|
30
33
|
) {
|
|
31
|
-
const dataId = found.dataset.locatorjsId;
|
|
32
|
-
const dataPath = found.dataset.locatorjs;
|
|
33
|
-
const styledDataId = found.dataset.locatorjsStyled;
|
|
34
34
|
|
|
35
35
|
if (!dataId && !dataPath) {
|
|
36
36
|
return null;
|
|
@@ -135,8 +135,9 @@ export function getElementInfo(target: HTMLElement): FullElementInfo | null {
|
|
|
135
135
|
|
|
136
136
|
export class JSXTreeNodeElement extends HtmlElementTreeNode {
|
|
137
137
|
getSource(): Source | null {
|
|
138
|
-
|
|
139
|
-
const
|
|
138
|
+
// Use getAttribute instead of dataset to support both HTML and SVG elements
|
|
139
|
+
const dataId = this.element.getAttribute("data-locatorjs-id");
|
|
140
|
+
const dataPath = this.element.getAttribute("data-locatorjs");
|
|
140
141
|
|
|
141
142
|
if (!dataId && !dataPath) {
|
|
142
143
|
return null;
|
|
@@ -184,8 +185,9 @@ export class JSXTreeNodeElement extends HtmlElementTreeNode {
|
|
|
184
185
|
return null;
|
|
185
186
|
}
|
|
186
187
|
getComponent(): TreeNodeComponent | null {
|
|
187
|
-
|
|
188
|
-
const
|
|
188
|
+
// Use getAttribute instead of dataset to support both HTML and SVG elements
|
|
189
|
+
const dataId = this.element.getAttribute("data-locatorjs-id");
|
|
190
|
+
const dataPath = this.element.getAttribute("data-locatorjs");
|
|
189
191
|
|
|
190
192
|
if (!dataId && !dataPath) {
|
|
191
193
|
return null;
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { ServerComponentInfo } from "../../types/ServerComponentInfo";
|
|
2
|
+
import { normalizeFilePath } from "../../functions/normalizeFilePath";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Parse Next.js server component data from data-locatorjs attribute.
|
|
6
|
+
*
|
|
7
|
+
* Format: data-locatorjs="/path/to/app/layout.tsx:27:4"
|
|
8
|
+
*
|
|
9
|
+
* The @treelocator/webpack-loader adds these attributes to elements
|
|
10
|
+
* rendered by Next.js Server Components.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Extract component name from file path.
|
|
15
|
+
* Examples:
|
|
16
|
+
* - "/apps/next-16/app/layout.tsx:27:4" → "RootLayout"
|
|
17
|
+
* - "/apps/next-16/app/page.tsx:5:4" → "Home"
|
|
18
|
+
* - "/apps/next-16/app/components/Header.tsx:10:2" → "Header"
|
|
19
|
+
*/
|
|
20
|
+
function extractComponentName(filePath: string): string {
|
|
21
|
+
// Remove line:column suffix
|
|
22
|
+
const pathOnly = filePath.split(":")[0] || filePath;
|
|
23
|
+
|
|
24
|
+
// Get filename without extension
|
|
25
|
+
const fileName = pathOnly.split("/").pop()?.replace(/\.(tsx?|jsx?)$/, "") || "Unknown";
|
|
26
|
+
|
|
27
|
+
// Common Next.js conventions:
|
|
28
|
+
// - "layout" → "RootLayout" or "Layout"
|
|
29
|
+
// - "page" → Component name (we don't know it, so use "Page")
|
|
30
|
+
// - Others → Use as-is
|
|
31
|
+
if (fileName === "layout") {
|
|
32
|
+
return "RootLayout";
|
|
33
|
+
} else if (fileName === "page") {
|
|
34
|
+
return "Page";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return fileName;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Parse a data-locatorjs attribute value.
|
|
42
|
+
* Format: "/path/to/file.tsx:line:column"
|
|
43
|
+
* Returns ServerComponentInfo or null if parsing fails.
|
|
44
|
+
*/
|
|
45
|
+
function parseDataLocatorjsValue(value: string): ServerComponentInfo | null {
|
|
46
|
+
if (!value) return null;
|
|
47
|
+
|
|
48
|
+
// Split by ":" to get [filePath, line, column]
|
|
49
|
+
const parts = value.split(":");
|
|
50
|
+
if (parts.length < 2) return null;
|
|
51
|
+
|
|
52
|
+
// Last two parts are column and line (in reverse order)
|
|
53
|
+
const column = parts.pop();
|
|
54
|
+
const line = parts.pop();
|
|
55
|
+
|
|
56
|
+
// Everything else is the file path (which may contain colons on Windows)
|
|
57
|
+
const filePath = parts.join(":");
|
|
58
|
+
|
|
59
|
+
if (!filePath || !line) return null;
|
|
60
|
+
|
|
61
|
+
const componentName = extractComponentName(filePath);
|
|
62
|
+
const normalizedPath = normalizeFilePath(filePath);
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
name: componentName,
|
|
66
|
+
filePath: normalizedPath,
|
|
67
|
+
line: parseInt(line, 10),
|
|
68
|
+
type: "component",
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Get the data-locatorjs attribute from the current element only.
|
|
74
|
+
* Returns array with single component info, or empty array if not found.
|
|
75
|
+
*
|
|
76
|
+
* We only look at the current element because the tree structure already
|
|
77
|
+
* shows the hierarchy - each parent element will have its own server component.
|
|
78
|
+
*
|
|
79
|
+
* Example DOM:
|
|
80
|
+
* ```html
|
|
81
|
+
* <html data-locatorjs="/app/layout.tsx:27:4"> <!-- RootLayout -->
|
|
82
|
+
* <body data-locatorjs="/app/layout.tsx:28:6"> <!-- RootLayout -->
|
|
83
|
+
* <div data-locatorjs="/app/page.tsx:5:4"> <!-- Page -->
|
|
84
|
+
* <button>Click</button>
|
|
85
|
+
* </div>
|
|
86
|
+
* </body>
|
|
87
|
+
* </html>
|
|
88
|
+
* ```
|
|
89
|
+
*
|
|
90
|
+
* For the div, returns: [{ name: "Page", filePath: "/app/page.tsx", line: 5 }]
|
|
91
|
+
* The tree structure will show: html (RootLayout) > body (RootLayout) > div (Page)
|
|
92
|
+
*/
|
|
93
|
+
export function collectNextjsServerComponents(element: Element): ServerComponentInfo[] {
|
|
94
|
+
const value = element.getAttribute("data-locatorjs");
|
|
95
|
+
if (!value) return [];
|
|
96
|
+
|
|
97
|
+
const info = parseDataLocatorjsValue(value);
|
|
98
|
+
return info ? [info] : [];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Main entry point: extract Next.js server component info from element.
|
|
103
|
+
* Returns null if no data-locatorjs attribute found.
|
|
104
|
+
*
|
|
105
|
+
* This function is called during ancestry collection to enrich each AncestryItem
|
|
106
|
+
* with server-side Next.js component information.
|
|
107
|
+
*/
|
|
108
|
+
export function parseNextjsServerComponents(element: Element): ServerComponentInfo[] | null {
|
|
109
|
+
const components = collectNextjsServerComponents(element);
|
|
110
|
+
if (components.length === 0) return null;
|
|
111
|
+
return components;
|
|
112
|
+
}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect, beforeEach } from "vitest";
|
|
6
|
+
import {
|
|
7
|
+
findPrecedingPhoenixComments,
|
|
8
|
+
phoenixMatchesToServerComponents,
|
|
9
|
+
parsePhoenixServerComponents,
|
|
10
|
+
} from "../parsePhoenixComments";
|
|
11
|
+
|
|
12
|
+
describe("parsePhoenixComments", () => {
|
|
13
|
+
let container: HTMLDivElement;
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
container = document.createElement("div");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe("findPrecedingPhoenixComments", () => {
|
|
20
|
+
it("parses @caller comment", () => {
|
|
21
|
+
container.innerHTML = `
|
|
22
|
+
<!-- @caller lib/app_web/home_live.ex:20 -->
|
|
23
|
+
<header>Content</header>
|
|
24
|
+
`;
|
|
25
|
+
|
|
26
|
+
const header = container.querySelector("header")!;
|
|
27
|
+
const matches = findPrecedingPhoenixComments(header);
|
|
28
|
+
|
|
29
|
+
expect(matches).toHaveLength(1);
|
|
30
|
+
expect(matches[0]).toMatchObject({
|
|
31
|
+
name: "@caller",
|
|
32
|
+
filePath: "lib/app_web/home_live.ex",
|
|
33
|
+
line: 20,
|
|
34
|
+
type: "caller",
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("parses component comment", () => {
|
|
39
|
+
container.innerHTML = `
|
|
40
|
+
<!-- <AppWeb.CoreComponents.header> lib/app_web/core_components.ex:123 -->
|
|
41
|
+
<header>Content</header>
|
|
42
|
+
`;
|
|
43
|
+
|
|
44
|
+
const header = container.querySelector("header")!;
|
|
45
|
+
const matches = findPrecedingPhoenixComments(header);
|
|
46
|
+
|
|
47
|
+
expect(matches).toHaveLength(1);
|
|
48
|
+
expect(matches[0]).toMatchObject({
|
|
49
|
+
name: "AppWeb.CoreComponents.header",
|
|
50
|
+
filePath: "lib/app_web/core_components.ex",
|
|
51
|
+
line: 123,
|
|
52
|
+
type: "component",
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("ignores closing tag comments", () => {
|
|
57
|
+
container.innerHTML = `
|
|
58
|
+
<!-- </AppWeb.CoreComponents.header> -->
|
|
59
|
+
<header>Content</header>
|
|
60
|
+
`;
|
|
61
|
+
|
|
62
|
+
const header = container.querySelector("header")!;
|
|
63
|
+
const matches = findPrecedingPhoenixComments(header);
|
|
64
|
+
|
|
65
|
+
expect(matches).toHaveLength(0);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("finds multiple preceding comments in correct order", () => {
|
|
69
|
+
container.innerHTML = `
|
|
70
|
+
<!-- @caller lib/app_web/home_live.ex:20 -->
|
|
71
|
+
<!-- <AppWeb.CoreComponents.header> lib/app_web/core_components.ex:123 -->
|
|
72
|
+
<header>Content</header>
|
|
73
|
+
`;
|
|
74
|
+
|
|
75
|
+
const header = container.querySelector("header")!;
|
|
76
|
+
const matches = findPrecedingPhoenixComments(header);
|
|
77
|
+
|
|
78
|
+
expect(matches).toHaveLength(2);
|
|
79
|
+
// Should be ordered from outermost to innermost
|
|
80
|
+
expect(matches[0]!.name).toBe("@caller");
|
|
81
|
+
expect(matches[0]!.line).toBe(20);
|
|
82
|
+
expect(matches[1]!.name).toBe("AppWeb.CoreComponents.header");
|
|
83
|
+
expect(matches[1]!.line).toBe(123);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("stops at non-comment element node", () => {
|
|
87
|
+
container.innerHTML = `
|
|
88
|
+
<div>Other element</div>
|
|
89
|
+
<!-- <AppWeb.CoreComponents.header> lib/app_web/core_components.ex:123 -->
|
|
90
|
+
<header>Content</header>
|
|
91
|
+
`;
|
|
92
|
+
|
|
93
|
+
const header = container.querySelector("header")!;
|
|
94
|
+
const matches = findPrecedingPhoenixComments(header);
|
|
95
|
+
|
|
96
|
+
// Should only find the comment between the div and header
|
|
97
|
+
expect(matches).toHaveLength(1);
|
|
98
|
+
expect(matches[0]!.name).toBe("AppWeb.CoreComponents.header");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("skips whitespace text nodes", () => {
|
|
102
|
+
container.innerHTML = `
|
|
103
|
+
<!-- @caller lib/app_web/home_live.ex:20 -->
|
|
104
|
+
|
|
105
|
+
<header>Content</header>
|
|
106
|
+
`;
|
|
107
|
+
|
|
108
|
+
const header = container.querySelector("header")!;
|
|
109
|
+
const matches = findPrecedingPhoenixComments(header);
|
|
110
|
+
|
|
111
|
+
// Should find the comment despite whitespace text node
|
|
112
|
+
expect(matches).toHaveLength(1);
|
|
113
|
+
expect(matches[0]!.name).toBe("@caller");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("stops at non-whitespace text node", () => {
|
|
117
|
+
container.innerHTML = `
|
|
118
|
+
<!-- @caller lib/app_web/home_live.ex:20 -->
|
|
119
|
+
Some text
|
|
120
|
+
<header>Content</header>
|
|
121
|
+
`;
|
|
122
|
+
|
|
123
|
+
const header = container.querySelector("header")!;
|
|
124
|
+
const matches = findPrecedingPhoenixComments(header);
|
|
125
|
+
|
|
126
|
+
// Should stop at the text node, not finding the comment
|
|
127
|
+
expect(matches).toHaveLength(0);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("returns empty array if no preceding comments", () => {
|
|
131
|
+
container.innerHTML = `<header>Content</header>`;
|
|
132
|
+
|
|
133
|
+
const header = container.querySelector("header")!;
|
|
134
|
+
const matches = findPrecedingPhoenixComments(header);
|
|
135
|
+
|
|
136
|
+
expect(matches).toHaveLength(0);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("ignores non-Phoenix comments", () => {
|
|
140
|
+
container.innerHTML = `
|
|
141
|
+
<!-- Regular HTML comment -->
|
|
142
|
+
<header>Content</header>
|
|
143
|
+
`;
|
|
144
|
+
|
|
145
|
+
const header = container.querySelector("header")!;
|
|
146
|
+
const matches = findPrecedingPhoenixComments(header);
|
|
147
|
+
|
|
148
|
+
expect(matches).toHaveLength(0);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("finds Phoenix comments and ignores non-Phoenix comments", () => {
|
|
152
|
+
container.innerHTML = `
|
|
153
|
+
<!-- Regular comment -->
|
|
154
|
+
<!-- @caller lib/app_web/home_live.ex:20 -->
|
|
155
|
+
<!-- Another regular comment -->
|
|
156
|
+
<!-- <AppWeb.CoreComponents.header> lib/app_web/core_components.ex:123 -->
|
|
157
|
+
<header>Content</header>
|
|
158
|
+
`;
|
|
159
|
+
|
|
160
|
+
const header = container.querySelector("header")!;
|
|
161
|
+
const matches = findPrecedingPhoenixComments(header);
|
|
162
|
+
|
|
163
|
+
// Should only find the 2 Phoenix comments
|
|
164
|
+
expect(matches).toHaveLength(2);
|
|
165
|
+
expect(matches[0]!.name).toBe("@caller");
|
|
166
|
+
expect(matches[1]!.name).toBe("AppWeb.CoreComponents.header");
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
describe("phoenixMatchesToServerComponents", () => {
|
|
171
|
+
it("converts matches to ServerComponentInfo format", () => {
|
|
172
|
+
container.innerHTML = `
|
|
173
|
+
<!-- @caller lib/app_web/home_live.ex:20 -->
|
|
174
|
+
<!-- <AppWeb.CoreComponents.button> lib/app_web/core_components.ex:456 -->
|
|
175
|
+
<button>Click</button>
|
|
176
|
+
`;
|
|
177
|
+
|
|
178
|
+
const button = container.querySelector("button")!;
|
|
179
|
+
const matches = findPrecedingPhoenixComments(button);
|
|
180
|
+
const serverComponents = phoenixMatchesToServerComponents(matches);
|
|
181
|
+
|
|
182
|
+
expect(serverComponents).toHaveLength(2);
|
|
183
|
+
expect(serverComponents[0]).toEqual({
|
|
184
|
+
name: "@caller",
|
|
185
|
+
filePath: "lib/app_web/home_live.ex",
|
|
186
|
+
line: 20,
|
|
187
|
+
type: "caller",
|
|
188
|
+
});
|
|
189
|
+
expect(serverComponents[1]).toEqual({
|
|
190
|
+
name: "AppWeb.CoreComponents.button",
|
|
191
|
+
filePath: "lib/app_web/core_components.ex",
|
|
192
|
+
line: 456,
|
|
193
|
+
type: "component",
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
describe("parsePhoenixServerComponents", () => {
|
|
199
|
+
it("returns ServerComponentInfo array when comments found", () => {
|
|
200
|
+
container.innerHTML = `
|
|
201
|
+
<!-- @caller lib/app_web/home_live.ex:48 -->
|
|
202
|
+
<!-- <AppWeb.CoreComponents.button> lib/app_web/core_components.ex:456 -->
|
|
203
|
+
<button data-phx-loc="458">Click Me</button>
|
|
204
|
+
`;
|
|
205
|
+
|
|
206
|
+
const button = container.querySelector("button")!;
|
|
207
|
+
const result = parsePhoenixServerComponents(button);
|
|
208
|
+
|
|
209
|
+
expect(result).not.toBeNull();
|
|
210
|
+
expect(result!).toHaveLength(2);
|
|
211
|
+
expect(result![0]).toEqual({
|
|
212
|
+
name: "@caller",
|
|
213
|
+
filePath: "lib/app_web/home_live.ex",
|
|
214
|
+
line: 48,
|
|
215
|
+
type: "caller",
|
|
216
|
+
});
|
|
217
|
+
expect(result![1]).toEqual({
|
|
218
|
+
name: "AppWeb.CoreComponents.button",
|
|
219
|
+
filePath: "lib/app_web/core_components.ex",
|
|
220
|
+
line: 456,
|
|
221
|
+
type: "component",
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("returns null when no comments found", () => {
|
|
226
|
+
container.innerHTML = `<button>Click Me</button>`;
|
|
227
|
+
|
|
228
|
+
const button = container.querySelector("button")!;
|
|
229
|
+
const result = parsePhoenixServerComponents(button);
|
|
230
|
+
|
|
231
|
+
expect(result).toBeNull();
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("handles nested structure with multiple components", () => {
|
|
235
|
+
container.innerHTML = `
|
|
236
|
+
<!-- @caller lib/app_web/home_live.ex:20 -->
|
|
237
|
+
<!-- <AppWeb.CoreComponents.header> lib/app_web/core_components.ex:123 -->
|
|
238
|
+
<header data-phx-loc="125" class="p-5">
|
|
239
|
+
<!-- @caller lib/app_web/home_live.ex:48 -->
|
|
240
|
+
<!-- <AppWeb.CoreComponents.button> lib/app_web/core_components.ex:456 -->
|
|
241
|
+
<button data-phx-loc="458" class="px-2">Click</button>
|
|
242
|
+
</header>
|
|
243
|
+
`;
|
|
244
|
+
|
|
245
|
+
const header = container.querySelector("header")!;
|
|
246
|
+
const headerResult = parsePhoenixServerComponents(header);
|
|
247
|
+
|
|
248
|
+
expect(headerResult).not.toBeNull();
|
|
249
|
+
expect(headerResult!).toHaveLength(2);
|
|
250
|
+
expect(headerResult![0]!.name).toBe("@caller");
|
|
251
|
+
expect(headerResult![0]!.line).toBe(20);
|
|
252
|
+
expect(headerResult![1]!.name).toBe("AppWeb.CoreComponents.header");
|
|
253
|
+
|
|
254
|
+
const button = container.querySelector("button")!;
|
|
255
|
+
const buttonResult = parsePhoenixServerComponents(button);
|
|
256
|
+
|
|
257
|
+
expect(buttonResult).not.toBeNull();
|
|
258
|
+
expect(buttonResult!).toHaveLength(2);
|
|
259
|
+
expect(buttonResult![0]!.name).toBe("@caller");
|
|
260
|
+
expect(buttonResult![0]!.line).toBe(48);
|
|
261
|
+
expect(buttonResult![1]!.name).toBe("AppWeb.CoreComponents.button");
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
});
|
|
@@ -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";
|