@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.
Files changed (58) hide show
  1. package/dist/adapters/createTreeNode.js +41 -4
  2. package/dist/adapters/nextjs/parseNextjsDataAttributes.d.ts +31 -0
  3. package/dist/adapters/nextjs/parseNextjsDataAttributes.js +106 -0
  4. package/dist/adapters/phoenix/__tests__/parsePhoenixComments.test.d.ts +4 -0
  5. package/dist/adapters/phoenix/__tests__/parsePhoenixComments.test.js +218 -0
  6. package/dist/adapters/phoenix/detectPhoenix.d.ts +11 -0
  7. package/dist/adapters/phoenix/detectPhoenix.js +38 -0
  8. package/dist/adapters/phoenix/index.d.ts +10 -0
  9. package/dist/adapters/phoenix/index.js +9 -0
  10. package/dist/adapters/phoenix/parsePhoenixComments.d.ts +35 -0
  11. package/dist/adapters/phoenix/parsePhoenixComments.js +131 -0
  12. package/dist/adapters/phoenix/types.d.ts +16 -0
  13. package/dist/adapters/phoenix/types.js +1 -0
  14. package/dist/adapters/react/findDebugSource.d.ts +13 -0
  15. package/dist/adapters/react/findDebugSource.js +37 -0
  16. package/dist/adapters/react/findFiberByHtmlElement.js +23 -1
  17. package/dist/adapters/react/getFiberLabel.js +2 -1
  18. package/dist/adapters/react/resolveSourceMap.d.ts +29 -0
  19. package/dist/adapters/react/resolveSourceMap.js +236 -0
  20. package/dist/browserApi.d.ts +4 -4
  21. package/dist/browserApi.js +13 -15
  22. package/dist/components/MaybeOutline.js +65 -3
  23. package/dist/components/Runtime.js +13 -0
  24. package/dist/functions/enrichAncestrySourceMaps.d.ts +7 -0
  25. package/dist/functions/enrichAncestrySourceMaps.js +80 -0
  26. package/dist/functions/formatAncestryChain.d.ts +3 -0
  27. package/dist/functions/formatAncestryChain.js +104 -15
  28. package/dist/functions/formatAncestryChain.test.js +26 -20
  29. package/dist/functions/normalizeFilePath.d.ts +14 -0
  30. package/dist/functions/normalizeFilePath.js +40 -0
  31. package/dist/output.css +87 -15
  32. package/dist/types/ServerComponentInfo.d.ts +14 -0
  33. package/dist/types/ServerComponentInfo.js +1 -0
  34. package/package.json +4 -3
  35. package/src/adapters/createTreeNode.ts +44 -3
  36. package/src/adapters/nextjs/parseNextjsDataAttributes.ts +112 -0
  37. package/src/adapters/phoenix/__tests__/parsePhoenixComments.test.ts +264 -0
  38. package/src/adapters/phoenix/detectPhoenix.ts +44 -0
  39. package/src/adapters/phoenix/index.ts +11 -0
  40. package/src/adapters/phoenix/parsePhoenixComments.ts +140 -0
  41. package/src/adapters/phoenix/types.ts +16 -0
  42. package/src/adapters/react/findDebugSource.ts +40 -0
  43. package/src/adapters/react/findFiberByHtmlElement.ts +26 -1
  44. package/src/adapters/react/getFiberLabel.ts +2 -1
  45. package/src/adapters/react/reactAdapter.ts +2 -1
  46. package/src/adapters/react/resolveSourceMap.ts +316 -0
  47. package/src/browserApi.ts +27 -25
  48. package/src/components/MaybeOutline.tsx +63 -4
  49. package/src/components/Runtime.tsx +15 -0
  50. package/src/functions/enrichAncestrySourceMaps.ts +103 -0
  51. package/src/functions/formatAncestryChain.test.ts +26 -20
  52. package/src/functions/formatAncestryChain.ts +121 -15
  53. package/src/functions/normalizeFilePath.ts +41 -0
  54. package/src/types/ServerComponentInfo.ts +14 -0
  55. package/.turbo/turbo-build.log +0 -30
  56. package/.turbo/turbo-test.log +0 -19
  57. package/.turbo/turbo-ts.log +0 -4
  58. package/LICENSE +0 -22
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@treelocator/runtime",
3
- "version": "0.1.8",
3
+ "version": "0.3.0",
4
4
  "description": "TreeLocatorJS runtime for component ancestry tracking. Alt+click any element to copy its component tree to clipboard. Exposes window.__treelocator__ API for browser automation (Playwright, Puppeteer, Selenium, Cypress).",
