@ng-annotate/vite-plugin 0.2.1

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.
@@ -0,0 +1,3 @@
1
+ import type { Plugin } from 'vite';
2
+ export default ngAnnotateMcp;
3
+ export declare function ngAnnotateMcp(): Plugin[];
package/dist/index.js ADDED
@@ -0,0 +1,14 @@
1
+ import { createWsHandler } from './ws-handler.js';
2
+ import { createManifestPlugin } from './manifest.js';
3
+ // Default export for Angular's angular.json `plugins` option — Angular calls it as a function.
4
+ export default ngAnnotateMcp;
5
+ export function ngAnnotateMcp() {
6
+ const mainPlugin = {
7
+ name: 'ng-annotate-mcp',
8
+ apply: 'serve',
9
+ configureServer(server) {
10
+ createWsHandler(server);
11
+ },
12
+ };
13
+ return [mainPlugin, createManifestPlugin()];
14
+ }
@@ -0,0 +1,6 @@
1
+ import type { Plugin } from 'vite';
2
+ export interface ManifestEntry {
3
+ component: string;
4
+ template?: string;
5
+ }
6
+ export declare function createManifestPlugin(): Plugin;
@@ -0,0 +1,35 @@
1
+ import path from 'node:path';
2
+ export function createManifestPlugin() {
3
+ const manifest = {};
4
+ let projectRoot = '';
5
+ return {
6
+ name: 'ng-annotate-mcp:manifest',
7
+ configResolved(config) {
8
+ projectRoot = config.root;
9
+ },
10
+ transform(code, id) {
11
+ if (!id.endsWith('.ts') || !code.includes('@Component'))
12
+ return null;
13
+ // Extract class name
14
+ const classMatch = /export\s+class\s+(\w+)/.exec(code);
15
+ if (!classMatch)
16
+ return null;
17
+ const className = classMatch[1];
18
+ // Compute relative path from project root
19
+ const relPath = path.relative(projectRoot, id).replace(/\\/g, '/');
20
+ // Extract templateUrl if present
21
+ const templateMatch = /templateUrl\s*:\s*['"`]([^'"`]+)['"`]/.exec(code);
22
+ const entry = { component: relPath };
23
+ if (templateMatch) {
24
+ const templateAbsPath = path.resolve(path.dirname(id), templateMatch[1]);
25
+ entry.template = path.relative(projectRoot, templateAbsPath).replace(/\\/g, '/');
26
+ }
27
+ manifest[className] = entry;
28
+ return null;
29
+ },
30
+ transformIndexHtml(html) {
31
+ const scriptTag = `<script>window.__NG_ANNOTATE_MANIFEST__ = ${JSON.stringify(manifest)};</script>`;
32
+ return html.replace('</head>', ` ${scriptTag}\n</head>`);
33
+ },
34
+ };
35
+ }
@@ -0,0 +1,47 @@
1
+ export type AnnotationStatus = 'pending' | 'acknowledged' | 'resolved' | 'dismissed';
2
+ export interface Session {
3
+ id: string;
4
+ createdAt: string;
5
+ lastSeenAt: string;
6
+ active: boolean;
7
+ url: string;
8
+ }
9
+ export interface AnnotationReply {
10
+ id: string;
11
+ createdAt: string;
12
+ author: 'agent' | 'user';
13
+ message: string;
14
+ }
15
+ export interface Annotation {
16
+ id: string;
17
+ sessionId: string;
18
+ createdAt: string;
19
+ status: AnnotationStatus;
20
+ replies: AnnotationReply[];
21
+ componentName: string;
22
+ componentFilePath: string;
23
+ templateFilePath?: string;
24
+ selector: string;
25
+ inputs: Record<string, unknown>;
26
+ domSnapshot: string;
27
+ componentTreePath: string[];
28
+ annotationText: string;
29
+ selectionText?: string;
30
+ }
31
+ export declare const STORE_DIR = ".ng-annotate";
32
+ export declare const STORE_PATH: string;
33
+ export declare function ensureStore(): void;
34
+ type WatcherFn = (annotation: Annotation) => void;
35
+ export declare function addWatcher(fn: WatcherFn): () => void;
36
+ export declare const store: {
37
+ createSession(payload: Omit<Session, "id" | "createdAt" | "lastSeenAt">): Promise<Session>;
38
+ updateSession(id: string, patch: Partial<Session>): Promise<Session | undefined>;
39
+ listSessions(): Promise<Session[]>;
40
+ getSession(id: string): Promise<Session | undefined>;
41
+ createAnnotation(payload: Omit<Annotation, "id" | "createdAt" | "status" | "replies">): Promise<Annotation>;
42
+ getAnnotation(id: string): Promise<Annotation | undefined>;
43
+ listAnnotations(sessionId?: string, status?: AnnotationStatus): Promise<Annotation[]>;
44
+ updateAnnotation(id: string, patch: Partial<Annotation>): Promise<Annotation | undefined>;
45
+ addReply(annotationId: string, reply: Omit<AnnotationReply, "id" | "createdAt">): Promise<Annotation | undefined>;
46
+ };
47
+ export {};
package/dist/store.js ADDED
@@ -0,0 +1,146 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { v4 as uuidv4 } from 'uuid';
4
+ const EMPTY_STORE = { sessions: {}, annotations: {} };
5
+ // ─── File paths ───────────────────────────────────────────────────────────────
6
+ export const STORE_DIR = '.ng-annotate';
7
+ export const STORE_PATH = path.join(process.cwd(), STORE_DIR, 'store.json');
8
+ // ─── Store init ───────────────────────────────────────────────────────────────
9
+ export function ensureStore() {
10
+ const dir = path.join(process.cwd(), STORE_DIR);
11
+ if (!fs.existsSync(dir))
12
+ fs.mkdirSync(dir, { recursive: true });
13
+ if (!fs.existsSync(STORE_PATH)) {
14
+ fs.writeFileSync(STORE_PATH, JSON.stringify(EMPTY_STORE, null, 2), 'utf8');
15
+ }
16
+ }
17
+ // ─── File locking ─────────────────────────────────────────────────────────────
18
+ // In-process mutex: serializes all writes from the same process using a promise chain.
19
+ // writeQueue always resolves (never rejects) so the chain is never broken by a
20
+ // failing task. The Promise returned to the caller still rejects on error.
21
+ // Note: cross-process safety (Vite plugin + MCP server) can be layered on top
22
+ // via proper-lockfile if needed, but requires a platform-reliable approach.
23
+ let writeQueue = Promise.resolve();
24
+ async function withLock(fn) {
25
+ ensureStore();
26
+ const result = writeQueue.then(async () => {
27
+ const raw = fs.readFileSync(STORE_PATH, 'utf8');
28
+ const data = JSON.parse(raw);
29
+ const updated = await fn(data);
30
+ fs.writeFileSync(STORE_PATH, JSON.stringify(updated, null, 2), 'utf8');
31
+ return updated;
32
+ });
33
+ // Keep the queue alive even when this task fails
34
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
35
+ writeQueue = result.catch(() => { });
36
+ return result;
37
+ }
38
+ function readStore() {
39
+ ensureStore();
40
+ return JSON.parse(fs.readFileSync(STORE_PATH, 'utf8'));
41
+ }
42
+ const watchers = new Set();
43
+ export function addWatcher(fn) {
44
+ watchers.add(fn);
45
+ return () => watchers.delete(fn);
46
+ }
47
+ function notifyWatchers(annotation) {
48
+ for (const fn of watchers)
49
+ fn(annotation);
50
+ }
51
+ // ─── Store API ────────────────────────────────────────────────────────────────
52
+ export const store = {
53
+ async createSession(payload) {
54
+ const now = new Date().toISOString();
55
+ const session = {
56
+ id: uuidv4(),
57
+ createdAt: now,
58
+ lastSeenAt: now,
59
+ ...payload,
60
+ };
61
+ await withLock((data) => {
62
+ data.sessions[session.id] = session;
63
+ return data;
64
+ });
65
+ return session;
66
+ },
67
+ async updateSession(id, patch) {
68
+ let result;
69
+ await withLock((data) => {
70
+ const existing = data.sessions[id];
71
+ if (!existing)
72
+ return data;
73
+ const updated = { ...existing, ...patch, id };
74
+ data.sessions[id] = updated;
75
+ result = updated;
76
+ return data;
77
+ });
78
+ return result;
79
+ },
80
+ listSessions() {
81
+ const data = readStore();
82
+ return Promise.resolve(Object.values(data.sessions).filter((s) => s !== undefined));
83
+ },
84
+ getSession(id) {
85
+ const data = readStore();
86
+ return Promise.resolve(data.sessions[id]);
87
+ },
88
+ async createAnnotation(payload) {
89
+ const annotation = {
90
+ id: uuidv4(),
91
+ createdAt: new Date().toISOString(),
92
+ status: 'pending',
93
+ replies: [],
94
+ ...payload,
95
+ };
96
+ await withLock((data) => {
97
+ data.annotations[annotation.id] = annotation;
98
+ return data;
99
+ });
100
+ notifyWatchers(annotation);
101
+ return annotation;
102
+ },
103
+ getAnnotation(id) {
104
+ const data = readStore();
105
+ return Promise.resolve(data.annotations[id]);
106
+ },
107
+ listAnnotations(sessionId, status) {
108
+ const data = readStore();
109
+ let list = Object.values(data.annotations).filter((a) => a !== undefined);
110
+ if (sessionId)
111
+ list = list.filter((a) => a.sessionId === sessionId);
112
+ if (status)
113
+ list = list.filter((a) => a.status === status);
114
+ return Promise.resolve(list.sort((a, b) => a.createdAt.localeCompare(b.createdAt)));
115
+ },
116
+ async updateAnnotation(id, patch) {
117
+ let result;
118
+ await withLock((data) => {
119
+ const existing = data.annotations[id];
120
+ if (!existing)
121
+ return data;
122
+ const updated = { ...existing, ...patch, id };
123
+ data.annotations[id] = updated;
124
+ result = updated;
125
+ return data;
126
+ });
127
+ return result;
128
+ },
129
+ async addReply(annotationId, reply) {
130
+ let result;
131
+ await withLock((data) => {
132
+ const annotation = data.annotations[annotationId];
133
+ if (!annotation)
134
+ return data;
135
+ const newReply = {
136
+ id: uuidv4(),
137
+ createdAt: new Date().toISOString(),
138
+ ...reply,
139
+ };
140
+ annotation.replies.push(newReply);
141
+ result = annotation;
142
+ return data;
143
+ });
144
+ return result;
145
+ },
146
+ };
@@ -0,0 +1,2 @@
1
+ import type { ViteDevServer } from 'vite';
2
+ export declare function createWsHandler(server: ViteDevServer): void;
@@ -0,0 +1,71 @@
1
+ import { WebSocketServer, WebSocket } from 'ws';
2
+ import { store } from './store.js';
3
+ const WS_PATH = '/__annotate';
4
+ const SYNC_INTERVAL_MS = 2000;
5
+ export function createWsHandler(server) {
6
+ const wss = new WebSocketServer({ noServer: true });
7
+ const sessionSockets = new Map();
8
+ // Upgrade only /__annotate connections
9
+ server.httpServer?.on('upgrade', (req, socket, head) => {
10
+ const url = req.url ?? '';
11
+ if (!url.startsWith(WS_PATH))
12
+ return;
13
+ wss.handleUpgrade(req, socket, head, (ws) => {
14
+ wss.emit('connection', ws, req);
15
+ });
16
+ });
17
+ wss.on('connection', (ws, req) => {
18
+ void (async () => {
19
+ const referer = req.headers.referer ?? req.headers.origin ?? '';
20
+ const session = await store.createSession({ active: true, url: referer });
21
+ const sessionId = session.id;
22
+ ws.send(JSON.stringify({ type: 'session:created', session }));
23
+ sessionSockets.set(sessionId, ws);
24
+ ws.on('message', (raw) => {
25
+ void (async () => {
26
+ let msg;
27
+ try {
28
+ msg = JSON.parse(raw.toString());
29
+ }
30
+ catch {
31
+ return;
32
+ }
33
+ if (msg.type === 'annotation:create') {
34
+ const annotation = await store.createAnnotation({
35
+ ...msg.payload,
36
+ sessionId,
37
+ });
38
+ ws.send(JSON.stringify({ type: 'annotation:created', annotation }));
39
+ }
40
+ else if (msg.type === 'annotation:reply') {
41
+ const annotation = await store.addReply(msg.id, { author: 'user', message: msg.message });
42
+ if (annotation) {
43
+ ws.send(JSON.stringify({ type: 'annotation:updated', annotation }));
44
+ }
45
+ }
46
+ else {
47
+ const annotation = await store.updateAnnotation(msg.id, { status: 'dismissed' });
48
+ if (annotation) {
49
+ ws.send(JSON.stringify({ type: 'annotation:updated', annotation }));
50
+ }
51
+ }
52
+ })();
53
+ });
54
+ ws.on('close', () => {
55
+ void store.updateSession(sessionId, { active: false });
56
+ sessionSockets.delete(sessionId);
57
+ });
58
+ })();
59
+ });
60
+ // Sync annotation status updates back to browsers every 2s
61
+ setInterval(() => {
62
+ void (async () => {
63
+ for (const [sessionId, ws] of sessionSockets) {
64
+ if (ws.readyState !== WebSocket.OPEN)
65
+ continue;
66
+ const annotations = await store.listAnnotations(sessionId);
67
+ ws.send(JSON.stringify({ type: 'annotations:sync', annotations }));
68
+ }
69
+ })();
70
+ }, SYNC_INTERVAL_MS);
71
+ }
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@ng-annotate/vite-plugin",
3
+ "version": "0.2.1",
4
+ "type": "module",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "https://github.com/yngvebn/ngagentify"
8
+ },
9
+ "files": [
10
+ "dist"
11
+ ],
12
+ "main": "dist/index.js",
13
+ "types": "dist/index.d.ts",
14
+ "scripts": {
15
+ "build": "tsc",
16
+ "build:watch": "tsc --watch",
17
+ "test": "vitest run",
18
+ "test:watch": "vitest",
19
+ "test:coverage": "vitest run --coverage",
20
+ "lint": "eslint src/",
21
+ "lint:fix": "eslint src/ --fix"
22
+ },
23
+ "peerDependencies": {
24
+ "vite": ">=5.0.0"
25
+ },
26
+ "devDependencies": {
27
+ "@types/node": "^25.3.2",
28
+ "@types/proper-lockfile": "^4.1.4",
29
+ "@types/uuid": "^10.0.0",
30
+ "@types/ws": "^8.18.1",
31
+ "@vitest/coverage-v8": "^4.0.18",
32
+ "typescript": "~5.6.0",
33
+ "vite": "^5.0.0",
34
+ "vitest": "^4.0.18"
35
+ },
36
+ "dependencies": {
37
+ "proper-lockfile": "^4.1.2",
38
+ "uuid": "^13.0.0",
39
+ "ws": "^8.19.0"
40
+ }
41
+ }