@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.
Files changed (40) hide show
  1. package/.turbo/turbo-build.log +8 -6
  2. package/.turbo/turbo-dev.log +32 -0
  3. package/.turbo/turbo-test.log +54 -10
  4. package/dist/adapters/createTreeNode.js +32 -4
  5. package/dist/adapters/nextjs/parseNextjsDataAttributes.d.ts +31 -0
  6. package/dist/adapters/nextjs/parseNextjsDataAttributes.js +106 -0
  7. package/dist/adapters/phoenix/__tests__/parsePhoenixComments.test.d.ts +4 -0
  8. package/dist/adapters/phoenix/__tests__/parsePhoenixComments.test.js +218 -0
  9. package/dist/adapters/phoenix/detectPhoenix.d.ts +11 -0
  10. package/dist/adapters/phoenix/detectPhoenix.js +38 -0
  11. package/dist/adapters/phoenix/index.d.ts +10 -0
  12. package/dist/adapters/phoenix/index.js +9 -0
  13. package/dist/adapters/phoenix/parsePhoenixComments.d.ts +35 -0
  14. package/dist/adapters/phoenix/parsePhoenixComments.js +131 -0
  15. package/dist/adapters/phoenix/types.d.ts +16 -0
  16. package/dist/adapters/phoenix/types.js +1 -0
  17. package/dist/adapters/react/getFiberLabel.js +2 -1
  18. package/dist/components/MaybeOutline.js +65 -3
  19. package/dist/functions/formatAncestryChain.d.ts +3 -0
  20. package/dist/functions/formatAncestryChain.js +104 -15
  21. package/dist/functions/formatAncestryChain.test.js +26 -20
  22. package/dist/functions/normalizeFilePath.d.ts +14 -0
  23. package/dist/functions/normalizeFilePath.js +40 -0
  24. package/dist/output.css +87 -15
  25. package/dist/types/ServerComponentInfo.d.ts +14 -0
  26. package/dist/types/ServerComponentInfo.js +1 -0
  27. package/package.json +4 -3
  28. package/src/adapters/createTreeNode.ts +35 -3
  29. package/src/adapters/nextjs/parseNextjsDataAttributes.ts +112 -0
  30. package/src/adapters/phoenix/__tests__/parsePhoenixComments.test.ts +264 -0
  31. package/src/adapters/phoenix/detectPhoenix.ts +44 -0
  32. package/src/adapters/phoenix/index.ts +11 -0
  33. package/src/adapters/phoenix/parsePhoenixComments.ts +140 -0
  34. package/src/adapters/phoenix/types.ts +16 -0
  35. package/src/adapters/react/getFiberLabel.ts +2 -1
  36. package/src/components/MaybeOutline.tsx +63 -4
  37. package/src/functions/formatAncestryChain.test.ts +26 -20
  38. package/src/functions/formatAncestryChain.ts +121 -15
  39. package/src/functions/normalizeFilePath.ts +41 -0
  40. package/src/types/ServerComponentInfo.ts +14 -0
@@ -10,21 +10,23 @@
10
10
  [babel] > @treelocator/runtime@0.1.8 build:babel /Users/wende/projects/locatorjs/packages/runtime
11
11
  [babel] > babel src --out-dir dist --extensions .js,.jsx,.ts,.tsx
12
12
  [babel]
13
- [ts]
14
- [ts] > @treelocator/runtime@0.1.8 build:ts /Users/wende/projects/locatorjs/packages/runtime
15
- [ts] > tsc --declaration --emitDeclarationOnly --noEmit false --outDir dist
16
- [ts]
17
13
  [wrapImage]
18
14
  [wrapImage] > @treelocator/runtime@0.1.8 build:wrapImage /Users/wende/projects/locatorjs/packages/runtime
19
15
  [wrapImage] > node ./scripts/wrapImage.js
20
16
  [wrapImage]
17
+ [ts]
18
+ [ts] > @treelocator/runtime@0.1.8 build:ts /Users/wende/projects/locatorjs/packages/runtime
19
+ [ts] > tsc --declaration --emitDeclarationOnly --noEmit false --outDir dist
20
+ [ts]
21
21
  [wrapImage] Tree icon file generated
