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/README.md +63 -0
- package/dist/embed.js +243 -0
- package/package.json +39 -0
- package/vite-plugin-commentation.ts +399 -0
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
|
+
}
|