angular-grab 0.1.1 → 0.1.2

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 (132) hide show
  1. package/README.md +215 -0
  2. package/examples/angular-19-app/.editorconfig +17 -0
  3. package/examples/angular-19-app/.vscode/extensions.json +4 -0
  4. package/examples/angular-19-app/.vscode/launch.json +20 -0
  5. package/examples/angular-19-app/.vscode/mcp.json +9 -0
  6. package/examples/angular-19-app/.vscode/tasks.json +42 -0
  7. package/examples/angular-19-app/README.md +59 -0
  8. package/examples/angular-19-app/angular.json +74 -0
  9. package/examples/angular-19-app/package.json +44 -0
  10. package/examples/angular-19-app/public/favicon.ico +0 -0
  11. package/examples/angular-19-app/src/app/app.config.ts +13 -0
  12. package/examples/angular-19-app/src/app/app.css +37 -0
  13. package/examples/angular-19-app/src/app/app.html +25 -0
  14. package/examples/angular-19-app/src/app/app.routes.ts +3 -0
  15. package/examples/angular-19-app/src/app/app.spec.ts +23 -0
  16. package/examples/angular-19-app/src/app/app.ts +12 -0
  17. package/examples/angular-19-app/src/app/button/button.component.ts +25 -0
  18. package/examples/angular-19-app/src/app/card/card.component.ts +33 -0
  19. package/examples/angular-19-app/src/app/header/header.component.ts +31 -0
  20. package/examples/angular-19-app/src/app/popover/popover.component.ts +133 -0
  21. package/examples/angular-19-app/src/index.html +13 -0
  22. package/examples/angular-19-app/src/main.ts +6 -0
  23. package/examples/angular-19-app/src/styles.css +1 -0
  24. package/examples/angular-19-app/tsconfig.app.json +15 -0
  25. package/examples/angular-19-app/tsconfig.json +33 -0
  26. package/examples/angular-19-app/tsconfig.spec.json +15 -0
  27. package/package.json +14 -111
  28. package/packages/angular-grab/package.json +96 -0
  29. package/packages/angular-grab/src/angular/__tests__/context-builder.test.ts +216 -0
  30. package/packages/angular-grab/src/angular/angular-grab.service.ts +62 -0
  31. package/packages/angular-grab/src/angular/index.ts +13 -0
  32. package/packages/angular-grab/src/angular/provide-angular-grab.ts +22 -0
  33. package/packages/angular-grab/src/angular/resolvers/component-resolver.ts +71 -0
  34. package/packages/angular-grab/src/angular/resolvers/context-builder.ts +86 -0
  35. package/packages/angular-grab/src/angular/resolvers/ng-utils.ts +14 -0
  36. package/packages/angular-grab/src/angular/resolvers/source-resolver.ts +61 -0
  37. package/packages/angular-grab/src/builder/__tests__/builder.test.ts +72 -0
  38. package/packages/angular-grab/src/builder/builders/application/index.ts +13 -0
  39. package/packages/angular-grab/src/builder/builders/dev-server/index.ts +9 -0
  40. package/packages/angular-grab/src/builder/index.ts +3 -0
  41. package/packages/angular-grab/src/cli/__tests__/cli.test.ts +239 -0
  42. package/packages/angular-grab/src/cli/commands/init.ts +106 -0
  43. package/packages/angular-grab/src/cli/index.ts +15 -0
  44. package/packages/angular-grab/src/cli/utils/detect-project.ts +78 -0
  45. package/packages/angular-grab/src/cli/utils/modify-angular-json.ts +42 -0
  46. package/packages/angular-grab/src/cli/utils/modify-app-config.ts +42 -0
  47. package/packages/angular-grab/src/core/__tests__/generate-snippet.test.ts +149 -0
  48. package/packages/angular-grab/src/core/__tests__/plugin-registry.test.ts +286 -0
  49. package/packages/angular-grab/src/core/__tests__/store.test.ts +118 -0
  50. package/packages/angular-grab/src/core/__tests__/utils.test.ts +85 -0
  51. package/packages/angular-grab/src/core/clipboard/copy.ts +104 -0
  52. package/packages/angular-grab/src/core/clipboard/generate-snippet.ts +38 -0
  53. package/packages/angular-grab/src/core/constants.ts +10 -0
  54. package/packages/angular-grab/src/core/grab.ts +596 -0
  55. package/packages/angular-grab/src/core/index.global.ts +13 -0
  56. package/packages/angular-grab/src/core/index.ts +19 -0
  57. package/packages/angular-grab/src/core/keyboard/keyboard-handler.ts +163 -0
  58. package/packages/angular-grab/src/core/overlay/crosshair.ts +107 -0
  59. package/packages/angular-grab/src/core/overlay/freeze-overlay.ts +239 -0
  60. package/packages/angular-grab/src/core/overlay/overlay-renderer.ts +180 -0
  61. package/packages/angular-grab/src/core/overlay/select-feedback.ts +108 -0
  62. package/packages/angular-grab/src/core/overlay/toast.ts +175 -0
  63. package/packages/angular-grab/src/core/picker/element-picker.ts +114 -0
  64. package/packages/angular-grab/src/core/plugins/plugin-registry.ts +83 -0
  65. package/packages/angular-grab/src/core/store.ts +52 -0
  66. package/packages/angular-grab/src/core/toolbar/actions-menu.ts +178 -0
  67. package/packages/angular-grab/src/core/toolbar/comment-popover.ts +235 -0
  68. package/packages/angular-grab/src/core/toolbar/copy-actions.ts +98 -0
  69. package/packages/angular-grab/src/core/toolbar/history-popover.ts +245 -0
  70. package/packages/angular-grab/src/core/toolbar/theme-manager.ts +188 -0
  71. package/packages/angular-grab/src/core/toolbar/toolbar-icons.ts +29 -0
  72. package/packages/angular-grab/src/core/toolbar/toolbar-renderer.ts +239 -0
  73. package/packages/angular-grab/src/core/types.ts +139 -0
  74. package/packages/angular-grab/src/core/utils.ts +16 -0
  75. package/packages/angular-grab/src/esbuild-plugin/__tests__/transform.test.ts +174 -0
  76. package/packages/angular-grab/src/esbuild-plugin/index.ts +3 -0
  77. package/packages/angular-grab/src/esbuild-plugin/plugin.ts +29 -0
  78. package/packages/angular-grab/src/esbuild-plugin/scan.ts +105 -0
  79. package/packages/angular-grab/src/esbuild-plugin/transform.ts +152 -0
  80. package/packages/angular-grab/src/vite-plugin/__tests__/plugin.test.ts +84 -0
  81. package/packages/angular-grab/src/vite-plugin/index.ts +19 -0
  82. package/packages/angular-grab/src/webpack-plugin/__tests__/plugin.test.ts +72 -0
  83. package/packages/angular-grab/src/webpack-plugin/index.ts +2 -0
  84. package/packages/angular-grab/src/webpack-plugin/loader.ts +15 -0
  85. package/packages/angular-grab/src/webpack-plugin/plugin.ts +20 -0
  86. package/packages/angular-grab/tsconfig.json +15 -0
  87. package/packages/angular-grab/tsup.config.ts +119 -0
  88. package/pnpm-workspace.yaml +3 -0
  89. package/turbo.json +21 -0
  90. package/dist/angular/index.d.ts +0 -151
  91. package/dist/angular/index.js +0 -2811
  92. package/dist/angular/index.js.map +0 -1
  93. package/dist/builder/builders/application/index.js +0 -143
  94. package/dist/builder/builders/application/index.js.map +0 -1
  95. package/dist/builder/builders/dev-server/index.js +0 -139
  96. package/dist/builder/builders/dev-server/index.js.map +0 -1
  97. package/dist/builder/index.js +0 -2
  98. package/dist/builder/index.js.map +0 -1
  99. package/dist/builder/package.json +0 -1
  100. package/dist/cli/index.js +0 -223
  101. package/dist/cli/index.js.map +0 -1
  102. package/dist/core/index.cjs +0 -2589
  103. package/dist/core/index.cjs.map +0 -1
  104. package/dist/core/index.d.cts +0 -139
  105. package/dist/core/index.d.ts +0 -139
  106. package/dist/core/index.global.js +0 -542
  107. package/dist/core/index.js +0 -2560
  108. package/dist/core/index.js.map +0 -1
  109. package/dist/esbuild-plugin/index.cjs +0 -239
  110. package/dist/esbuild-plugin/index.cjs.map +0 -1
  111. package/dist/esbuild-plugin/index.d.cts +0 -26
  112. package/dist/esbuild-plugin/index.d.ts +0 -26
  113. package/dist/esbuild-plugin/index.js +0 -200
  114. package/dist/esbuild-plugin/index.js.map +0 -1
  115. package/dist/vite-plugin/index.d.ts +0 -7
  116. package/dist/vite-plugin/index.js +0 -128
  117. package/dist/vite-plugin/index.js.map +0 -1
  118. package/dist/webpack-plugin/index.cjs +0 -54
  119. package/dist/webpack-plugin/index.cjs.map +0 -1
  120. package/dist/webpack-plugin/index.d.cts +0 -5
  121. package/dist/webpack-plugin/index.d.ts +0 -5
  122. package/dist/webpack-plugin/index.js +0 -23
  123. package/dist/webpack-plugin/index.js.map +0 -1
  124. package/dist/webpack-plugin/loader.cjs +0 -155
  125. package/dist/webpack-plugin/loader.cjs.map +0 -1
  126. package/dist/webpack-plugin/loader.d.cts +0 -3
  127. package/dist/webpack-plugin/loader.d.ts +0 -3
  128. package/dist/webpack-plugin/loader.js +0 -122
  129. package/dist/webpack-plugin/loader.js.map +0 -1
  130. /package/{builders.json → packages/angular-grab/builders.json} +0 -0
  131. /package/{dist → packages/angular-grab/src}/builder/builders/application/schema.json +0 -0
  132. /package/{dist → packages/angular-grab/src}/builder/builders/dev-server/schema.json +0 -0
