commentation 0.1.0

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/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "commentation",
3
+ "version": "0.1.0",
4
+ "description": "Pin-based contextual comments overlay for websites. Local-first, sync via Git.",
5
+ "type": "module",
6
+ "main": "./vite-plugin-commentation.ts",
7
+ "types": "./vite-plugin-commentation.ts",
8
+ "exports": {
9
+ ".": {
10
+ "import": "./vite-plugin-commentation.ts",
11
+ "types": "./vite-plugin-commentation.ts"
12
+ }
13
+ },
14
+ "files": [
15
+ "dist",
16
+ "vite-plugin-commentation.ts"
17
+ ],
18
+ "keywords": [
19
+ "comments",
20
+ "feedback",
21
+ "vite",
22
+ "plugin",
23
+ "overlay",
24
+ "design-review",
25
+ "local-first",
26
+ "git"
27
+ ],
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "https://github.com/joelbodker/commentation"
31
+ },
32
+ "license": "MIT",
33
+ "peerDependencies": {
34
+ "vite": ">=5.0.0"
35
+ },
36
+ "engines": {
37
+ "node": ">=18"
38
+ }
39
+ }
@@ -0,0 +1,399 @@
1
+ /**
2
+ * Vite plugin: serves Commentation API from .commentation/ and writes updates to disk.
3
+ * Comments sync via Git — commit and push to share with your team.
4
+ */
5
+ import type { Plugin } from "vite";
6
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
7
+ import { join, dirname } from "path";
8
+ import { fileURLToPath } from "url";
9
+
10
+ const __dirname = dirname(fileURLToPath(import.meta.url));
11
+
12
+ const API_PREFIX = "/__commentation__/api";
13
+ const EMBED_PATH = "/__commentation__/embed.js";
14
+ const DATA_FILE = ".commentation/data.json";
15
+
16
+ type Comment = {
17
+ id: string;
18
+ threadId: string;
19
+ body: string;
20
+ createdBy: string;
21
+ createdAt: string;
22
+ };
23
+
24
+ type Thread = {
25
+ id: string;
26
+ projectId: string;
27
+ pageUrl: string;
28
+ selector: string;
29
+ xPercent: number;
30
+ yPercent: number;
31
+ offsetRatioX?: number;
32
+ offsetRatioY?: number;
33
+ status: string;
34
+ createdBy: string;
35
+ createdAt: string;
36
+ resolvedBy?: string | null;
37
+ resolvedAt?: string | null;
38
+ assignedTo?: string | null;
39
+ assignedBy?: string | null;
40
+ assignedAt?: string | null;
41
+ comments: Comment[];
42
+ };
43
+
44
+ type PageData = {
45
+ threads: Thread[];
46
+ order: string[];
47
+ };
48
+
49
+ type ActivityLogEntry = {
50
+ id: string;
51
+ threadId: string | null;
52
+ type: string;
53
+ message: string;
54
+ timestamp: string;
55
+ meta?: Record<string, unknown>;
56
+ };
57
+
58
+ type DataFile = {
59
+ projects: Record<string, Record<string, PageData>>;
60
+ activityLog?: ActivityLogEntry[];
61
+ };
62
+
63
+ function id(): string {
64
+ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 11)}`;
65
+ }
66
+
67
+ function getDataPath(root: string): string {
68
+ return join(root, DATA_FILE);
69
+ }
70
+
71
+ function loadData(root: string): DataFile {
72
+ const path = getDataPath(root);
73
+ if (!existsSync(path)) {
74
+ return { projects: {}, activityLog: [] };
75
+ }
76
+ try {
77
+ const raw = readFileSync(path, "utf-8");
78
+ const data = JSON.parse(raw) as DataFile;
79
+ if (!data.activityLog) data.activityLog = [];
80
+ return data;
81
+ } catch {
82
+ return { projects: {}, activityLog: [] };
83
+ }
84
+ }
85
+
86
+ function saveData(root: string, data: DataFile): void {
87
+ const path = getDataPath(root);
88
+ const dir = dirname(path);
89
+ if (!existsSync(dir)) {
90
+ mkdirSync(dir, { recursive: true });
91
+ }
92
+ writeFileSync(path, JSON.stringify(data, null, 2), "utf-8");
93
+ }
94
+
95
+ function findThread(data: DataFile, threadId: string): { thread: Thread; projectId: string; pageUrl: string } | null {
96
+ for (const [projectId, pages] of Object.entries(data.projects)) {
97
+ for (const [pageUrl, pageData] of Object.entries(pages)) {
98
+ const thread = pageData.threads.find((t) => t.id === threadId);
99
+ if (thread) return { thread, projectId, pageUrl };
100
+ }
101
+ }
102
+ return null;
103
+ }
104
+
105
+ function getPageData(data: DataFile, projectId: string, pageUrl: string): PageData {
106
+ if (!data.projects[projectId]) data.projects[projectId] = {};
107
+ if (!data.projects[projectId][pageUrl]) {
108
+ data.projects[projectId][pageUrl] = { threads: [], order: [] };
109
+ }
110
+ return data.projects[projectId][pageUrl];
111
+ }
112
+
113
+ export function commentationPlugin(): Plugin {
114
+ let root: string;
115
+
116
+ return {
117
+ name: "commentation",
118
+ configResolved(config) {
119
+ root = config.root;
120
+ },
121
+ writeBundle(options, bundle) {
122
+ // Copy embed.js to output so static builds (e.g. GitHub Pages) can serve it
123
+ const outDir = options.dir ?? "dist";
124
+ const embedPath = join(__dirname, "dist", "embed.js");
125
+ if (!existsSync(embedPath)) return;
126
+ const destDir = join(outDir, "__commentation__");
127
+ const destPath = join(destDir, "embed.js");
128
+ mkdirSync(destDir, { recursive: true });
129
+ writeFileSync(destPath, readFileSync(embedPath, "utf-8"), "utf-8");
130
+ },
131
+ configureServer(server) {
132
+ server.middlewares.use(async (req, res, next) => {
133
+ // Serve embed.js from package dist (works when installed via npm)
134
+ const reqPath = req.url?.split("?")[0];
135
+ if (reqPath === EMBED_PATH) {
136
+ try {
137
+ const embedPath = join(__dirname, "dist", "embed.js");
138
+ const code = readFileSync(embedPath, "utf-8");
139
+ res.statusCode = 200;
140
+ res.setHeader("Content-Type", "application/javascript");
141
+ res.end(code);
142
+ } catch {
143
+ res.statusCode = 404;
144
+ res.end("Commentation embed not found. Run npm run build:package first.");
145
+ }
146
+ return;
147
+ }
148
+ if (!req.url?.startsWith(API_PREFIX)) return next();
149
+
150
+ const url = new URL(req.url, `http://${req.headers.host}`);
151
+ const path = url.pathname.slice(API_PREFIX.length);
152
+ const method = req.method ?? "GET";
153
+
154
+ const sendJson = (status: number, body: unknown) => {
155
+ res.statusCode = status;
156
+ res.setHeader("Content-Type", "application/json");
157
+ res.end(JSON.stringify(body));
158
+ };
159
+
160
+ const readBody = (): Promise<unknown> =>
161
+ new Promise((resolve, reject) => {
162
+ let body = "";
163
+ req.on("data", (chunk) => (body += chunk));
164
+ req.on("end", () => {
165
+ try {
166
+ resolve(body ? JSON.parse(body) : {});
167
+ } catch {
168
+ reject(new Error("Invalid JSON"));
169
+ }
170
+ });
171
+ req.on("error", reject);
172
+ });
173
+
174
+ try {
175
+ // GET /api/health
176
+ if (path === "/health" && method === "GET") {
177
+ return sendJson(200, { ok: true });
178
+ }
179
+
180
+ // GET /api/projects/:projectId/activity-log
181
+ const getLogMatch = path.match(/^\/projects\/([^/]+)\/activity-log$/);
182
+ if (getLogMatch && method === "GET") {
183
+ const data = loadData(root);
184
+ const entries = data.activityLog ?? [];
185
+ return sendJson(200, entries);
186
+ }
187
+
188
+ // POST /api/projects/:projectId/activity-log
189
+ const addLogMatch = path.match(/^\/projects\/([^/]+)\/activity-log$/);
190
+ if (addLogMatch && method === "POST") {
191
+ const projectId = addLogMatch[1];
192
+ const body = (await readBody()) as {
193
+ threadId?: string;
194
+ type?: string;
195
+ message?: string;
196
+ meta?: Record<string, unknown>;
197
+ };
198
+ const { threadId, type, message, meta } = body;
199
+ if (!message) {
200
+ return sendJson(400, { error: "Required: message" });
201
+ }
202
+ const data = loadData(root);
203
+ if (!data.activityLog) data.activityLog = [];
204
+ const entry: ActivityLogEntry = {
205
+ id: id(),
206
+ threadId: threadId ?? null,
207
+ type: type ?? "generic",
208
+ message,
209
+ timestamp: new Date().toISOString(),
210
+ meta: meta ?? undefined,
211
+ };
212
+ data.activityLog.push(entry);
213
+ if (data.activityLog.length > 500) {
214
+ data.activityLog = data.activityLog.slice(-500);
215
+ }
216
+ saveData(root, data);
217
+ return sendJson(201, entry);
218
+ }
219
+
220
+ // GET /api/projects/:projectId/threads?pageUrl=...&status=open|resolved|all
221
+ const listMatch = path.match(/^\/projects\/([^/]+)\/threads$/);
222
+ if (listMatch && method === "GET") {
223
+ const projectId = listMatch[1];
224
+ const pageUrl = url.searchParams.get("pageUrl");
225
+ const statusFilter = url.searchParams.get("status") || "open";
226
+ const data = loadData(root);
227
+ const status = statusFilter === "resolved" ? "RESOLVED" : statusFilter === "all" ? null : "OPEN";
228
+ let threads: Thread[] = [];
229
+ if (pageUrl) {
230
+ const page = getPageData(data, projectId, pageUrl);
231
+ threads = page.threads.filter((t) => status === null || t.status === status);
232
+ } else {
233
+ const pages = data.projects[projectId];
234
+ if (pages) {
235
+ for (const pageData of Object.values(pages)) {
236
+ threads = threads.concat(
237
+ pageData.threads.filter((t) => status === null || t.status === status)
238
+ );
239
+ }
240
+ }
241
+ }
242
+ threads = threads.map((t) => {
243
+ const comments = t.comments ?? [];
244
+ const latest = comments.length > 0 ? comments[comments.length - 1] : null;
245
+ return {
246
+ ...t,
247
+ latestComment: latest,
248
+ commentCount: comments.length,
249
+ };
250
+ });
251
+ return sendJson(200, threads);
252
+ }
253
+
254
+ // POST /api/projects/:projectId/threads
255
+ const createMatch = path.match(/^\/projects\/([^/]+)\/threads$/);
256
+ if (createMatch && method === "POST") {
257
+ const projectId = createMatch[1];
258
+ const body = (await readBody()) as {
259
+ pageUrl?: string;
260
+ selector?: string;
261
+ xPercent?: number;
262
+ yPercent?: number;
263
+ offsetRatioX?: number;
264
+ offsetRatioY?: number;
265
+ body?: string;
266
+ createdBy?: string;
267
+ };
268
+ const { pageUrl, selector, xPercent, yPercent, offsetRatioX, offsetRatioY, body: commentBody, createdBy } = body;
269
+ if (!pageUrl || selector == null || xPercent == null || yPercent == null || !commentBody || !createdBy) {
270
+ return sendJson(400, { error: "Required: pageUrl, selector, xPercent, yPercent, body, createdBy" });
271
+ }
272
+ const data = loadData(root);
273
+ const page = getPageData(data, projectId, pageUrl);
274
+ const threadId = id();
275
+ const commentId = id();
276
+ const now = new Date().toISOString();
277
+ const thread: Thread = {
278
+ id: threadId,
279
+ projectId,
280
+ pageUrl,
281
+ selector,
282
+ xPercent: Number(xPercent),
283
+ yPercent: Number(yPercent),
284
+ offsetRatioX: typeof offsetRatioX === "number" ? offsetRatioX : undefined,
285
+ offsetRatioY: typeof offsetRatioY === "number" ? offsetRatioY : undefined,
286
+ status: "OPEN",
287
+ createdBy,
288
+ createdAt: now,
289
+ resolvedBy: null,
290
+ resolvedAt: null,
291
+ assignedTo: null,
292
+ assignedBy: null,
293
+ assignedAt: null,
294
+ comments: [
295
+ { id: commentId, threadId, body: commentBody, createdBy, createdAt: now },
296
+ ],
297
+ };
298
+ page.threads.push(thread);
299
+ page.order.push(threadId);
300
+ saveData(root, data);
301
+ return sendJson(201, thread);
302
+ }
303
+
304
+ // GET /api/threads/:threadId
305
+ const getThreadMatch = path.match(/^\/threads\/([^/]+)$/);
306
+ if (getThreadMatch && method === "GET") {
307
+ const threadId = getThreadMatch[1];
308
+ const data = loadData(root);
309
+ const found = findThread(data, threadId);
310
+ if (!found) return sendJson(404, { error: "Thread not found" });
311
+ return sendJson(200, found.thread);
312
+ }
313
+
314
+ // POST /api/threads/:threadId/comments
315
+ const addCommentMatch = path.match(/^\/threads\/([^/]+)\/comments$/);
316
+ if (addCommentMatch && method === "POST") {
317
+ const threadId = addCommentMatch[1];
318
+ const body = (await readBody()) as { body?: string; createdBy?: string };
319
+ const { body: commentBody, createdBy } = body;
320
+ if (!commentBody || !createdBy) {
321
+ return sendJson(400, { error: "Required: body, createdBy" });
322
+ }
323
+ const data = loadData(root);
324
+ const found = findThread(data, threadId);
325
+ if (!found) return sendJson(404, { error: "Thread not found" });
326
+ const comment: Comment = {
327
+ id: id(),
328
+ threadId,
329
+ body: commentBody,
330
+ createdBy,
331
+ createdAt: new Date().toISOString(),
332
+ };
333
+ found.thread.comments = found.thread.comments ?? [];
334
+ found.thread.comments.push(comment);
335
+ saveData(root, data);
336
+ return sendJson(201, comment);
337
+ }
338
+
339
+ // PATCH /api/threads/:threadId
340
+ const patchMatch = path.match(/^\/threads\/([^/]+)$/);
341
+ if (patchMatch && method === "PATCH") {
342
+ const threadId = patchMatch[1];
343
+ const body = (await readBody()) as {
344
+ status?: "OPEN" | "RESOLVED";
345
+ resolvedBy?: string;
346
+ assignedTo?: string | null;
347
+ assignedBy?: string | null;
348
+ };
349
+ const { status, resolvedBy, assignedTo, assignedBy } = body;
350
+ const data = loadData(root);
351
+ const found = findThread(data, threadId);
352
+ if (!found) return sendJson(404, { error: "Thread not found" });
353
+ const t = found.thread;
354
+ if (status !== undefined) {
355
+ if (status !== "OPEN" && status !== "RESOLVED") {
356
+ return sendJson(400, { error: "status must be 'OPEN' or 'RESOLVED'" });
357
+ }
358
+ t.status = status;
359
+ if (status === "RESOLVED") {
360
+ t.resolvedBy = resolvedBy ?? null;
361
+ t.resolvedAt = new Date().toISOString();
362
+ } else {
363
+ t.resolvedBy = null;
364
+ t.resolvedAt = null;
365
+ }
366
+ }
367
+ if (assignedTo !== undefined) {
368
+ t.assignedTo = assignedTo ?? null;
369
+ t.assignedBy = assignedBy ?? null;
370
+ t.assignedAt = assignedTo != null && assignedTo !== "" ? new Date().toISOString() : null;
371
+ }
372
+ saveData(root, data);
373
+ return sendJson(200, t);
374
+ }
375
+
376
+ // DELETE /api/threads/:threadId
377
+ const deleteMatch = path.match(/^\/threads\/([^/]+)$/);
378
+ if (deleteMatch && method === "DELETE") {
379
+ const threadId = deleteMatch[1];
380
+ const data = loadData(root);
381
+ const found = findThread(data, threadId);
382
+ if (!found) return sendJson(404, { error: "Thread not found" });
383
+ const page = getPageData(data, found.projectId, found.pageUrl);
384
+ page.threads = page.threads.filter((t) => t.id !== threadId);
385
+ page.order = page.order.filter((id) => id !== threadId);
386
+ saveData(root, data);
387
+ res.statusCode = 204;
388
+ return res.end();
389
+ }
390
+
391
+ sendJson(404, { error: "Not found" });
392
+ } catch (err) {
393
+ console.error("[Commentation]", err);
394
+ sendJson(500, { error: "Internal server error" });
395
+ }
396
+ });
397
+ },
398
+ };
399
+ }