22
22
  [wrapImage] pnpm run build:wrapImage exited with code 0
23
+ [tailwind] [baseline-browser-mapping] The data in this module is over two months old. To ensure accurate Baseline data, please update: `npm i baseline-browser-mapping@latest -D`
23
24
  [tailwind]
24
25
  [tailwind] Rebuilding...
25
26
  [tailwind]
26
- [tailwind] Done in 164ms.
27
+ [tailwind] Done in 173ms.
27
28
  [tailwind] pnpm run build:tailwind exited with code 0
28
- [babel] Successfully compiled 72 files with Babel (409ms).
29
+ [babel] [baseline-browser-mapping] The data in this module is over two months old. To ensure accurate Baseline data, please update: `npm i baseline-browser-mapping@latest -D`
30
+ [babel] Successfully compiled 80 files with Babel (499ms).
29
31
  [babel] pnpm run build:babel exited with code 0
30
32
  [ts] pnpm run build:ts exited with code 0
@@ -0,0 +1,32 @@
1
+
2
+ > @treelocator/runtime@0.1.8 dev /Users/wende/projects/locatorjs/packages/runtime
3
+ > concurrently pnpm:dev:*
4
+
5
+ [babel]
6
+ [babel] > @treelocator/runtime@0.1.8 dev:babel /Users/wende/projects/locatorjs/packages/runtime
7
+ [babel] > babel src --watch --out-dir dist --extensions .js,.jsx,.ts,.tsx
8
+ [babel]
9
+ [wrapImage]
10
+ [wrapImage] > @treelocator/runtime@0.1.8 dev:wrapImage /Users/wende/projects/locatorjs/packages/runtime
11
+ [wrapImage] > WATCH=true node ./scripts/wrapImage.js
12
+ [wrapImage]
13
+ [ts]
14
+ [ts] > @treelocator/runtime@0.1.8 dev:ts /Users/wende/projects/locatorjs/packages/runtime
15
+ [ts] > tsc --watch --declaration --emitDeclarationOnly --noEmit false --outDir dist --preserveWatchOutput
16
+ [ts]
17
+ [wrapCSS]
18
+ [wrapCSS] > @treelocator/runtime@0.1.8 dev:wrapCSS /Users/wende/projects/locatorjs/packages/runtime
19
+ [wrapCSS] > WATCH=true node ./scripts/wrapCSS.js
20
+ [wrapCSS]
21
+ [tailwind]
22
+ [tailwind] > @treelocator/runtime@0.1.8 dev:tailwind /Users/wende/projects/locatorjs/packages/runtime
23
+ [tailwind] > tailwindcss -i ./src/main.css -o ./dist/output.css --watch
24
+ [tailwind]
25
+ [tailwind]  ELIFECYCLE  Command failed.
26
+ [ts]  ELIFECYCLE  Command failed.
27
+ [wrapImage]  ELIFECYCLE  Command failed.
28
+ [wrapCSS]  ELIFECYCLE  Command failed.
29
+ [babel]  ELIFECYCLE  Command failed.
30
+ [babel] pnpm run dev:babel exited with code SIGINT
31
+ [wrapCSS] pnpm run dev:wrapCSS exited with code SIGINT
32
+ [wrapImage] pnpm run dev:wrapImage exited with code SIGINT
@@ -1,19 +1,63 @@
1
1
 
2
- > @treelocator/runtime@0.1.5 test /Users/wende/projects/locatorjs/packages/runtime
2
+ > @treelocator/runtime@0.1.8 test /Users/wende/projects/locatorjs/packages/runtime
3
3
  > vitest run
4
4
 
5
5
 
6
6
  RUN v2.1.9 /Users/wende/projects/locatorjs/packages/runtime
7
7
 
8
+ ✓ src/functions/evalTemplate.test.ts (1 test) 1ms
9
+ ✓ src/functions/transformPath.test.ts (3 tests) 1ms
8
10
  ✓ src/functions/mergeRects.test.ts (1 test) 2ms