5
5
  "keywords": [
6
6
  "locator",
@@ -53,7 +53,8 @@
53
53
  "devDependencies": {
54
54
  "@babel/cli": "^7.25.9",
55
55
  "@babel/core": "^7.26.0",
56
- "@treelocator/dev-config": "^0.1.0",
56
+ "@tailwindcss/forms": "^0.5.11",
57
+ "@treelocator/dev-config": "^0.3.0",
57
58
  "@types/jsdom": "^21.1.7",
58
59
  "babel-preset-solid": "^1.9.2",
59
60
  "concurrently": "^9.1.0",
@@ -72,5 +73,5 @@
72
73
  "directory": "packages/runtime"
73
74
  },
74
75
  "license": "MIT",
75
- "gitHead": "69fb05167bd689b2e3602fcbc74a70912695f136"
76
+ "gitHead": "afc8a534284e818665bb4d03b7fa940ec5ad5880"
76
77
  }
@@ -1,22 +1,63 @@
1
1
  import { TreeNode } from "../types/TreeNode";
2
2
  import { ReactTreeNodeElement } from "./react/reactAdapter";
3
3
  import { JSXTreeNodeElement } from "./jsx/jsxAdapter";
4
+ import { SvelteTreeNodeElement } from "./svelte/svelteAdapter";
5
+ import { VueTreeNodeElement } from "./vue/vueAdapter";
4
6
  import {
5
7
  detectJSX,
6
8
  detectReact,
9
+ detectSvelte,
10
+ detectVue,
7
11
  } from "@locator/shared";
12
+ import { detectPhoenix } from "./phoenix/detectPhoenix";
13
+
14
+ /**
15
+ * Fallback React detection: check if any DOM element has __reactFiber$ keys.
16
+ * Works without React DevTools extension (where detectReact() fails because
17
+ * the renderers Map is empty).
18
+ */
19
+ function hasReactFiberKeys(element: HTMLElement): boolean {
20
+ return Object.keys(element).some((k) => k.startsWith("__reactFiber$"));
21
+ }
8
22
 
9
23
  export function createTreeNode(
10
24
  element: HTMLElement,
11
25
  adapterId?: string
12
26
  ): TreeNode | null {
13
- // Check for React adapter
14
- if (adapterId === "react" || detectReact()) {
27
+ // Check for explicit adapter ID first
28
+ if (adapterId === "react") {
29
+ return new ReactTreeNodeElement(element);
30
+ }
31
+ if (adapterId === "svelte") {
32
+ return new SvelteTreeNodeElement(element);
33
+ }
34
+ if (adapterId === "vue") {
35
+ return new VueTreeNodeElement(element);
36
+ }
37
+ if (adapterId === "jsx") {
38
+ return new JSXTreeNodeElement(element);
39
+ }
40
+
41
+ // Auto-detect framework
42
+ if (detectSvelte()) {
43
+ return new SvelteTreeNodeElement(element);
44
+ }
45
+
46
+ if (detectVue()) {
47
+ return new VueTreeNodeElement(element);
48
+ }
49
+
50
+ if (detectReact() || hasReactFiberKeys(element)) {
15
51
  return new ReactTreeNodeElement(element);
16
52
  }
17
53
 
18
54
  // Check for JSX adapter (babel plugin) - check if element has data-locatorjs-id
19
- if (adapterId === "jsx" || detectJSX() || element.dataset.locatorjsId) {
55
+ if (detectJSX() || element.dataset.locatorjsId) {
56
+ return new JSXTreeNodeElement(element);
57
+ }
58
+
59
+ // Check for Phoenix LiveView (uses JSX adapter as fallback for pure Phoenix apps)
60
+ if (detectPhoenix()) {
20
61
  return new JSXTreeNodeElement(element);
21
62
  }
22
63
 
@@ -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";
@@ -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
+ }