@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.
- package/.turbo/turbo-build.log +8 -6
- package/.turbo/turbo-dev.log +32 -0
- package/.turbo/turbo-test.log +54 -10
- package/dist/adapters/createTreeNode.js +32 -4
- package/dist/adapters/nextjs/parseNextjsDataAttributes.d.ts +31 -0
- package/dist/adapters/nextjs/parseNextjsDataAttributes.js +106 -0
- package/dist/adapters/phoenix/__tests__/parsePhoenixComments.test.d.ts +4 -0
- package/dist/adapters/phoenix/__tests__/parsePhoenixComments.test.js +218 -0
- package/dist/adapters/phoenix/detectPhoenix.d.ts +11 -0
- package/dist/adapters/phoenix/detectPhoenix.js +38 -0
- package/dist/adapters/phoenix/index.d.ts +10 -0
- package/dist/adapters/phoenix/index.js +9 -0
- package/dist/adapters/phoenix/parsePhoenixComments.d.ts +35 -0
- package/dist/adapters/phoenix/parsePhoenixComments.js +131 -0
- package/dist/adapters/phoenix/types.d.ts +16 -0
- package/dist/adapters/phoenix/types.js +1 -0
- package/dist/adapters/react/getFiberLabel.js +2 -1
- package/dist/components/MaybeOutline.js +65 -3
- package/dist/functions/formatAncestryChain.d.ts +3 -0
- package/dist/functions/formatAncestryChain.js +104 -15
- package/dist/functions/formatAncestryChain.test.js +26 -20
- package/dist/functions/normalizeFilePath.d.ts +14 -0
- package/dist/functions/normalizeFilePath.js +40 -0
- package/dist/output.css +87 -15
- package/dist/types/ServerComponentInfo.d.ts +14 -0
- package/dist/types/ServerComponentInfo.js +1 -0
- package/package.json +4 -3
- package/src/adapters/createTreeNode.ts +35 -3
- package/src/adapters/nextjs/parseNextjsDataAttributes.ts +112 -0
- package/src/adapters/phoenix/__tests__/parsePhoenixComments.test.ts +264 -0
- package/src/adapters/phoenix/detectPhoenix.ts +44 -0
- package/src/adapters/phoenix/index.ts +11 -0
- package/src/adapters/phoenix/parsePhoenixComments.ts +140 -0
- package/src/adapters/phoenix/types.ts +16 -0
- package/src/adapters/react/getFiberLabel.ts +2 -1
- package/src/components/MaybeOutline.tsx +63 -4
- package/src/functions/formatAncestryChain.test.ts +26 -20
- package/src/functions/formatAncestryChain.ts +121 -15
- package/src/functions/normalizeFilePath.ts +41 -0
- package/src/types/ServerComponentInfo.ts +14 -0
package/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]:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
"@
|
|
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": "
|
|
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
|
|
14
|
-
if (adapterId === "react"
|
|
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 (
|
|
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
|
+
});
|