proto-comments 0.0.0-stage → 0.2.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 CHANGED
@@ -1,6 +1,51 @@
1
1
  {
2
2
  "name": "proto-comments",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "Figma-style review comments on a running React prototype, split by screen even in single-URL apps, stored in a JSON file a coding agent can apply.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "keywords": [
8
+ "react",
9
+ "prototype",
10
+ "comments",
11
+ "review",
12
+ "feedback",
13
+ "annotations",
14
+ "design",
15
+ "ai-agent",
16
+ "claude"
17
+ ],
18
+ "main": "./dist/index.js",
19
+ "module": "./dist/index.js",
20
+ "exports": {
21
+ ".": "./dist/index.js",
22
+ "./next": "./server/next.js",
23
+ "./storage": "./server/storage.js",
24
+ "./package.json": "./package.json"
25
+ },
26
+ "bin": {
27
+ "proto-comments": "bin/proto-comments.js"
28
+ },
29
+ "files": [
30
+ "dist",
31
+ "server",
32
+ "bin",
33
+ "README.md",
34
+ "LICENSE"
35
+ ],
36
+ "sideEffects": false,
37
+ "engines": {
38
+ "node": ">=18"
39
+ },
40
+ "scripts": {
41
+ "build": "node build.mjs",
42
+ "prepublishOnly": "npm run build"
43
+ },
44
+ "peerDependencies": {
45
+ "react": ">=18",
46
+ "react-dom": ">=18"
47
+ },
48
+ "devDependencies": {
49
+ "esbuild": "0.24.2"
50
+ }
51
+ }
package/server/next.js ADDED
@@ -0,0 +1,25 @@
1
+ import { createStorage } from './storage.js';
2
+
3
+ /**
4
+ * Route handlers for a Next.js App Router route, e.g. app/api/comments/route.js:
5
+ *
6
+ * import { createNextHandlers } from 'proto-comments/next';
7
+ * export const dynamic = 'force-dynamic';
8
+ * export const { GET, POST, PATCH, DELETE } = createNextHandlers();
9
+ *
10
+ * Off outside `next dev` unless `enabled` is set.
11
+ */
12
+ export function createNextHandlers({ file, enabled = process.env.NODE_ENV === 'development' } = {}) {
13
+ const storage = createStorage(file);
14
+ const run = method => async req => {
15
+ if (!enabled) return new Response('Not found', { status: 404 });
16
+ /* Same-origin JSON only: a page on another site must not be able to write comments, even with a "simple" request. */
17
+ const origin = req.headers.get('origin');
18
+ if (origin && new URL(origin).host !== req.headers.get('host')) return new Response('Forbidden', { status: 403 });
19
+ if (method !== 'GET' && !(req.headers.get('content-type') || '').startsWith('application/json')) return new Response('Unsupported Media Type', { status: 415 });
20
+ const body = method === 'GET' ? null : await req.json();
21
+ const { status, json } = await storage.handle(method, body);
22
+ return Response.json(json, { status });
23
+ };
24
+ return { GET: run('GET'), POST: run('POST'), PATCH: run('PATCH'), DELETE: run('DELETE') };
25
+ }
@@ -0,0 +1,69 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ /* Comments live in one JSON file in the project, so a coding agent can read and resolve them directly. */
5
+ export const DEFAULT_FILE = 'comments/comments.json';
6
+ /* Same limit as the comment field in src/ui.jsx. */
7
+ export const MAX_TEXT = 5000;
8
+
9
+ export function createStorage(file = DEFAULT_FILE) {
10
+ const abs = path.resolve(process.cwd(), file);
11
+ /* Writes go one at a time, so two quick requests can't overwrite each other. */
12
+ let queue = Promise.resolve();
13
+ const locked = fn => (queue = queue.then(fn, fn));
14
+
15
+ async function read() {
16
+ try { return JSON.parse(await fs.readFile(abs, 'utf8')); } catch (e) { if (e.code === 'ENOENT') return []; throw e; }
17
+ }
18
+ async function write(list) {
19
+ await fs.mkdir(path.dirname(abs), { recursive: true });
20
+ await fs.writeFile(abs, JSON.stringify(list, null, 2) + '\n');
21
+ }
22
+ const now = () => new Date().toISOString();
23
+ /* Numbers count open comments only: resolved ones show a check, so a new comment after them is #1 again. */
24
+ const nextN = list => Math.max(0, ...list.filter(c => c.status === 'open').map(c => c.n)) + 1;
25
+
26
+ /** One entry point for every transport (Next route, standalone server): returns { status, json }. */
27
+ async function handle(method, body) {
28
+ if (method === 'GET') return { status: 200, json: await read() };
29
+ const text = body?.text ?? body?.reply?.text;
30
+ if (typeof text === 'string' && text.length > MAX_TEXT) return { status: 413, json: { error: `Comment is longer than ${MAX_TEXT} characters` } };
31
+ return locked(async () => {
32
+ const list = await read();
33
+ if (method === 'POST') {
34
+ const { screen, screenLabel, context, snapshot, anchor, text, author } = body;
35
+ const n = nextN(list);
36
+ const c = { id: `c${Date.now().toString(36)}`, n, screen, screenLabel, context, anchor, text, author, status: 'open', queued: false, createdAt: now(), replies: [], snapshot };
37
+ await write([...list, c]);
38
+ return { status: 200, json: c };
39
+ }
40
+ if (method === 'PATCH') {
41
+ const { id, ids, queued, status, reply, author } = body;
42
+ if (ids) {
43
+ list.forEach(x => { if (ids.includes(x.id)) x.queued = !!queued; });
44
+ await write(list);
45
+ return { status: 200, json: list };
46
+ }
47
+ const c = list.find(x => x.id === id);
48
+ if (!c) return { status: 404, json: { error: 'Not found' } };
49
+ if (status === 'open' && c.status === 'resolved') {
50
+ /* Reopened: its old number may belong to a newer open comment by now. */
51
+ if (list.some(x => x.status === 'open' && x.n === c.n)) c.n = nextN(list);
52
+ delete c.resolvedBy; delete c.resolvedAt;
53
+ }
54
+ if (status) c.status = status;
55
+ if (status === 'resolved') Object.assign(c, { queued: false, resolvedBy: author, resolvedAt: now() });
56
+ if (reply) c.replies.push({ ...reply, createdAt: now() });
57
+ await write(list);
58
+ return { status: 200, json: c };
59
+ }
60
+ if (method === 'DELETE') {
61
+ await write(body.all ? [] : list.filter(x => x.id !== body.id));
62
+ return { status: 200, json: { ok: true } };
63
+ }
64
+ return { status: 405, json: { error: 'Method not allowed' } };
65
+ });
66
+ }
67
+
68
+ return { file: abs, read, write, handle };
69
+ }