9
- ✓ src/functions/evalTemplate.test.ts (1 test) 2ms
10
- ✓ src/functions/transformPath.test.ts (3 tests) 3ms
11
- src/functions/getUsableFileName.test.tsx (4 tests) 3ms
12
- ✓ src/functions/formatAncestryChain.test.ts (7 tests) 2ms
13
- src/functions/cropPath.test.ts (2 tests) 2ms
11
+ ✓ src/functions/cropPath.test.ts (2 tests) 1ms
12
+ ✓ src/functions/getUsableFileName.test.tsx (4 tests) 4ms
13
+ src/functions/formatAncestryChain.test.ts (9 tests | 2 failed) 7ms
14
+ × formatAncestryChain > shows all owner components when ownerComponents is provided 4ms
15
+ expected 'div in App at src/App.jsx:104\n └─…' to be 'div in App at src/App.jsx:104\n └─…' // Object.is equality
16
+ × formatAncestryChain > shows single owner component without arrow when only one in chain 1ms
17
+ → expected 'Button at src/Button.tsx:10' to be 'button in Button at src/Button.tsx:10' // Object.is equality
18
+ ✓ src/adapters/phoenix/__tests__/parsePhoenixComments.test.ts (14 tests) 16ms
14
19
 
15
- Test Files 6 passed (6)
16
- Tests 18 passed (18)
17
- Start at 17:17:22
18
- Duration 718ms (transform 282ms, setup 0ms, collect 336ms, tests 14ms, environment 1ms, prepare 463ms)
20
+ ⎯⎯⎯⎯⎯⎯⎯ Failed Tests 2 ⎯⎯⎯⎯⎯⎯⎯
19
21
 
