angular-grab 0.1.0 → 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.
- package/README.md +215 -0
- package/examples/angular-19-app/.editorconfig +17 -0
- package/examples/angular-19-app/.vscode/extensions.json +4 -0
- package/examples/angular-19-app/.vscode/launch.json +20 -0
- package/examples/angular-19-app/.vscode/mcp.json +9 -0
- package/examples/angular-19-app/.vscode/tasks.json +42 -0
- package/examples/angular-19-app/README.md +59 -0
- package/examples/angular-19-app/angular.json +74 -0
- package/examples/angular-19-app/package.json +44 -0
- package/examples/angular-19-app/public/favicon.ico +0 -0
- package/examples/angular-19-app/src/app/app.config.ts +13 -0
- package/examples/angular-19-app/src/app/app.css +37 -0
- package/examples/angular-19-app/src/app/app.html +25 -0
- package/examples/angular-19-app/src/app/app.routes.ts +3 -0
- package/examples/angular-19-app/src/app/app.spec.ts +23 -0
- package/examples/angular-19-app/src/app/app.ts +12 -0
- package/examples/angular-19-app/src/app/button/button.component.ts +25 -0
- package/examples/angular-19-app/src/app/card/card.component.ts +33 -0
- package/examples/angular-19-app/src/app/header/header.component.ts +31 -0
- package/examples/angular-19-app/src/app/popover/popover.component.ts +133 -0
- package/examples/angular-19-app/src/index.html +13 -0
- package/examples/angular-19-app/src/main.ts +6 -0
- package/examples/angular-19-app/src/styles.css +1 -0
- package/examples/angular-19-app/tsconfig.app.json +15 -0
- package/examples/angular-19-app/tsconfig.json +33 -0
- package/examples/angular-19-app/tsconfig.spec.json +15 -0
- package/package.json +14 -111
- package/packages/angular-grab/package.json +96 -0
- package/packages/angular-grab/src/angular/__tests__/context-builder.test.ts +216 -0
- package/packages/angular-grab/src/angular/angular-grab.service.ts +62 -0
- package/packages/angular-grab/src/angular/index.ts +13 -0
- package/packages/angular-grab/src/angular/provide-angular-grab.ts +22 -0
- package/packages/angular-grab/src/angular/resolvers/component-resolver.ts +71 -0
- package/packages/angular-grab/src/angular/resolvers/context-builder.ts +86 -0
- package/packages/angular-grab/src/angular/resolvers/ng-utils.ts +14 -0
- package/packages/angular-grab/src/angular/resolvers/source-resolver.ts +61 -0
- package/packages/angular-grab/src/builder/__tests__/builder.test.ts +72 -0
- package/packages/angular-grab/src/builder/builders/application/index.ts +13 -0
- package/packages/angular-grab/src/builder/builders/dev-server/index.ts +9 -0
- package/packages/angular-grab/src/builder/index.ts +3 -0
- package/packages/angular-grab/src/cli/__tests__/cli.test.ts +239 -0
- package/packages/angular-grab/src/cli/commands/init.ts +106 -0
- package/packages/angular-grab/src/cli/index.ts +15 -0
- package/packages/angular-grab/src/cli/utils/detect-project.ts +78 -0
- package/packages/angular-grab/src/cli/utils/modify-angular-json.ts +42 -0
- package/packages/angular-grab/src/cli/utils/modify-app-config.ts +42 -0
- package/packages/angular-grab/src/core/__tests__/generate-snippet.test.ts +149 -0
- package/packages/angular-grab/src/core/__tests__/plugin-registry.test.ts +286 -0
- package/packages/angular-grab/src/core/__tests__/store.test.ts +118 -0
- package/packages/angular-grab/src/core/__tests__/utils.test.ts +85 -0
- package/packages/angular-grab/src/core/clipboard/copy.ts +104 -0
- package/packages/angular-grab/src/core/clipboard/generate-snippet.ts +38 -0
- package/packages/angular-grab/src/core/constants.ts +10 -0
- package/packages/angular-grab/src/core/grab.ts +596 -0
- package/packages/angular-grab/src/core/index.global.ts +13 -0
- package/packages/angular-grab/src/core/index.ts +19 -0
- package/packages/angular-grab/src/core/keyboard/keyboard-handler.ts +163 -0
- package/packages/angular-grab/src/core/overlay/crosshair.ts +107 -0
- package/packages/angular-grab/src/core/overlay/freeze-overlay.ts +239 -0
- package/packages/angular-grab/src/core/overlay/overlay-renderer.ts +180 -0
- package/packages/angular-grab/src/core/overlay/select-feedback.ts +108 -0
- package/packages/angular-grab/src/core/overlay/toast.ts +175 -0
- package/packages/angular-grab/src/core/picker/element-picker.ts +114 -0
- package/packages/angular-grab/src/core/plugins/plugin-registry.ts +83 -0
- package/packages/angular-grab/src/core/store.ts +52 -0
- package/packages/angular-grab/src/core/toolbar/actions-menu.ts +178 -0
- package/packages/angular-grab/src/core/toolbar/comment-popover.ts +235 -0
- package/packages/angular-grab/src/core/toolbar/copy-actions.ts +98 -0
- package/packages/angular-grab/src/core/toolbar/history-popover.ts +245 -0
- package/packages/angular-grab/src/core/toolbar/theme-manager.ts +188 -0
- package/packages/angular-grab/src/core/toolbar/toolbar-icons.ts +29 -0
- package/packages/angular-grab/src/core/toolbar/toolbar-renderer.ts +239 -0
- package/packages/angular-grab/src/core/types.ts +139 -0
- package/packages/angular-grab/src/core/utils.ts +16 -0
- package/packages/angular-grab/src/esbuild-plugin/__tests__/transform.test.ts +174 -0
- package/packages/angular-grab/src/esbuild-plugin/index.ts +3 -0
- package/packages/angular-grab/src/esbuild-plugin/plugin.ts +29 -0
- package/packages/angular-grab/src/esbuild-plugin/scan.ts +105 -0
- package/packages/angular-grab/src/esbuild-plugin/transform.ts +152 -0
- package/packages/angular-grab/src/vite-plugin/__tests__/plugin.test.ts +84 -0
- package/packages/angular-grab/src/vite-plugin/index.ts +19 -0
- package/packages/angular-grab/src/webpack-plugin/__tests__/plugin.test.ts +72 -0
- package/packages/angular-grab/src/webpack-plugin/index.ts +2 -0
- package/packages/angular-grab/src/webpack-plugin/loader.ts +15 -0
- package/packages/angular-grab/src/webpack-plugin/plugin.ts +20 -0
- package/packages/angular-grab/tsconfig.json +15 -0
- package/packages/angular-grab/tsup.config.ts +119 -0
- package/pnpm-workspace.yaml +3 -0
- package/turbo.json +21 -0
- package/dist/angular/index.d.ts +0 -151
- package/dist/angular/index.js +0 -2811
- package/dist/angular/index.js.map +0 -1
- package/dist/builder/builders/application/index.js +0 -143
- package/dist/builder/builders/application/index.js.map +0 -1
- package/dist/builder/builders/dev-server/index.js +0 -139
- package/dist/builder/builders/dev-server/index.js.map +0 -1
- package/dist/builder/index.js +0 -2
- package/dist/builder/index.js.map +0 -1
- package/dist/builder/package.json +0 -1
- package/dist/cli/index.js +0 -223
- package/dist/cli/index.js.map +0 -1
- package/dist/core/index.cjs +0 -2589
- package/dist/core/index.cjs.map +0 -1
- package/dist/core/index.d.cts +0 -139
- package/dist/core/index.d.ts +0 -139
- package/dist/core/index.global.js +0 -542
- package/dist/core/index.js +0 -2560
- package/dist/core/index.js.map +0 -1
- package/dist/esbuild-plugin/index.cjs +0 -239
- package/dist/esbuild-plugin/index.cjs.map +0 -1
- package/dist/esbuild-plugin/index.d.cts +0 -26
- package/dist/esbuild-plugin/index.d.ts +0 -26
- package/dist/esbuild-plugin/index.js +0 -200
- package/dist/esbuild-plugin/index.js.map +0 -1
- package/dist/vite-plugin/index.d.ts +0 -7
- package/dist/vite-plugin/index.js +0 -128
- package/dist/vite-plugin/index.js.map +0 -1
- package/dist/webpack-plugin/index.cjs +0 -54
- package/dist/webpack-plugin/index.cjs.map +0 -1
- package/dist/webpack-plugin/index.d.cts +0 -5
- package/dist/webpack-plugin/index.d.ts +0 -5
- package/dist/webpack-plugin/index.js +0 -23
- package/dist/webpack-plugin/index.js.map +0 -1
- package/dist/webpack-plugin/loader.cjs +0 -155
- package/dist/webpack-plugin/loader.cjs.map +0 -1
- package/dist/webpack-plugin/loader.d.cts +0 -3
- package/dist/webpack-plugin/loader.d.ts +0 -3
- package/dist/webpack-plugin/loader.js +0 -122
- package/dist/webpack-plugin/loader.js.map +0 -1
- /package/{builders.json → packages/angular-grab/builders.json} +0 -0
- /package/{dist → packages/angular-grab/src}/builder/builders/application/schema.json +0 -0
- /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
|
+
});
|