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,174 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { transformAngularComponent } from '../transform';
|
|
3
|
+
|
|
4
|
+
const ROOT_DIR = '/project';
|
|
5
|
+
|
|
6
|
+
function transform(code: string, filePath = '/project/src/app/example.component.ts') {
|
|
7
|
+
return transformAngularComponent(code, filePath, ROOT_DIR);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
describe('transformAngularComponent', () => {
|
|
11
|
+
it('adds host with data-ng-source to a basic component', () => {
|
|
12
|
+
const input = `@Component({ selector: 'app-example', templateUrl: './example.component.html' })
|
|
13
|
+
export class ExampleComponent {}`;
|
|
14
|
+
|
|
15
|
+
const result = transform(input);
|
|
16
|
+
expect(result).not.toBeNull();
|
|
17
|
+
expect(result!.code).toContain("host: { 'data-ng-source': 'src/app/example.component.ts:0:0' }");
|
|
18
|
+
expect(result!.code).toContain('@Component(');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('merges into an existing host property', () => {
|
|
22
|
+
const input = `@Component({
|
|
23
|
+
selector: 'app-example',
|
|
24
|
+
host: { 'class': 'block' }
|
|
25
|
+
})
|
|
26
|
+
export class ExampleComponent {}`;
|
|
27
|
+
|
|
28
|
+
const result = transform(input);
|
|
29
|
+
expect(result).not.toBeNull();
|
|
30
|
+
expect(result!.code).toContain("'class': 'block'");
|
|
31
|
+
expect(result!.code).toContain("'data-ng-source': 'src/app/example.component.ts:0:0'");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('is idempotent — skips if data-ng-source already exists', () => {
|
|
35
|
+
const input = `@Component({
|
|
36
|
+
selector: 'app-example',
|
|
37
|
+
host: { 'data-ng-source': 'src/app/example.component.ts:0:0' }
|
|
38
|
+
})
|
|
39
|
+
export class ExampleComponent {}`;
|
|
40
|
+
|
|
41
|
+
const result = transform(input);
|
|
42
|
+
expect(result).toBeNull();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('handles multiple components in one file', () => {
|
|
46
|
+
const input = `@Component({ selector: 'app-first' })
|
|
47
|
+
export class FirstComponent {}
|
|
48
|
+
|
|
49
|
+
@Component({ selector: 'app-second' })
|
|
50
|
+
export class SecondComponent {}`;
|
|
51
|
+
|
|
52
|
+
const result = transform(input);
|
|
53
|
+
expect(result).not.toBeNull();
|
|
54
|
+
|
|
55
|
+
// Both should have data-ng-source with correct line numbers
|
|
56
|
+
expect(result!.code).toContain("'data-ng-source': 'src/app/example.component.ts:0:0'");
|
|
57
|
+
expect(result!.code).toContain("'data-ng-source': 'src/app/example.component.ts:3:0'");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('returns null when no @Component decorator is present', () => {
|
|
61
|
+
const input = `export class PlainService {
|
|
62
|
+
constructor() {}
|
|
63
|
+
}`;
|
|
64
|
+
|
|
65
|
+
const result = transform(input);
|
|
66
|
+
expect(result).toBeNull();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('handles empty decorator object @Component({})', () => {
|
|
70
|
+
const input = `@Component({})
|
|
71
|
+
export class EmptyComponent {}`;
|
|
72
|
+
|
|
73
|
+
const result = transform(input);
|
|
74
|
+
expect(result).not.toBeNull();
|
|
75
|
+
expect(result!.code).toContain("host: { 'data-ng-source': 'src/app/example.component.ts:0:0' }");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('handles standalone component', () => {
|
|
79
|
+
const input = `@Component({
|
|
80
|
+
selector: 'app-standalone',
|
|
81
|
+
standalone: true,
|
|
82
|
+
template: '<p>hello</p>'
|
|
83
|
+
})
|
|
84
|
+
export class StandaloneComponent {}`;
|
|
85
|
+
|
|
86
|
+
const result = transform(input);
|
|
87
|
+
expect(result).not.toBeNull();
|
|
88
|
+
expect(result!.code).toContain('standalone: true');
|
|
89
|
+
expect(result!.code).toContain("host: { 'data-ng-source': 'src/app/example.component.ts:0:0' }");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('uses correct line numbers (0-indexed)', () => {
|
|
93
|
+
const input = `import { Component } from '@angular/core';
|
|
94
|
+
|
|
95
|
+
// Some comment
|
|
96
|
+
@Component({ selector: 'app-offset' })
|
|
97
|
+
export class OffsetComponent {}`;
|
|
98
|
+
|
|
99
|
+
const result = transform(input);
|
|
100
|
+
expect(result).not.toBeNull();
|
|
101
|
+
// Decorator is on line 3 (0-indexed)
|
|
102
|
+
expect(result!.code).toContain("'data-ng-source': 'src/app/example.component.ts:3:0'");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('computes correct relative paths including nested directories', () => {
|
|
106
|
+
const filePath = '/project/src/app/features/auth/login/login.component.ts';
|
|
107
|
+
const input = `@Component({ selector: 'app-login', templateUrl: './login.component.html' })
|
|
108
|
+
export class LoginComponent {}`;
|
|
109
|
+
|
|
110
|
+
const result = transformAngularComponent(input, filePath, ROOT_DIR);
|
|
111
|
+
expect(result).not.toBeNull();
|
|
112
|
+
expect(result!.code).toContain(
|
|
113
|
+
"'data-ng-source': 'src/app/features/auth/login/login.component.ts:0:0'",
|
|
114
|
+
);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('handles component with inline template', () => {
|
|
118
|
+
const input = `@Component({
|
|
119
|
+
selector: 'app-inline',
|
|
120
|
+
template: \`
|
|
121
|
+
<div>
|
|
122
|
+
<h1>Hello</h1>
|
|
123
|
+
</div>
|
|
124
|
+
\`
|
|
125
|
+
})
|
|
126
|
+
export class InlineComponent {}`;
|
|
127
|
+
|
|
128
|
+
const result = transform(input);
|
|
129
|
+
expect(result).not.toBeNull();
|
|
130
|
+
expect(result!.code).toContain("host: { 'data-ng-source': 'src/app/example.component.ts:0:0' }");
|
|
131
|
+
// Ensure template is preserved
|
|
132
|
+
expect(result!.code).toContain('<h1>Hello</h1>');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('handles empty decorator call @Component()', () => {
|
|
136
|
+
const input = `@Component()
|
|
137
|
+
export class EmptyCallComponent {}`;
|
|
138
|
+
|
|
139
|
+
const result = transform(input);
|
|
140
|
+
expect(result).not.toBeNull();
|
|
141
|
+
expect(result!.code).toContain("host: { 'data-ng-source': 'src/app/example.component.ts:0:0' }");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('preserves the rest of the file content', () => {
|
|
145
|
+
const input = `import { Component } from '@angular/core';
|
|
146
|
+
|
|
147
|
+
@Component({ selector: 'app-test' })
|
|
148
|
+
export class TestComponent {
|
|
149
|
+
title = 'hello';
|
|
150
|
+
|
|
151
|
+
greet(): string {
|
|
152
|
+
return this.title;
|
|
153
|
+
}
|
|
154
|
+
}`;
|
|
155
|
+
|
|
156
|
+
const result = transform(input);
|
|
157
|
+
expect(result).not.toBeNull();
|
|
158
|
+
expect(result!.code).toContain("title = 'hello'");
|
|
159
|
+
expect(result!.code).toContain('greet(): string');
|
|
160
|
+
expect(result!.code).toContain('return this.title');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('handles existing host with empty object', () => {
|
|
164
|
+
const input = `@Component({
|
|
165
|
+
selector: 'app-empty-host',
|
|
166
|
+
host: {}
|
|
167
|
+
})
|
|
168
|
+
export class EmptyHostComponent {}`;
|
|
169
|
+
|
|
170
|
+
const result = transform(input);
|
|
171
|
+
expect(result).not.toBeNull();
|
|
172
|
+
expect(result!.code).toContain("'data-ng-source': 'src/app/example.component.ts:0:0'");
|
|
173
|
+
});
|
|
174
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { Plugin } from 'esbuild';
|
|
2
|
+
import { scanComponentSources } from './scan';
|
|
3
|
+
|
|
4
|
+
export function angularGrabEsbuildPlugin(options?: {
|
|
5
|
+
rootDir?: string;
|
|
6
|
+
/** Set to false to disable the transform (e.g., in production). Default: true */
|
|
7
|
+
enabled?: boolean;
|
|
8
|
+
}): Plugin {
|
|
9
|
+
return {
|
|
10
|
+
name: 'angular-grab',
|
|
11
|
+
setup(build) {
|
|
12
|
+
if (options?.enabled === false) return;
|
|
13
|
+
|
|
14
|
+
const rootDir = options?.rootDir || process.cwd();
|
|
15
|
+
const sourceMap = scanComponentSources(rootDir);
|
|
16
|
+
|
|
17
|
+
if (Object.keys(sourceMap).length === 0) return;
|
|
18
|
+
|
|
19
|
+
// Inject the source map as a global variable via banner
|
|
20
|
+
const json = JSON.stringify(sourceMap);
|
|
21
|
+
const banner = build.initialOptions.banner || {};
|
|
22
|
+
const existing = typeof banner === 'object' ? (banner.js || '') : '';
|
|
23
|
+
build.initialOptions.banner = {
|
|
24
|
+
...(typeof banner === 'object' ? banner : {}),
|
|
25
|
+
js: `globalThis.__ANGULAR_GRAB_SOURCE_MAP__=${json};${existing}`,
|
|
26
|
+
};
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import ts from 'typescript';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
|
|
5
|
+
export interface ComponentSourceInfo {
|
|
6
|
+
file: string;
|
|
7
|
+
line: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type SourceMap = Record<string, ComponentSourceInfo>;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Scans the project for `.component.ts` files and extracts component
|
|
14
|
+
* class names with their source locations. Returns a map of
|
|
15
|
+
* `{ ComponentName: { file, line } }`.
|
|
16
|
+
*/
|
|
17
|
+
export function scanComponentSources(rootDir: string): SourceMap {
|
|
18
|
+
const sourceMap: SourceMap = {};
|
|
19
|
+
const files = findComponentFiles(rootDir);
|
|
20
|
+
|
|
21
|
+
for (const filePath of files) {
|
|
22
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
23
|
+
if (!content.includes('@Component')) continue;
|
|
24
|
+
|
|
25
|
+
const relativePath = path.relative(rootDir, filePath).replace(/\\/g, '/');
|
|
26
|
+
const components = extractComponents(content, filePath);
|
|
27
|
+
|
|
28
|
+
for (const comp of components) {
|
|
29
|
+
sourceMap[comp.name] = { file: relativePath, line: comp.line };
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return sourceMap;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface ComponentInfo {
|
|
37
|
+
name: string;
|
|
38
|
+
line: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function extractComponents(code: string, filePath: string): ComponentInfo[] {
|
|
42
|
+
const sourceFile = ts.createSourceFile(
|
|
43
|
+
filePath,
|
|
44
|
+
code,
|
|
45
|
+
ts.ScriptTarget.Latest,
|
|
46
|
+
true,
|
|
47
|
+
ts.ScriptKind.TS,
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
const results: ComponentInfo[] = [];
|
|
51
|
+
|
|
52
|
+
function visit(node: ts.Node): void {
|
|
53
|
+
if (ts.isClassDeclaration(node) && node.name) {
|
|
54
|
+
const decorators = ts.getDecorators(node);
|
|
55
|
+
if (!decorators) return;
|
|
56
|
+
|
|
57
|
+
const hasComponent = decorators.some((d) => {
|
|
58
|
+
if (!ts.isCallExpression(d.expression)) return false;
|
|
59
|
+
if (!ts.isIdentifier(d.expression.expression)) return false;
|
|
60
|
+
return d.expression.expression.text === 'Component';
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
if (hasComponent) {
|
|
64
|
+
const { line } = sourceFile.getLineAndCharacterOfPosition(node.getStart());
|
|
65
|
+
results.push({ name: node.name.text, line: line + 1 });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
ts.forEachChild(node, visit);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
visit(sourceFile);
|
|
73
|
+
return results;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function findComponentFiles(rootDir: string): string[] {
|
|
77
|
+
const results: string[] = [];
|
|
78
|
+
const srcDir = path.join(rootDir, 'src');
|
|
79
|
+
|
|
80
|
+
if (!fs.existsSync(srcDir)) return results;
|
|
81
|
+
|
|
82
|
+
walk(srcDir, results);
|
|
83
|
+
return results;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function walk(dir: string, results: string[]): void {
|
|
87
|
+
let entries: fs.Dirent[];
|
|
88
|
+
try {
|
|
89
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
90
|
+
} catch {
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
for (const entry of entries) {
|
|
95
|
+
if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue;
|
|
96
|
+
|
|
97
|
+
const fullPath = path.join(dir, entry.name);
|
|
98
|
+
|
|
99
|
+
if (entry.isDirectory()) {
|
|
100
|
+
walk(fullPath, results);
|
|
101
|
+
} else if (entry.name.endsWith('.component.ts')) {
|
|
102
|
+
results.push(fullPath);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import ts from 'typescript';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
|
|
4
|
+
export interface TransformResult {
|
|
5
|
+
code: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
interface Edit {
|
|
9
|
+
pos: number;
|
|
10
|
+
end: number;
|
|
11
|
+
text: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function transformAngularComponent(
|
|
15
|
+
code: string,
|
|
16
|
+
filePath: string,
|
|
17
|
+
rootDir: string,
|
|
18
|
+
): TransformResult | null {
|
|
19
|
+
const sourceFile = ts.createSourceFile(
|
|
20
|
+
filePath,
|
|
21
|
+
code,
|
|
22
|
+
ts.ScriptTarget.Latest,
|
|
23
|
+
true,
|
|
24
|
+
ts.ScriptKind.TS,
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
const relativePath = path.relative(rootDir, filePath).replace(/\\/g, '/').replace(/'/g, "\\'");
|
|
28
|
+
const edits: Edit[] = [];
|
|
29
|
+
|
|
30
|
+
visitNode(sourceFile);
|
|
31
|
+
|
|
32
|
+
if (edits.length === 0) return null;
|
|
33
|
+
|
|
34
|
+
// Apply edits in reverse order to preserve positions
|
|
35
|
+
edits.sort((a, b) => b.pos - a.pos);
|
|
36
|
+
|
|
37
|
+
let result = code;
|
|
38
|
+
for (const edit of edits) {
|
|
39
|
+
result = result.slice(0, edit.pos) + edit.text + result.slice(edit.end);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return { code: result };
|
|
43
|
+
|
|
44
|
+
function visitNode(node: ts.Node): void {
|
|
45
|
+
if (ts.isClassDeclaration(node)) {
|
|
46
|
+
processClassDeclaration(node);
|
|
47
|
+
}
|
|
48
|
+
ts.forEachChild(node, visitNode);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function processClassDeclaration(classNode: ts.ClassDeclaration): void {
|
|
52
|
+
const decorators = ts.getDecorators(classNode);
|
|
53
|
+
if (!decorators) return;
|
|
54
|
+
|
|
55
|
+
for (const decorator of decorators) {
|
|
56
|
+
if (!ts.isCallExpression(decorator.expression)) continue;
|
|
57
|
+
|
|
58
|
+
const expr = decorator.expression;
|
|
59
|
+
if (!ts.isIdentifier(expr.expression)) continue;
|
|
60
|
+
if (expr.expression.text !== 'Component') continue;
|
|
61
|
+
|
|
62
|
+
processComponentDecorator(expr);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function processComponentDecorator(callExpr: ts.CallExpression): void {
|
|
67
|
+
if (callExpr.arguments.length === 0) {
|
|
68
|
+
// Empty decorator call: @Component() — add host arg
|
|
69
|
+
const line = sourceFile.getLineAndCharacterOfPosition(callExpr.getStart()).line;
|
|
70
|
+
const sourceAttr = `'data-ng-source': '${relativePath}:${line}:0'`;
|
|
71
|
+
const insertText = `{ host: { ${sourceAttr} } }`;
|
|
72
|
+
|
|
73
|
+
const openParen = callExpr.getStart() + callExpr.expression.getText().length;
|
|
74
|
+
// Find the actual position of the opening paren
|
|
75
|
+
const parenPos = code.indexOf('(', openParen);
|
|
76
|
+
const closeParenPos = code.indexOf(')', parenPos);
|
|
77
|
+
|
|
78
|
+
edits.push({
|
|
79
|
+
pos: parenPos + 1,
|
|
80
|
+
end: closeParenPos,
|
|
81
|
+
text: insertText,
|
|
82
|
+
});
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const arg = callExpr.arguments[0];
|
|
87
|
+
if (!ts.isObjectLiteralExpression(arg)) return;
|
|
88
|
+
|
|
89
|
+
const line = sourceFile.getLineAndCharacterOfPosition(callExpr.getStart()).line;
|
|
90
|
+
const sourceAttr = `'data-ng-source': '${relativePath}:${line}:0'`;
|
|
91
|
+
|
|
92
|
+
// Check if host property already exists
|
|
93
|
+
const hostProp = arg.properties.find(
|
|
94
|
+
(p) => ts.isPropertyAssignment(p) && ts.isIdentifier(p.name) && p.name.text === 'host',
|
|
95
|
+
) as ts.PropertyAssignment | undefined;
|
|
96
|
+
|
|
97
|
+
if (hostProp) {
|
|
98
|
+
// Host property exists — inject into it
|
|
99
|
+
if (!ts.isObjectLiteralExpression(hostProp.initializer)) return;
|
|
100
|
+
|
|
101
|
+
const hostObj = hostProp.initializer;
|
|
102
|
+
|
|
103
|
+
// Check for existing data-ng-source (idempotent)
|
|
104
|
+
const hasExisting = hostObj.properties.some(
|
|
105
|
+
(p) =>
|
|
106
|
+
ts.isPropertyAssignment(p) &&
|
|
107
|
+
((ts.isStringLiteral(p.name) && p.name.text === 'data-ng-source') ||
|
|
108
|
+
(ts.isIdentifier(p.name) && p.name.text === 'data-ng-source')),
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
if (hasExisting) return;
|
|
112
|
+
|
|
113
|
+
// Insert into existing host object
|
|
114
|
+
if (hostObj.properties.length > 0) {
|
|
115
|
+
const lastProp = hostObj.properties[hostObj.properties.length - 1];
|
|
116
|
+
edits.push({
|
|
117
|
+
pos: lastProp.getEnd(),
|
|
118
|
+
end: lastProp.getEnd(),
|
|
119
|
+
text: `, ${sourceAttr}`,
|
|
120
|
+
});
|
|
121
|
+
} else {
|
|
122
|
+
// Empty host object: host: {}
|
|
123
|
+
const openBrace = hostObj.getStart();
|
|
124
|
+
const closeBrace = hostObj.getEnd();
|
|
125
|
+
edits.push({
|
|
126
|
+
pos: openBrace + 1,
|
|
127
|
+
end: closeBrace - 1,
|
|
128
|
+
text: ` ${sourceAttr} `,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
} else {
|
|
132
|
+
// No host property — add one
|
|
133
|
+
if (arg.properties.length > 0) {
|
|
134
|
+
const lastProp = arg.properties[arg.properties.length - 1];
|
|
135
|
+
edits.push({
|
|
136
|
+
pos: lastProp.getEnd(),
|
|
137
|
+
end: lastProp.getEnd(),
|
|
138
|
+
text: `, host: { ${sourceAttr} }`,
|
|
139
|
+
});
|
|
140
|
+
} else {
|
|
141
|
+
// Empty object literal: @Component({})
|
|
142
|
+
const openBrace = arg.getStart();
|
|
143
|
+
const closeBrace = arg.getEnd();
|
|
144
|
+
edits.push({
|
|
145
|
+
pos: openBrace + 1,
|
|
146
|
+
end: closeBrace - 1,
|
|
147
|
+
text: ` host: { ${sourceAttr} } `,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { angularGrabVitePlugin } from '../index';
|
|
3
|
+
|
|
4
|
+
describe('angularGrabVitePlugin', () => {
|
|
5
|
+
it('returns a plugin object with the correct name', () => {
|
|
6
|
+
const plugin = angularGrabVitePlugin();
|
|
7
|
+
expect(plugin.name).toBe('angular-grab-source-injector');
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('sets enforce to "pre"', () => {
|
|
11
|
+
const plugin = angularGrabVitePlugin();
|
|
12
|
+
expect(plugin.enforce).toBe('pre');
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('sets apply to "serve"', () => {
|
|
16
|
+
const plugin = angularGrabVitePlugin();
|
|
17
|
+
expect(plugin.apply).toBe('serve');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('has a transform hook', () => {
|
|
21
|
+
const plugin = angularGrabVitePlugin();
|
|
22
|
+
expect(plugin.transform).toBeDefined();
|
|
23
|
+
expect(typeof plugin.transform).toBe('function');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('accepts optional rootDir option', () => {
|
|
27
|
+
const plugin = angularGrabVitePlugin({ rootDir: '/custom/root' });
|
|
28
|
+
expect(plugin.name).toBe('angular-grab-source-injector');
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe('transform hook', () => {
|
|
33
|
+
function getTransform() {
|
|
34
|
+
const plugin = angularGrabVitePlugin({ rootDir: '/project' });
|
|
35
|
+
// The transform function is directly on the plugin
|
|
36
|
+
return plugin.transform as (code: string, id: string) => any;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
it('returns null for non-component TypeScript files', () => {
|
|
40
|
+
const transform = getTransform();
|
|
41
|
+
const result = transform('export class MyService {}', '/project/src/app/my.service.ts');
|
|
42
|
+
expect(result).toBeNull();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('returns null for .component.ts files without @Component', () => {
|
|
46
|
+
const transform = getTransform();
|
|
47
|
+
const result = transform('export class PlainClass {}', '/project/src/app/example.component.ts');
|
|
48
|
+
expect(result).toBeNull();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('transforms a .component.ts file with @Component decorator', () => {
|
|
52
|
+
const transform = getTransform();
|
|
53
|
+
const input = `@Component({ selector: 'app-example', template: '<p>hi</p>' })
|
|
54
|
+
export class ExampleComponent {}`;
|
|
55
|
+
|
|
56
|
+
const result = transform(input, '/project/src/app/example.component.ts');
|
|
57
|
+
expect(result).not.toBeNull();
|
|
58
|
+
expect(result.code).toContain("'data-ng-source': 'src/app/example.component.ts:0:0'");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('returns null for CSS files', () => {
|
|
62
|
+
const transform = getTransform();
|
|
63
|
+
const result = transform('.foo { color: red; }', '/project/src/app/styles.css');
|
|
64
|
+
expect(result).toBeNull();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('returns null for HTML template files', () => {
|
|
68
|
+
const transform = getTransform();
|
|
69
|
+
const result = transform('<div>hello</div>', '/project/src/app/example.component.html');
|
|
70
|
+
expect(result).toBeNull();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('is idempotent — skips if data-ng-source already present', () => {
|
|
74
|
+
const transform = getTransform();
|
|
75
|
+
const input = `@Component({
|
|
76
|
+
selector: 'app-example',
|
|
77
|
+
host: { 'data-ng-source': 'src/app/example.component.ts:0:0' }
|
|
78
|
+
})
|
|
79
|
+
export class ExampleComponent {}`;
|
|
80
|
+
|
|
81
|
+
const result = transform(input, '/project/src/app/example.component.ts');
|
|
82
|
+
expect(result).toBeNull();
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Plugin } from 'vite';
|
|
2
|
+
import { transformAngularComponent } from '../esbuild-plugin';
|
|
3
|
+
|
|
4
|
+
export function angularGrabVitePlugin(options?: { rootDir?: string }): Plugin {
|
|
5
|
+
return {
|
|
6
|
+
name: 'angular-grab-source-injector',
|
|
7
|
+
enforce: 'pre',
|
|
8
|
+
apply: 'serve',
|
|
9
|
+
transform(code: string, id: string) {
|
|
10
|
+
if (!id.endsWith('.component.ts') || !code.includes('@Component')) {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
const rootDir = options?.rootDir || process.cwd();
|
|
14
|
+
return transformAngularComponent(code, id, rootDir);
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export default angularGrabVitePlugin;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { AngularGrabWebpackPlugin } from '../plugin';
|
|
3
|
+
|
|
4
|
+
describe('AngularGrabWebpackPlugin', () => {
|
|
5
|
+
it('can be instantiated', () => {
|
|
6
|
+
const plugin = new AngularGrabWebpackPlugin();
|
|
7
|
+
expect(plugin).toBeInstanceOf(AngularGrabWebpackPlugin);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('has an apply method', () => {
|
|
11
|
+
const plugin = new AngularGrabWebpackPlugin();
|
|
12
|
+
expect(typeof plugin.apply).toBe('function');
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('adds a module rule for .component.ts files', () => {
|
|
16
|
+
const plugin = new AngularGrabWebpackPlugin();
|
|
17
|
+
const compiler = {
|
|
18
|
+
options: {
|
|
19
|
+
module: {
|
|
20
|
+
rules: [],
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
plugin.apply(compiler);
|
|
26
|
+
|
|
27
|
+
expect(compiler.options.module.rules.length).toBe(1);
|
|
28
|
+
const rule = compiler.options.module.rules[0] as any;
|
|
29
|
+
expect(rule.test).toEqual(/\.component\.ts$/);
|
|
30
|
+
expect(rule.enforce).toBe('pre');
|
|
31
|
+
expect(rule.use).toBeDefined();
|
|
32
|
+
expect(rule.use.length).toBe(1);
|
|
33
|
+
expect(rule.use[0].loader).toContain('loader');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('creates module.rules array when missing', () => {
|
|
37
|
+
const plugin = new AngularGrabWebpackPlugin();
|
|
38
|
+
const compiler = {
|
|
39
|
+
options: {},
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
plugin.apply(compiler);
|
|
43
|
+
|
|
44
|
+
expect(compiler.options.module).toBeDefined();
|
|
45
|
+
expect((compiler.options as any).module.rules.length).toBe(1);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('appends to existing rules without removing them', () => {
|
|
49
|
+
const plugin = new AngularGrabWebpackPlugin();
|
|
50
|
+
const existingRule = { test: /\.css$/, use: ['style-loader'] };
|
|
51
|
+
const compiler = {
|
|
52
|
+
options: {
|
|
53
|
+
module: {
|
|
54
|
+
rules: [existingRule],
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
plugin.apply(compiler);
|
|
60
|
+
|
|
61
|
+
expect(compiler.options.module.rules.length).toBe(2);
|
|
62
|
+
expect(compiler.options.module.rules[0]).toBe(existingRule);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe('webpack-plugin exports', () => {
|
|
67
|
+
it('exports AngularGrabWebpackPlugin from index', async () => {
|
|
68
|
+
const mod = await import('../index');
|
|
69
|
+
expect(mod.AngularGrabWebpackPlugin).toBeDefined();
|
|
70
|
+
expect(mod.AngularGrabWebpackPlugin).toBe(AngularGrabWebpackPlugin);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { transformAngularComponent } from '../esbuild-plugin';
|
|
2
|
+
|
|
3
|
+
export default function angularGrabLoader(this: any, source: string) {
|
|
4
|
+
const callback = this.async();
|
|
5
|
+
const filePath = this.resourcePath;
|
|
6
|
+
|
|
7
|
+
if (!filePath.endsWith('.component.ts') || !source.includes('@Component')) {
|
|
8
|
+
return callback(null, source);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const rootDir = this.rootContext || process.cwd();
|
|
12
|
+
const result = transformAngularComponent(source, filePath, rootDir);
|
|
13
|
+
|
|
14
|
+
callback(null, result?.code || source);
|
|
15
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { fileURLToPath } from 'node:url';
|
|
2
|
+
import { dirname, resolve } from 'node:path';
|
|
3
|
+
|
|
4
|
+
export class AngularGrabWebpackPlugin {
|
|
5
|
+
apply(compiler: any) {
|
|
6
|
+
const pluginDir = dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
|
|
8
|
+
compiler.options.module = compiler.options.module || {};
|
|
9
|
+
compiler.options.module.rules = compiler.options.module.rules || [];
|
|
10
|
+
compiler.options.module.rules.push({
|
|
11
|
+
test: /\.component\.ts$/,
|
|
12
|
+
enforce: 'pre' as const,
|
|
13
|
+
use: [
|
|
14
|
+
{
|
|
15
|
+
loader: resolve(pluginDir, 'loader.js'),
|
|
16
|
+
},
|
|
17
|
+
],
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
}
|