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.
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,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,3 @@
1
+ export { angularGrabEsbuildPlugin } from './plugin';
2
+ export { transformAngularComponent, type TransformResult } from './transform';
3
+ export { scanComponentSources, type SourceMap, type ComponentSourceInfo } from './scan';
@@ -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,2 @@
1
+ // Placeholder
2
+ export { AngularGrabWebpackPlugin } from './plugin';
@@ -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
+ }