@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
package/dist/output.css CHANGED
@@ -554,7 +554,7 @@ video {
554
554
  display: none;
555
555
  }
556
556
 
557
- [type='text'],input:where(:not([type])),[type='email'],[type='url'],[type='password'],[type='number'],[type='date'],[type='datetime-local'],[type='month'],[type='search'],[type='tel'],[type='time'],[type='week'],[multiple],textarea,select {
557
+ input:where([type='text']),input:where(:not([type])),input:where([type='email']),input:where([type='url']),input:where([type='password']),input:where([type='number']),input:where([type='date']),input:where([type='datetime-local']),input:where([type='month']),input:where([type='search']),input:where([type='tel']),input:where([type='time']),input:where([type='week']),select:where([multiple]),textarea,select {
558
558
  -webkit-appearance: none;
559
559
  -moz-appearance: none;
560
560
  appearance: none;
@@ -571,7 +571,7 @@ video {
571
571
  --tw-shadow: 0 0 #0000;
572
572
  }
573
573
 
574
- [type='text']:focus, input:where(:not([type])):focus, [type='email']:focus, [type='url']:focus, [type='password']:focus, [type='number']:focus, [type='date']:focus, [type='datetime-local']:focus, [type='month']:focus, [type='search']:focus, [type='tel']:focus, [type='time']:focus, [type='week']:focus, [multiple]:focus, textarea:focus, select:focus {
574
+ input:where([type='text']):focus, input:where(:not([type])):focus, input:where([type='email']):focus, input:where([type='url']):focus, input:where([type='password']):focus, input:where([type='number']):focus, input:where([type='date']):focus, input:where([type='datetime-local']):focus, input:where([type='month']):focus, input:where([type='search']):focus, input:where([type='tel']):focus, input:where([type='time']):focus, input:where([type='week']):focus, select:where([multiple]):focus, textarea:focus, select:focus {
575
575
  outline: 2px solid transparent;
576
576
  outline-offset: 2px;
577
577
  --tw-ring-inset: var(--tw-empty,/*!*/ /*!*/);
@@ -600,6 +600,11 @@ input::placeholder,textarea::placeholder {
600
600
 
601
601
  ::-webkit-date-and-time-value {
602
602
  min-height: 1.5em;
603
+ text-align: inherit;
604
+ }
605
+
606
+ ::-webkit-datetime-edit {
607
+ display: inline-flex;
603
608
  }
604
609
 
605
610
  ::-webkit-datetime-edit,::-webkit-datetime-edit-year-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-meridiem-field {
@@ -617,7 +622,7 @@ select {
617
622
  print-color-adjust: exact;
618
623
  }
619
624
 
620
- [multiple],[size]:where(select:not([size="1"])) {
625
+ select:where([multiple]),select:where([size]:not([size="1"])) {
621
626
  background-image: initial;
622
627
  background-position: initial;
623
628
  background-repeat: unset;
@@ -627,7 +632,7 @@ select {
627
632
  print-color-adjust: unset;
628
633
  }
629
634
 
630
- [type='checkbox'],[type='radio'] {
635
+ input:where([type='checkbox']),input:where([type='radio']) {
631
636
  -webkit-appearance: none;
632
637
  -moz-appearance: none;
633
638
  appearance: none;
@@ -650,15 +655,15 @@ select {
650
655
  --tw-shadow: 0 0 #0000;
651
656
  }
652
657
 
653
- [type='checkbox'] {
658
+ input:where([type='checkbox']) {
654
659
  border-radius: 0px;
655
660
  }
656
661
 
657
- [type='radio'] {
662
+ input:where([type='radio']) {
658
663
  border-radius: 100%;
659
664
  }
660
665
 
661
- [type='checkbox']:focus,[type='radio']:focus {
666
+ input:where([type='checkbox']):focus,input:where([type='radio']):focus {
662
667
  outline: 2px solid transparent;
663
668
  outline-offset: 2px;
664
669
  --tw-ring-inset: var(--tw-empty,/*!*/ /*!*/);
@@ -670,7 +675,7 @@ select {
670
675
  box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
671
676
  }
672
677
 
673
- [type='checkbox']:checked,[type='radio']:checked {
678
+ input:where([type='checkbox']):checked,input:where([type='radio']):checked {
674
679
  border-color: transparent;
675
680
  background-color: currentColor;
676
681
  background-size: 100% 100%;
@@ -678,20 +683,36 @@ select {
678
683
  background-repeat: no-repeat;
679
684
  }
680
685
 
681
- [type='checkbox']:checked {
686
+ input:where([type='checkbox']):checked {
682
687
  background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e");
683
688
  }
684
689
 
685
- [type='radio']:checked {
690
+ @media (forced-colors: active) {
691
+ input:where([type='checkbox']):checked {
692
+ -webkit-appearance: auto;
693
+ -moz-appearance: auto;
694
+ appearance: auto;
695
+ }
696
+ }
697
+
698
+ input:where([type='radio']):checked {
686
699
  background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3ccircle cx='8' cy='8' r='3'/%3e%3c/svg%3e");
687
700
  }
688
701
 
689
- [type='checkbox']:checked:hover,[type='checkbox']:checked:focus,[type='radio']:checked:hover,[type='radio']:checked:focus {
702
+ @media (forced-colors: active) {
703
+ input:where([type='radio']):checked {
704
+ -webkit-appearance: auto;
705
+ -moz-appearance: auto;
706
+ appearance: auto;
707
+ }
708
+ }
709
+
710
+ input:where([type='checkbox']):checked:hover,input:where([type='checkbox']):checked:focus,input:where([type='radio']):checked:hover,input:where([type='radio']):checked:focus {
690
711
  border-color: transparent;
691
712
  background-color: currentColor;
692
713
  }
693
714
 
694
- [type='checkbox']:indeterminate {
715
+ input:where([type='checkbox']):indeterminate {
695
716
  background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 16'%3e%3cpath stroke='white' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M4 8h8'/%3e%3c/svg%3e");
696
717
  border-color: transparent;
697
718
  background-color: currentColor;
@@ -700,12 +721,20 @@ select {
700
721
  background-repeat: no-repeat;
701
722
  }
702
723
 
703
- [type='checkbox']:indeterminate:hover,[type='checkbox']:indeterminate:focus {
724
+ @media (forced-colors: active) {
725
+ input:where([type='checkbox']):indeterminate {
726
+ -webkit-appearance: auto;
727
+ -moz-appearance: auto;
728
+ appearance: auto;
729
+ }
730
+ }
731
+
732
+ input:where([type='checkbox']):indeterminate:hover,input:where([type='checkbox']):indeterminate:focus {
704
733
  border-color: transparent;
705
734
  background-color: currentColor;
706
735
  }
707
736
 
708
- [type='file'] {
737
+ input:where([type='file']) {
709
738
  background: unset;
710
739
  border-color: inherit;
711
740
  border-width: 0;
@@ -715,11 +744,45 @@ select {
715
744
  line-height: inherit;
716
745
  }
717
746
 
718
- [type='file']:focus {
747
+ input:where([type='file']):focus {
719
748
  outline: 1px solid ButtonText;
720
749
  outline: 1px auto -webkit-focus-ring-color;
721
750
  }
722
751
 
752
+ .container {
753
+ width: 100%;
754
+ }
755
+
756
+ @media (min-width: 640px) {
757
+ .container {
758
+ max-width: 640px;
759
+ }
760
+ }
761
+
762
+ @media (min-width: 768px) {
763
+ .container {
764
+ max-width: 768px;
765
+ }
766
+ }
767
+
768
+ @media (min-width: 1024px) {
769
+ .container {
770
+ max-width: 1024px;
771
+ }
772
+ }
773
+
774
+ @media (min-width: 1280px) {
775
+ .container {
776
+ max-width: 1280px;
777
+ }
778
+ }
779
+
780
+ @media (min-width: 1536px) {
781
+ .container {
782
+ max-width: 1536px;
783
+ }
784
+ }
785
+
723
786
  .sr-only {
724
787
  position: absolute;
725
788
  width: 1px;
@@ -1184,6 +1247,11 @@ select {
1184
1247
  border-color: rgb(209 213 219 / var(--tw-border-opacity, 1));
1185
1248
  }
1186
1249
 
1250
+ .border-gray-500 {
1251
+ --tw-border-opacity: 1;
1252
+ border-color: rgb(107 114 128 / var(--tw-border-opacity, 1));
1253
+ }
1254
+
1187
1255
  .border-gray-600 {
1188
1256
  --tw-border-opacity: 1;
1189
1257
  border-color: rgb(75 85 99 / var(--tw-border-opacity, 1));
@@ -1351,6 +1419,10 @@ select {
1351
1419
  padding: 1rem;
1352
1420
  }
1353
1421
 
1422
+ .p-5 {
1423
+ padding: 1.25rem;
1424
+ }
1425
+
1354
1426
  .p-6 {
1355
1427
  padding: 1.5rem;
1356
1428
  }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Represents a server-side component in the ancestry chain.
3
+ * Used for Phoenix LiveView, Rails ViewComponents, Next.js RSC, etc.
4
+ */
5
+ export interface ServerComponentInfo {
6
+ /** Component name (e.g., "AppWeb.CoreComponents.button") or "@caller" for call site */
7
+ name: string;
8
+ /** File path (e.g., "lib/app_web/core_components.ex") */
9
+ filePath: string;
10
+ /** Line number in the source file */
11
+ line: number;
12
+ /** Type of server component annotation */
13
+ type: "component" | "caller";
14
+ }
@@ -0,0 +1 @@
1
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@treelocator/runtime",
3
- "version": "0.1.8",
3
+ "version": "0.2.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.2.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": "5d53daa18f4fef5e815c3fd281b899608f8673ea"
76
77
  }
@@ -1,22 +1,54 @@
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";
8
13
 
9
14
  export function createTreeNode(
10
15
  element: HTMLElement,
11
16
  adapterId?: string
12
17
  ): TreeNode | null {
13
- // Check for React adapter
14
- if (adapterId === "react" || detectReact()) {
18
+ // Check for explicit adapter ID first
19
+ if (adapterId === "react") {
20
+ return new ReactTreeNodeElement(element);
21
+ }
22
+ if (adapterId === "svelte") {
23
+ return new SvelteTreeNodeElement(element);
24
+ }
25
+ if (adapterId === "vue") {
26
+ return new VueTreeNodeElement(element);
27
+ }
28
+ if (adapterId === "jsx") {
29
+ return new JSXTreeNodeElement(element);
30
+ }
31
+
32
+ // Auto-detect framework
33
+ if (detectSvelte()) {
34
+ return new SvelteTreeNodeElement(element);
35
+ }
36
+
37
+ if (detectVue()) {
38
+ return new VueTreeNodeElement(element);
39
+ }
40
+
41
+ if (detectReact()) {
15
42
  return new ReactTreeNodeElement(element);
16
43
  }
17
44
 
18
45
  // Check for JSX adapter (babel plugin) - check if element has data-locatorjs-id
19
- if (adapterId === "jsx" || detectJSX() || element.dataset.locatorjsId) {
46
+ if (detectJSX() || element.dataset.locatorjsId) {
47
+ return new JSXTreeNodeElement(element);
48
+ }
49
+
50
+ // Check for Phoenix LiveView (uses JSX adapter as fallback for pure Phoenix apps)
51
+ if (detectPhoenix()) {
20
52
  return new JSXTreeNodeElement(element);
21
53
  }
22
54
 
@@ -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
+ });