@@ -0,0 +1,216 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
3
+ import type { ComponentStackEntry } from '../../core';
4
+
5
+ vi.mock('../resolvers/component-resolver', () => ({
6
+ resolveComponent: vi.fn(),
7
+ }));
8
+
9
+ vi.mock('../resolvers/source-resolver', () => ({
10
+ resolveSource: vi.fn(),
11
+ resolveSourceForComponent: vi.fn(),
12
+ }));
13
+
14
+ import { buildContext } from '../resolvers/context-builder';
15
+ import { resolveComponent } from '../resolvers/component-resolver';
16
+ import { resolveSource, resolveSourceForComponent } from '../resolvers/source-resolver';
17
+
18
+ const mockedResolveComponent = vi.mocked(resolveComponent);
19
+ const mockedResolveSource = vi.mocked(resolveSource);
20
+ const mockedResolveSourceForComponent = vi.mocked(resolveSourceForComponent);
21
+
22
+ beforeEach(() => {
23
+ vi.clearAllMocks();
24
+
25
+ mockedResolveComponent.mockReturnValue({
26
+ name: null,
27
+ hostElement: null,
28
+ stack: [],
29
+ });
30
+
31
+ mockedResolveSource.mockReturnValue({
32
+ filePath: null,
33
+ line: null,
34
+ column: null,
35
+ });
36
+
37
+ mockedResolveSourceForComponent.mockReturnValue({
38
+ filePath: null,
39
+ line: null,
40
+ column: null,
41
+ });
42
+ });
43
+
44
+ describe('buildContext', () => {
45
+ it('produces correct ElementContext shape', () => {
46
+ const el = document.createElement('div');
47
+ el.innerHTML = 'hello';
48
+
49
+ const ctx = buildContext(el);
50
+
51
+ expect(ctx).toEqual(
52
+ expect.objectContaining({
53
+ element: el,
54
+ html: expect.any(String),
55
+ componentName: null,
56
+ filePath: null,
57
+ line: null,
58
+ column: null,
59
+ componentStack: [],
60
+ selector: expect.any(String),
61
+ cssClasses: expect.any(Array),
62
+ }),
63
+ );
64
+ });
65
+
66
+ it('includes component name from resolver', () => {
67
+ mockedResolveComponent.mockReturnValue({
68
+ name: 'MyComponent',
69
+ hostElement: null,
70
+ stack: [],
71
+ });
72
+
73
+ const el = document.createElement('div');
74
+ const ctx = buildContext(el);
75
+
76
+ expect(ctx.componentName).toBe('MyComponent');
77
+ });
78
+
79
+ it('includes file path from source resolver', () => {
80
+ mockedResolveSource.mockReturnValue({
81
+ filePath: 'src/app/app.component.ts',
82
+ line: 42,
83
+ column: 3,
84
+ });
85
+
86
+ const el = document.createElement('div');
87
+ const ctx = buildContext(el);
88
+
89
+ expect(ctx.filePath).toBe('src/app/app.component.ts');
90
+ expect(ctx.line).toBe(42);
91
+ expect(ctx.column).toBe(3);
92
+ });
93
+
94
+ it('builds component stack from resolver stack', () => {
95
+ mockedResolveComponent.mockReturnValue({
96
+ name: 'Child',
97
+ hostElement: null,
98
+ stack: [
99
+ { name: 'Child', hostElement: null },
100
+ { name: 'Parent', hostElement: null },
101
+ ],
102
+ });
103
+ mockedResolveSourceForComponent
104
+ .mockReturnValueOnce({ filePath: 'child.ts', line: 5, column: null })
105
+ .mockReturnValueOnce({ filePath: 'parent.ts', line: 20, column: null });
106
+
107
+ const el = document.createElement('div');
108
+ const ctx = buildContext(el);
109
+
110
+ expect(ctx.componentStack).toHaveLength(2);
111
+ expect(ctx.componentStack[0]).toEqual({
112
+ name: 'Child',
113
+ filePath: 'child.ts',
114
+ line: 5,
115
+ column: null,
116
+ });
117
+ expect(ctx.componentStack[1]).toEqual({
118
+ name: 'Parent',
119
+ filePath: 'parent.ts',
120
+ line: 20,
121
+ column: null,
122
+ });
123
+ });
124
+
125
+ it('truncates long HTML with truncation marker', () => {
126
+ const el = document.createElement('div');
127
+ // Create content longer than MAX_HTML_LENGTH (2000)
128
+ el.textContent = 'x'.repeat(2100);
129
+
130
+ const ctx = buildContext(el);
131
+
132
+ expect(ctx.html.length).toBeLessThanOrEqual(2000 + '<!-- truncated -->'.length);
133
+ expect(ctx.html).toContain('<!-- truncated -->');
134
+ });
135
+
136
+ it('does not truncate short HTML', () => {
137
+ const el = document.createElement('div');
138
+ el.textContent = 'short';
139
+
140
+ const ctx = buildContext(el);
141
+
142
+ expect(ctx.html).not.toContain('<!-- truncated -->');
143
+ expect(ctx.html).toContain('short');
144
+ });
145
+
146
+ it('generates CSS selector for element', () => {
147
+ const el = document.createElement('div');
148
+ el.id = 'myid';
149
+ document.body.appendChild(el);
150
+
151
+ const ctx = buildContext(el);
152
+ expect(ctx.selector).toContain('div#myid');
153
+
154
+ document.body.removeChild(el);
155
+ });
156
+
157
+ it('generates selector with classes', () => {
158
+ const el = document.createElement('span');
159
+ el.className = 'foo bar';
160
+ document.body.appendChild(el);
161
+
162
+ const ctx = buildContext(el);
163
+ expect(ctx.selector).toContain('span.foo.bar');
164
+
165
+ document.body.removeChild(el);
166
+ });
167
+
168
+ it('generates selector with nth-of-type for siblings', () => {
169
+ const parent = document.createElement('div');
170
+ const child1 = document.createElement('span');
171
+ const child2 = document.createElement('span');
172
+ parent.appendChild(child1);
173
+ parent.appendChild(child2);
174
+ document.body.appendChild(parent);
175
+
176
+ const ctx = buildContext(child2);
177
+ expect(ctx.selector).toContain('nth-of-type(2)');
178
+
179
+ document.body.removeChild(parent);
180
+ });
181
+
182
+ it('filters out Angular CSS classes', () => {
183
+ const el = document.createElement('div');
184
+ el.classList.add('my-class', 'ng-star-inserted', '_ngcontent-abc', 'real-class');
185
+
186
+ const ctx = buildContext(el);
187
+
188
+ expect(ctx.cssClasses).toContain('my-class');
189
+ expect(ctx.cssClasses).toContain('real-class');
190
+ expect(ctx.cssClasses).not.toContain('ng-star-inserted');
191
+ expect(ctx.cssClasses).not.toContain('_ngcontent-abc');
192
+ });
193
+
194
+ it('handles element with no classes', () => {
195
+ const el = document.createElement('div');
196
+ const ctx = buildContext(el);
197
+ expect(ctx.cssClasses).toEqual([]);
198
+ });
199
+
200
+ it('stops selector at element with id', () => {
201
+ const grandparent = document.createElement('section');
202
+ const parent = document.createElement('div');
203
+ parent.id = 'stop-here';
204
+ const child = document.createElement('span');
205
+ grandparent.appendChild(parent);
206
+ parent.appendChild(child);
207
+ document.body.appendChild(grandparent);
208
+
209
+ const ctx = buildContext(child);
210
+ // The selector should start from div#stop-here because id terminates the walk
211
+ expect(ctx.selector).toContain('div#stop-here');
212
+ expect(ctx.selector).toContain('span');
213
+
214
+ document.body.removeChild(grandparent);
215
+ });
216
+ });
@@ -0,0 +1,62 @@
1
+ import { init } from '../core';
2
+ import type { AngularGrabAPI, AngularGrabOptions, Plugin } from '../core';
3
+ import { resolveComponent } from './resolvers/component-resolver';
4
+ import { resolveSource } from './resolvers/source-resolver';
5
+
6
+ declare const ngDevMode: boolean | undefined;
7
+
8
+ let instance: AngularGrabAPI | null = null;
9
+
10
+ /**
11
+ * Initialize angular-grab. Registers Angular-specific component and source
12
+ * resolvers, then returns the API handle. Idempotent — subsequent calls
13
+ * return the same instance.
14
+ */
15
+ export function initAngularGrab(options?: Partial<AngularGrabOptions>): AngularGrabAPI {
16
+ if (instance) return instance;
17
+
18
+ // No-op in production
19
+ if (options?.devOnly !== false && typeof ngDevMode !== 'undefined' && !ngDevMode) {
20
+ instance = createNoOpApi();
21
+ return instance;
22
+ }
23
+
24
+ instance = init(options);
25
+ instance.setComponentResolver((el) => resolveComponent(el));
26
+ instance.setSourceResolver((el) => resolveSource(el));
27
+
28
+ return instance;
29
+ }
30
+
31
+ export function getAngularGrabApi(): AngularGrabAPI | null {
32
+ return instance;
33
+ }
34
+
35
+ export function registerAngularGrabPlugin(plugin: Plugin): void {
36
+ instance?.registerPlugin(plugin);
37
+ }
38
+
39
+ export function disposeAngularGrab(): void {
40
+ instance?.dispose();
41
+ instance = null;
42
+ }
43
+
44
+ function createNoOpApi(): AngularGrabAPI {
45
+ return {
46
+ activate() {},
47
+ deactivate() {},
48
+ toggle() {},
49
+ isActive() { return false; },
50
+ setOptions() {},
51
+ registerPlugin() {},
52
+ unregisterPlugin() {},
53
+ setComponentResolver() {},
54
+ setSourceResolver() {},
55
+ showToolbar() {},
56
+ hideToolbar() {},
57
+ setThemeMode() {},
58
+ getHistory() { return []; },
59
+ clearHistory() {},
60
+ dispose() {},
61
+ };
62
+ }
@@ -0,0 +1,13 @@
1
+ // Resolvers
2
+ export { resolveComponent } from './resolvers/component-resolver';
3
+ export { resolveSource } from './resolvers/source-resolver';
4
+ export { buildContext } from './resolvers/context-builder';
5
+
6
+ // Angular integration
7
+ export {
8
+ initAngularGrab,
9
+ getAngularGrabApi,
10
+ registerAngularGrabPlugin,
11
+ disposeAngularGrab,
12
+ } from './angular-grab.service';
13
+ export { provideAngularGrab, ANGULAR_GRAB_API } from './provide-angular-grab';
@@ -0,0 +1,22 @@
1
+ import {
2
+ InjectionToken,
3
+ makeEnvironmentProviders,
4
+ provideEnvironmentInitializer,
5
+ type EnvironmentProviders,
6
+ } from '@angular/core';
7
+ import type { AngularGrabOptions, AngularGrabAPI } from '../core';
8
+ import { initAngularGrab } from './angular-grab.service';
9
+
10
+ export const ANGULAR_GRAB_API = new InjectionToken<AngularGrabAPI>('ANGULAR_GRAB_API');
11
+
12
+ export function provideAngularGrab(
13
+ options?: Partial<AngularGrabOptions>,
14
+ ): EnvironmentProviders {
15
+ return makeEnvironmentProviders([
16
+ {
17
+ provide: ANGULAR_GRAB_API,
18
+ useFactory: () => initAngularGrab(options),
19
+ },
20
+ provideEnvironmentInitializer(() => initAngularGrab(options)),
21
+ ]);
22
+ }
@@ -0,0 +1,71 @@
1
+ import { getNgApi, cleanComponentName } from './ng-utils';
2
+
3
+ export function resolveComponent(element: Element): {
4
+ name: string | null;
5
+ hostElement: Element | null;
6
+ stack: Array<{ name: string; hostElement: Element | null }>;
7
+ } {
8
+ const ng = getNgApi();
9
+ if (!ng) return { name: null, hostElement: null, stack: [] };
10
+
11
+ const stack: Array<{ name: string; hostElement: Element | null }> = [];
12
+ const seen = new Set<any>();
13
+
14
+ // Walk up the DOM collecting every Angular component
15
+ let current: Element | null = element;
16
+ while (current) {
17
+ const comp = ng.getComponent(current);
18
+ if (comp && !seen.has(comp)) {
19
+ seen.add(comp);
20
+ stack.push({
21
+ name: cleanComponentName(comp.constructor.name),
22
+ hostElement: current,
23
+ });
24
+ }
25
+ current = current.parentElement;
26
+ }
27
+
28
+ // If no direct component found, try getOwningComponent for the original element
29
+ if (stack.length === 0) {
30
+ let walk: Element | null = element;
31
+ while (walk) {
32
+ const owning = ng.getOwningComponent(walk);
33
+ if (owning && !seen.has(owning)) {
34
+ seen.add(owning);
35
+ // Find the host element
36
+ let host: Element | null = walk.parentElement;
37
+ while (host) {
38
+ if (ng.getComponent(host) === owning) break;
39
+ host = host.parentElement;
40
+ }
41
+ stack.push({
42
+ name: cleanComponentName(owning.constructor.name),
43
+ hostElement: host ?? walk.parentElement,
44
+ });
45
+ // Continue walking from the host to collect parents
46
+ current = host?.parentElement ?? walk.parentElement?.parentElement ?? null;
47
+ while (current) {
48
+ const comp = ng.getComponent(current);
49
+ if (comp && !seen.has(comp)) {
50
+ seen.add(comp);
51
+ stack.push({
52
+ name: cleanComponentName(comp.constructor.name),
53
+ hostElement: current,
54
+ });
55
+ }
56
+ current = current.parentElement;
57
+ }
58
+ break;
59
+ }
60
+ walk = walk.parentElement;
61
+ }
62
+ }
63
+
64
+ const closest = stack.length > 0 ? stack[0] : { name: null, hostElement: null };
65
+
66
+ return {
67
+ name: closest.name,
68
+ hostElement: closest.hostElement,
69
+ stack,
70
+ };
71
+ }
@@ -0,0 +1,86 @@
1
+ import { filterAngularClasses, type ElementContext, type ComponentStackEntry } from '../../core';
2
+ import { resolveComponent } from './component-resolver';
3
+ import { resolveSource, resolveSourceForComponent } from './source-resolver';
4
+
5
+ const MAX_HTML_LENGTH = 2000;
6
+
7
+ export function buildContext(element: Element): ElementContext {
8
+ const compResult = resolveComponent(element);
9
+ const { filePath, line, column } = resolveSource(element);
10
+ const html = extractHtml(element);
11
+ const selector = generateSelector(element);
12
+
13
+ const cssClasses = filterAngularClasses(element.classList);
14
+
15
+ const componentStack: ComponentStackEntry[] = compResult.stack.map((entry) => {
16
+ const src = resolveSourceForComponent(entry.name);
17
+ return {
18
+ name: entry.name,
19
+ filePath: src.filePath,
20
+ line: src.line,
21
+ column: src.column,
22
+ };
23
+ });
24
+
25
+ return {
26
+ element,
27
+ html,
28
+ componentName: compResult.name,
29
+ filePath,
30
+ line,
31
+ column,
32
+ componentStack,
33
+ selector,
34
+ cssClasses,
35
+ };
36
+ }
37
+
38
+ function extractHtml(element: Element): string {
39
+ const html = element.outerHTML;
40
+ if (html.length <= MAX_HTML_LENGTH) return html;
41
+ return html.slice(0, MAX_HTML_LENGTH) + '<!-- truncated -->';
42
+ }
43
+
44
+ function generateSelector(element: Element): string {
45
+ const parts: string[] = [];
46
+ let current: Element | null = element;
47
+
48
+ while (current && current !== document.documentElement) {
49
+ let selector = current.tagName.toLowerCase();
50
+
51
+ if (current.id) {
52
+ selector += `#${current.id}`;
53
+ parts.unshift(selector);
54
+ break;
55
+ }
56
+
57
+ const className = current.getAttribute('class');
58
+ if (className) {
59
+ const classes = className
60
+ .trim()
61
+ .split(/\s+/)
62
+ .filter(Boolean)
63
+ .slice(0, 3);
64
+ if (classes.length > 0) {
65
+ selector += '.' + classes.join('.');
66
+ }
67
+ }
68
+
69
+ // Add nth-of-type if there are sibling elements with the same tag
70
+ const parent = current.parentElement;
71
+ if (parent) {
72
+ const siblings = Array.from(parent.children).filter(
73
+ (s) => s.tagName === current!.tagName
74
+ );
75
+ if (siblings.length > 1) {
76
+ const index = siblings.indexOf(current) + 1;
77
+ selector += `:nth-of-type(${index})`;
78
+ }
79
+ }
80
+
81
+ parts.unshift(selector);
82
+ current = current.parentElement;
83
+ }
84
+
85
+ return parts.join(' > ');
86
+ }
@@ -0,0 +1,14 @@
1
+ export interface NgDebugApi {
2
+ getComponent<T>(element: Element): T | null;
3
+ getOwningComponent<T>(element: Element): T | null;
4
+ getDirectives(element: Element): any[];
5
+ }
6
+
7
+ export function getNgApi(): NgDebugApi | null {
8
+ if (typeof window === 'undefined') return null;
9
+ return (window as any).ng || null;
10
+ }
11
+
12
+ export function cleanComponentName(name: string): string {
13
+ return name.startsWith('_') ? name.slice(1) : name;
14
+ }
@@ -0,0 +1,61 @@
1
+ import { type NgDebugApi, getNgApi, cleanComponentName } from './ng-utils';
2
+
3
+ interface SourceMapEntry {
4
+ file: string;
5
+ line: number;
6
+ }
7
+
8
+ function getSourceMap(): Record<string, SourceMapEntry> | null {
9
+ return (globalThis as any).__ANGULAR_GRAB_SOURCE_MAP__ ?? null;
10
+ }
11
+
12
+ export function resolveSource(element: Element): {
13
+ filePath: string | null;
14
+ line: number | null;
15
+ column: number | null;
16
+ } {
17
+ const sourceMap = getSourceMap();
18
+ if (!sourceMap) return { filePath: null, line: null, column: null };
19
+
20
+ const ng = getNgApi();
21
+ if (!ng) return { filePath: null, line: null, column: null };
22
+
23
+ // Find the component that owns this element
24
+ const componentName = findComponentName(element, ng);
25
+ if (!componentName) return { filePath: null, line: null, column: null };
26
+
27
+ const entry = sourceMap[componentName];
28
+ if (!entry) return { filePath: null, line: null, column: null };
29
+
30
+ return { filePath: entry.file, line: entry.line, column: null };
31
+ }
32
+
33
+ export function resolveSourceForComponent(componentName: string): {
34
+ filePath: string | null;
35
+ line: number | null;
36
+ column: number | null;
37
+ } {
38
+ const sourceMap = getSourceMap();
39
+ if (!sourceMap) return { filePath: null, line: null, column: null };
40
+
41
+ const entry = sourceMap[componentName];
42
+ if (!entry) return { filePath: null, line: null, column: null };
43
+
44
+ return { filePath: entry.file, line: entry.line, column: null };
45
+ }
46
+
47
+ function findComponentName(element: Element, ng: NgDebugApi): string | null {
48
+ // Try direct component on this element
49
+ const direct = ng.getComponent(element);
50
+ if (direct) return cleanComponentName(direct.constructor.name);
51
+
52
+ // Walk up to find owning component
53
+ let current: Element | null = element;
54
+ while (current) {
55
+ const owning = ng.getOwningComponent(current);
56
+ if (owning) return cleanComponentName(owning.constructor.name);
57
+ current = current.parentElement;
58
+ }
59
+
60
+ return null;
61
+ }
@@ -0,0 +1,72 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { readFileSync } from 'fs';
3
+ import { join } from 'path';
4
+
5
+ const PACKAGE_ROOT = join(__dirname, '..', '..', '..');
6
+
7
+ describe('builders.json', () => {
8
+ const buildersJson = JSON.parse(
9
+ readFileSync(join(PACKAGE_ROOT, 'builders.json'), 'utf8'),
10
+ );
11
+
12
+ it('has a builders property', () => {
13
+ expect(buildersJson.builders).toBeDefined();
14
+ expect(typeof buildersJson.builders).toBe('object');
15
+ });
16
+
17
+ it('defines an application builder', () => {
18
+ const app = buildersJson.builders.application;
19
+ expect(app).toBeDefined();
20
+ expect(app.implementation).toBeDefined();
21
+ expect(app.schema).toBeDefined();
22
+ expect(app.description).toBeDefined();
23
+ });
24
+
25
+ it('defines a dev-server builder', () => {
26
+ const devServer = buildersJson.builders['dev-server'];
27
+ expect(devServer).toBeDefined();
28
+ expect(devServer.implementation).toBeDefined();
29
+ expect(devServer.schema).toBeDefined();
30
+ expect(devServer.description).toBeDefined();
31
+ });
32
+
33
+ it('application builder points to correct implementation path', () => {
34
+ const app = buildersJson.builders.application;
35
+ expect(app.implementation).toContain('builders/application');
36
+ });
37
+
38
+ it('dev-server builder points to correct implementation path', () => {
39
+ const devServer = buildersJson.builders['dev-server'];
40
+ expect(devServer.implementation).toContain('builders/dev-server');
41
+ });
42
+ });
43
+
44
+ describe('package.json', () => {
45
+ const packageJson = JSON.parse(
46
+ readFileSync(join(PACKAGE_ROOT, 'package.json'), 'utf8'),
47
+ );
48
+
49
+ it('declares builders field pointing to builders.json', () => {
50
+ expect(packageJson.builders).toBe('./builders.json');
51
+ });
52
+
53
+ it('exports builders.json', () => {
54
+ expect(packageJson.exports['./builders.json']).toBe('./builders.json');
55
+ });
56
+ });
57
+
58
+ describe('schema files', () => {
59
+ it('application schema is valid JSON', () => {
60
+ const schema = JSON.parse(
61
+ readFileSync(join(PACKAGE_ROOT, 'src', 'builder', 'builders', 'application', 'schema.json'), 'utf8'),
62
+ );
63
+ expect(schema.type).toBe('object');
64
+ });
65
+
66
+ it('dev-server schema is valid JSON', () => {
67
+ const schema = JSON.parse(
68
+ readFileSync(join(PACKAGE_ROOT, 'src', 'builder', 'builders', 'dev-server', 'schema.json'), 'utf8'),
69
+ );
70
+ expect(schema.type).toBe('object');
71
+ });
72
+ });
@@ -0,0 +1,13 @@
1
+ import { createBuilder } from '@angular-devkit/architect';
2
+ import { buildApplication } from '@angular/build';
3
+ import { angularGrabEsbuildPlugin } from '../../../esbuild-plugin';
4
+
5
+ export default createBuilder(async function* (options: any, context) {
6
+ const isProduction = !!options.optimization;
7
+ yield* buildApplication(options, context, {
8
+ codePlugins: [angularGrabEsbuildPlugin({
9
+ rootDir: context.workspaceRoot,
10
+ enabled: !isProduction,
11
+ })],
12
+ });
13
+ });
@@ -0,0 +1,9 @@
1
+ import { createBuilder } from '@angular-devkit/architect';
2
+ import { executeDevServerBuilder } from '@angular/build';
3
+ import { angularGrabEsbuildPlugin } from '../../../esbuild-plugin';
4
+
5
+ export default createBuilder(async function* (options: any, context) {
6
+ yield* executeDevServerBuilder(options, context, {
7
+ buildPlugins: [angularGrabEsbuildPlugin({ rootDir: context.workspaceRoot })],
8
+ });
9
+ });
@@ -0,0 +1,3 @@
1
+ // angular-grab/builder
2
+ // This package provides Angular CLI builders that inject angular-grab source location mapping.
3
+ // Builders are resolved via builders.json — this file is not directly imported by users.