22
+ FAIL src/functions/formatAncestryChain.test.ts > formatAncestryChain > shows all owner components when ownerComponents is provided
23
+ AssertionError: expected 'div in App at src/App.jsx:104\n └─…' to be 'div in App at src/App.jsx:104\n └─…' // Object.is equality
24
+
25
+ - Expected
26
+ + Received
27
+
28
+ div in App at src/App.jsx:104
29
+ - └─ div#sidebar-panel in Sidebar > GlassPanel at src/components/game/Sidebar.jsx:78
30
+ + └─ GlassPanel#sidebar-panel in Sidebar at src/components/game/Sidebar.jsx:78
31
+
32
+ ❯ src/functions/formatAncestryChain.test.ts:120:20
33
+ 118|
34
+ 119| const result = formatAncestryChain(items);
35
+ 120| expect(result).toBe(
36
+ | ^
37
+ 121| `div in App at src/App.jsx:104
38
+ 122| └─ div#sidebar-panel in Sidebar > GlassPanel at src/components/gam…
39
+
40
+ ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/2]⎯
41
+
42
+ FAIL src/functions/formatAncestryChain.test.ts > formatAncestryChain > shows single owner component without arrow when only one in chain
43
+ AssertionError: expected 'Button at src/Button.tsx:10' to be 'button in Button at src/Button.tsx:10' // Object.is equality
44
+
45
+ Expected: "button in Button at src/Button.tsx:10"
46
+ Received: "Button at src/Button.tsx:10"
47
+
48
+ ❯ src/functions/formatAncestryChain.test.ts:138:20
49
+ 136|
50
+ 137| const result = formatAncestryChain(items);
51
+ 138| expect(result).toBe("button in Button at src/Button.tsx:10");
52
+ | ^
53
+ 139| });
54
+ 140| });
55
+
56
+ ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/2]⎯
57
+
58
+ Test Files 1 failed | 6 passed (7)
59
+ Tests 2 failed | 32 passed (34)
60
+ Start at 11:30:22
61
+ Duration 904ms (transform 370ms, setup 0ms, collect 434ms, tests 32ms, environment 497ms, prepare 595ms)
62
+
63
+  ELIFECYCLE  Test failed. See above for more details.
@@ -1,14 +1,42 @@
1
1
  import { ReactTreeNodeElement } from "./react/reactAdapter";
2
2
  import { JSXTreeNodeElement } from "./jsx/jsxAdapter";
3
- import { detectJSX, detectReact } from "@locator/shared";
3
+ import { SvelteTreeNodeElement } from "./svelte/svelteAdapter";
4
+ import { VueTreeNodeElement } from "./vue/vueAdapter";
5
+ import { detectJSX, detectReact, detectSvelte, detectVue } from "@locator/shared";
6
+ import { detectPhoenix } from "./phoenix/detectPhoenix";
4
7
  export function createTreeNode(element, adapterId) {
5
- // Check for React adapter
6
- if (adapterId === "react" || detectReact()) {
8
+ // Check for explicit adapter ID first
9
+ if (adapterId === "react") {
10
+ return new ReactTreeNodeElement(element);
11
+ }
12
+ if (adapterId === "svelte") {
13
+ return new SvelteTreeNodeElement(element);
14
+ }
15
+ if (adapterId === "vue") {
16
+ return new VueTreeNodeElement(element);
17
+ }
18
+ if (adapterId === "jsx") {
19
+ return new JSXTreeNodeElement(element);
20
+ }
21
+
22
+ // Auto-detect framework
23
+ if (detectSvelte()) {
24
+ return new SvelteTreeNodeElement(element);
25
+ }
26
+ if (detectVue()) {
27
+ return new VueTreeNodeElement(element);
28
+ }
29
+ if (detectReact()) {
7
30
  return new ReactTreeNodeElement(element);
8
31
  }
9
32
 
10
33
  // Check for JSX adapter (babel plugin) - check if element has data-locatorjs-id
11
- if (adapterId === "jsx" || detectJSX() || element.dataset.locatorjsId) {
34
+ if (detectJSX() || element.dataset.locatorjsId) {
35
+ return new JSXTreeNodeElement(element);
36
+ }
37
+
38
+ // Check for Phoenix LiveView (uses JSX adapter as fallback for pure Phoenix apps)
39
+ if (detectPhoenix()) {
12
40
  return new JSXTreeNodeElement(element);
13
41
  }
14
42
 
@@ -0,0 +1,31 @@
1
+ import { ServerComponentInfo } from "../../types/ServerComponentInfo";
2
+ /**
3
+ * Get the data-locatorjs attribute from the current element only.
4
+ * Returns array with single component info, or empty array if not found.
5
+ *
6
+ * We only look at the current element because the tree structure already
7
+ * shows the hierarchy - each parent element will have its own server component.
8
+ *
9
+ * Example DOM:
10
+ * ```html
11
+ * <html data-locatorjs="/app/layout.tsx:27:4"> <!-- RootLayout -->
12
+ * <body data-locatorjs="/app/layout.tsx:28:6"> <!-- RootLayout -->
13
+ * <div data-locatorjs="/app/page.tsx:5:4"> <!-- Page -->
14
+ * <button>Click</button>
15
+ * </div>
16
+ * </body>
17
+ * </html>
18
+ * ```
19
+ *
20
+ * For the div, returns: [{ name: "Page", filePath: "/app/page.tsx", line: 5 }]
21
+ * The tree structure will show: html (RootLayout) > body (RootLayout) > div (Page)
22
+ */
23
+ export declare function collectNextjsServerComponents(element: Element): ServerComponentInfo[];
24
+ /**
25
+ * Main entry point: extract Next.js server component info from element.
26
+ * Returns null if no data-locatorjs attribute found.
27
+ *
28
+ * This function is called during ancestry collection to enrich each AncestryItem
29
+ * with server-side Next.js component information.
30
+ */
31
+ export declare function parseNextjsServerComponents(element: Element): ServerComponentInfo[] | null;
@@ -0,0 +1,106 @@
1
+ import { normalizeFilePath } from "../../functions/normalizeFilePath";
2
+
3
+ /**
4
+ * Parse Next.js server component data from data-locatorjs attribute.
5
+ *
6
+ * Format: data-locatorjs="/path/to/app/layout.tsx:27:4"
7
+ *
8
+ * The @treelocator/webpack-loader adds these attributes to elements
9
+ * rendered by Next.js Server Components.
10
+ */
11
+
12
+ /**
13
+ * Extract component name from file path.
14
+ * Examples:
15
+ * - "/apps/next-16/app/layout.tsx:27:4" → "RootLayout"
16
+ * - "/apps/next-16/app/page.tsx:5:4" → "Home"
17
+ * - "/apps/next-16/app/components/Header.tsx:10:2" → "Header"
18
+ */
19
+ function extractComponentName(filePath) {
20
+ // Remove line:column suffix
21
+ const pathOnly = filePath.split(":")[0] || filePath;
22
+
23
+ // Get filename without extension
24
+ const fileName = pathOnly.split("/").pop()?.replace(/\.(tsx?|jsx?)$/, "") || "Unknown";
25
+
26
+ // Common Next.js conventions:
27
+ // - "layout" → "RootLayout" or "Layout"
28
+ // - "page" → Component name (we don't know it, so use "Page")
29
+ // - Others → Use as-is
30
+ if (fileName === "layout") {
31
+ return "RootLayout";
32
+ } else if (fileName === "page") {
33
+ return "Page";
34
+ }
35
+ return fileName;
36
+ }
37
+
38
+ /**
39
+ * Parse a data-locatorjs attribute value.
40
+ * Format: "/path/to/file.tsx:line:column"
41
+ * Returns ServerComponentInfo or null if parsing fails.
42
+ */
43
+ function parseDataLocatorjsValue(value) {
44
+ if (!value) return null;
45
+
46
+ // Split by ":" to get [filePath, line, column]
47
+ const parts = value.split(":");
48
+ if (parts.length < 2) return null;
49
+
50
+ // Last two parts are column and line (in reverse order)
51
+ const column = parts.pop();
52
+ const line = parts.pop();
53
+
54
+ // Everything else is the file path (which may contain colons on Windows)
55
+ const filePath = parts.join(":");
56
+ if (!filePath || !line) return null;
57
+ const componentName = extractComponentName(filePath);
58
+ const normalizedPath = normalizeFilePath(filePath);
59
+ return {
60
+ name: componentName,
61
+ filePath: normalizedPath,
62
+ line: parseInt(line, 10),
63
+ type: "component"
64
+ };
65
+ }
66
+
67
+ /**
68
+ * Get the data-locatorjs attribute from the current element only.
69
+ * Returns array with single component info, or empty array if not found.
70
+ *
71
+ * We only look at the current element because the tree structure already
72
+ * shows the hierarchy - each parent element will have its own server component.
73
+ *
74
+ * Example DOM:
75
+ * ```html
76
+ * <html data-locatorjs="/app/layout.tsx:27:4"> <!-- RootLayout -->
77
+ * <body data-locatorjs="/app/layout.tsx:28:6"> <!-- RootLayout -->
78
+ * <div data-locatorjs="/app/page.tsx:5:4"> <!-- Page -->
79
+ * <button>Click</button>
80
+ * </div>
81
+ * </body>
82
+ * </html>
83
+ * ```
84
+ *
85
+ * For the div, returns: [{ name: "Page", filePath: "/app/page.tsx", line: 5 }]
86
+ * The tree structure will show: html (RootLayout) > body (RootLayout) > div (Page)
87
+ */
88
+ export function collectNextjsServerComponents(element) {
89
+ const value = element.getAttribute("data-locatorjs");
90
+ if (!value) return [];
91
+ const info = parseDataLocatorjsValue(value);
92
+ return info ? [info] : [];
93
+ }
94
+
95
+ /**
96
+ * Main entry point: extract Next.js server component info from element.
97
+ * Returns null if no data-locatorjs attribute found.
98
+ *
99
+ * This function is called during ancestry collection to enrich each AncestryItem
100
+ * with server-side Next.js component information.
101
+ */
102
+ export function parseNextjsServerComponents(element) {
103
+ const components = collectNextjsServerComponents(element);
104
+ if (components.length === 0) return null;
105
+ return components;
106
+ }
@@ -0,0 +1,4 @@
1
+ /**
2
+ * @vitest-environment jsdom
3
+ */
4
+ export {};
@@ -0,0 +1,218 @@
1
+ /**
2
+ * @vitest-environment jsdom
3
+ */
4
+
5
+ import { describe, it, expect, beforeEach } from "vitest";
6
+ import { findPrecedingPhoenixComments, phoenixMatchesToServerComponents, parsePhoenixServerComponents } from "../parsePhoenixComments";
7
+ describe("parsePhoenixComments", () => {
8
+ let container;
9
+ beforeEach(() => {
10
+ container = document.createElement("div");
11
+ });
12
+ describe("findPrecedingPhoenixComments", () => {
13
+ it("parses @caller comment", () => {
14
+ container.innerHTML = `
15
+ <!-- @caller lib/app_web/home_live.ex:20 -->
16
+ <header>Content</header>
17
+ `;
18
+ const header = container.querySelector("header");
19
+ const matches = findPrecedingPhoenixComments(header);
20
+ expect(matches).toHaveLength(1);
21
+ expect(matches[0]).toMatchObject({
22
+ name: "@caller",
23
+ filePath: "lib/app_web/home_live.ex",
24
+ line: 20,
25
+ type: "caller"
26
+ });
27
+ });
28
+ it("parses component comment", () => {
29
+ container.innerHTML = `
30
+ <!-- <AppWeb.CoreComponents.header> lib/app_web/core_components.ex:123 -->
31
+ <header>Content</header>
32
+ `;
33
+ const header = container.querySelector("header");
34
+ const matches = findPrecedingPhoenixComments(header);
35
+ expect(matches).toHaveLength(1);
36
+ expect(matches[0]).toMatchObject({
37
+ name: "AppWeb.CoreComponents.header",
38
+ filePath: "lib/app_web/core_components.ex",
39
+ line: 123,
40
+ type: "component"
41
+ });
42
+ });
43
+ it("ignores closing tag comments", () => {
44
+ container.innerHTML = `
45
+ <!-- </AppWeb.CoreComponents.header> -->
46
+ <header>Content</header>
47
+ `;
48
+ const header = container.querySelector("header");
49
+ const matches = findPrecedingPhoenixComments(header);
50
+ expect(matches).toHaveLength(0);
51
+ });
52
+ it("finds multiple preceding comments in correct order", () => {
53
+ container.innerHTML = `
54
+ <!-- @caller lib/app_web/home_live.ex:20 -->
55
+ <!-- <AppWeb.CoreComponents.header> lib/app_web/core_components.ex:123 -->
56
+ <header>Content</header>
57
+ `;
58
+ const header = container.querySelector("header");
59
+ const matches = findPrecedingPhoenixComments(header);
60
+ expect(matches).toHaveLength(2);
61
+ // Should be ordered from outermost to innermost
62
+ expect(matches[0].name).toBe("@caller");
63
+ expect(matches[0].line).toBe(20);
64
+ expect(matches[1].name).toBe("AppWeb.CoreComponents.header");
65
+ expect(matches[1].line).toBe(123);
66
+ });
67
+ it("stops at non-comment element node", () => {
68
+ container.innerHTML = `
69
+ <div>Other element</div>
70
+ <!-- <AppWeb.CoreComponents.header> lib/app_web/core_components.ex:123 -->
71
+ <header>Content</header>
72
+ `;
73
+ const header = container.querySelector("header");
74
+ const matches = findPrecedingPhoenixComments(header);
75
+
76
+ // Should only find the comment between the div and header
77
+ expect(matches).toHaveLength(1);
78
+ expect(matches[0].name).toBe("AppWeb.CoreComponents.header");
79
+ });
80
+ it("skips whitespace text nodes", () => {
81
+ container.innerHTML = `
82
+ <!-- @caller lib/app_web/home_live.ex:20 -->
83
+
84
+ <header>Content</header>
85
+ `;
86
+ const header = container.querySelector("header");
87
+ const matches = findPrecedingPhoenixComments(header);
88
+
89
+ // Should find the comment despite whitespace text node
90
+ expect(matches).toHaveLength(1);
91
+ expect(matches[0].name).toBe("@caller");
92
+ });
93
+ it("stops at non-whitespace text node", () => {
94
+ container.innerHTML = `
95
+ <!-- @caller lib/app_web/home_live.ex:20 -->
96
+ Some text
97
+ <header>Content</header>
98
+ `;
99
+ const header = container.querySelector("header");
100
+ const matches = findPrecedingPhoenixComments(header);
101
+
102
+ // Should stop at the text node, not finding the comment
103
+ expect(matches).toHaveLength(0);
104
+ });
105
+ it("returns empty array if no preceding comments", () => {
106
+ container.innerHTML = `<header>Content</header>`;
107
+ const header = container.querySelector("header");
108
+ const matches = findPrecedingPhoenixComments(header);
109
+ expect(matches).toHaveLength(0);
110
+ });
111
+ it("ignores non-Phoenix comments", () => {
112
+ container.innerHTML = `
113
+ <!-- Regular HTML comment -->
114
+ <header>Content</header>
115
+ `;
116
+ const header = container.querySelector("header");
117
+ const matches = findPrecedingPhoenixComments(header);
118
+ expect(matches).toHaveLength(0);
119
+ });
120
+ it("finds Phoenix comments and ignores non-Phoenix comments", () => {
121
+ container.innerHTML = `
122
+ <!-- Regular comment -->
123
+ <!-- @caller lib/app_web/home_live.ex:20 -->
124
+ <!-- Another regular comment -->
125
+ <!-- <AppWeb.CoreComponents.header> lib/app_web/core_components.ex:123 -->
126
+ <header>Content</header>
127
+ `;
128
+ const header = container.querySelector("header");
129
+ const matches = findPrecedingPhoenixComments(header);
130
+
131
+ // Should only find the 2 Phoenix comments
132
+ expect(matches).toHaveLength(2);
133
+ expect(matches[0].name).toBe("@caller");
134
+ expect(matches[1].name).toBe("AppWeb.CoreComponents.header");
135
+ });
136
+ });
137
+ describe("phoenixMatchesToServerComponents", () => {
138
+ it("converts matches to ServerComponentInfo format", () => {
139
+ container.innerHTML = `
140
+ <!-- @caller lib/app_web/home_live.ex:20 -->
141
+ <!-- <AppWeb.CoreComponents.button> lib/app_web/core_components.ex:456 -->
142
+ <button>Click</button>
143
+ `;
144
+ const button = container.querySelector("button");
145
+ const matches = findPrecedingPhoenixComments(button);
146
+ const serverComponents = phoenixMatchesToServerComponents(matches);
147
+ expect(serverComponents).toHaveLength(2);
148
+ expect(serverComponents[0]).toEqual({
149
+ name: "@caller",
150
+ filePath: "lib/app_web/home_live.ex",
151
+ line: 20,
152
+ type: "caller"
153
+ });
154
+ expect(serverComponents[1]).toEqual({
155
+ name: "AppWeb.CoreComponents.button",
156
+ filePath: "lib/app_web/core_components.ex",
157
+ line: 456,
158
+ type: "component"
159
+ });
160
+ });
161
+ });
162
+ describe("parsePhoenixServerComponents", () => {
163
+ it("returns ServerComponentInfo array when comments found", () => {
164
+ container.innerHTML = `
165
+ <!-- @caller lib/app_web/home_live.ex:48 -->
166
+ <!-- <AppWeb.CoreComponents.button> lib/app_web/core_components.ex:456 -->
167
+ <button data-phx-loc="458">Click Me</button>
168
+ `;
169
+ const button = container.querySelector("button");
170
+ const result = parsePhoenixServerComponents(button);
171
+ expect(result).not.toBeNull();
172
+ expect(result).toHaveLength(2);
173
+ expect(result[0]).toEqual({
174
+ name: "@caller",
175
+ filePath: "lib/app_web/home_live.ex",
176
+ line: 48,
177
+ type: "caller"
178
+ });
179
+ expect(result[1]).toEqual({
180
+ name: "AppWeb.CoreComponents.button",
181
+ filePath: "lib/app_web/core_components.ex",
182
+ line: 456,
183
+ type: "component"
184
+ });
185
+ });
186
+ it("returns null when no comments found", () => {
187
+ container.innerHTML = `<button>Click Me</button>`;
188
+ const button = container.querySelector("button");
189
+ const result = parsePhoenixServerComponents(button);
190
+ expect(result).toBeNull();
191
+ });
192
+ it("handles nested structure with multiple components", () => {
193
+ container.innerHTML = `
194
+ <!-- @caller lib/app_web/home_live.ex:20 -->
195
+ <!-- <AppWeb.CoreComponents.header> lib/app_web/core_components.ex:123 -->
196
+ <header data-phx-loc="125" class="p-5">
197
+ <!-- @caller lib/app_web/home_live.ex:48 -->
198
+ <!-- <AppWeb.CoreComponents.button> lib/app_web/core_components.ex:456 -->
199
+ <button data-phx-loc="458" class="px-2">Click</button>
200
+ </header>
201
+ `;
202
+ const header = container.querySelector("header");
203
+ const headerResult = parsePhoenixServerComponents(header);
204
+ expect(headerResult).not.toBeNull();
205
+ expect(headerResult).toHaveLength(2);
206
+ expect(headerResult[0].name).toBe("@caller");
207
+ expect(headerResult[0].line).toBe(20);
208
+ expect(headerResult[1].name).toBe("AppWeb.CoreComponents.header");
209
+ const button = container.querySelector("button");
210
+ const buttonResult = parsePhoenixServerComponents(button);
211
+ expect(buttonResult).not.toBeNull();
212
+ expect(buttonResult).toHaveLength(2);
213
+ expect(buttonResult[0].name).toBe("@caller");
214
+ expect(buttonResult[0].line).toBe(48);
215
+ expect(buttonResult[1].name).toBe("AppWeb.CoreComponents.button");
216
+ });
217
+ });
218
+ });
@@ -0,0 +1,11 @@
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 declare function detectPhoenix(): boolean;
@@ -0,0 +1,38 @@
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() {
12
+ // Check 1: Look for LiveView socket (most reliable)
13
+ if (typeof window !== "undefined" && window.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(document.body, NodeFilter.SHOW_COMMENT, null);
27
+ let count = 0;
28
+ while (walker.nextNode() && count < 50) {
29
+ const text = walker.currentNode.textContent;
30
+ // Look for Phoenix-specific comment patterns
31
+ if (text?.includes("@caller") || text?.includes("<App")) {
32
+ return true;
33
+ }
34
+ count++;
35
+ }
36
+ }
37
+ return false;
38
+ }
@@ -0,0 +1,10 @@
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
+ export { parsePhoenixServerComponents, findPrecedingPhoenixComments } from "./parsePhoenixComments";
8
+ export { detectPhoenix } from "./detectPhoenix";
9
+ export type { ServerComponentInfo } from "../../types/ServerComponentInfo";
10
+ export type { PhoenixCommentMatch } from "./types";
@@ -0,0 +1,9 @@
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";
@@ -0,0 +1,35 @@
1
+ import { ServerComponentInfo } from "../../types/ServerComponentInfo";
2
+ import { PhoenixCommentMatch } from "./types";
3
+ /**
4
+ * Find all Phoenix comment annotations immediately preceding an element.
5
+ * Walks backward through previous siblings until hitting a non-comment node.
6
+ *
7
+ * Returns array ordered from outermost to innermost (matching Phoenix nesting order).
8
+ * Example: [@caller, CoreComponents.button] where @caller is outermost.
9
+ *
10
+ * Example DOM structure:
11
+ * ```html
12
+ * <!-- @caller lib/app_web/home_live.ex:48 -->
13
+ * <!-- <AppWeb.CoreComponents.button> lib/app_web/core_components.ex:456 -->
14
+ * <button>Click Me</button>
15
+ * ```
16
+ *
17
+ * Would return: [
18
+ * { name: "@caller", filePath: "lib/app_web/home_live.ex", line: 48, type: "caller" },
19
+ * { name: "AppWeb.CoreComponents.button", filePath: "lib/app_web/core_components.ex", line: 456, type: "component" }
20
+ * ]
21
+ */
22
+ export declare function findPrecedingPhoenixComments(element: Element): PhoenixCommentMatch[];
23
+ /**
24
+ * Convert PhoenixCommentMatch[] to ServerComponentInfo[].
25
+ * Filters and transforms matches into the format expected by AncestryItem.
26
+ */
27
+ export declare function phoenixMatchesToServerComponents(matches: PhoenixCommentMatch[]): ServerComponentInfo[];
28
+ /**
29
+ * Main entry point: extract server component info from element.
30
+ * Returns null if no Phoenix annotations found.
31
+ *
32
+ * This function is called during ancestry collection to enrich each AncestryItem
33
+ * with server-side component information.
34
+ */
35
+ export declare function parsePhoenixServerComponents(element: Element): ServerComponentInfo[] | null;