repoview 0.4.1 → 0.5.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.
package/src/cli.js CHANGED
@@ -3,6 +3,7 @@ import path from "node:path";
3
3
  import process from "node:process";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { startServer } from "./server.js";
6
+ import { handleReviewCommand } from "./review-cli.js";
6
7
 
7
8
  function printHelp() {
8
9
  // Keep this in sync with README.md
@@ -23,6 +24,13 @@ Options:
23
24
  --no-watch Disable live reload
24
25
  -h, --help Show this help
25
26
 
27
+ Review subcommands:
28
+ repoview review new --title "Title" Create a new review thread
29
+ repoview review post <id> --role agent --body "…" Post a message to a thread
30
+ repoview review post <id> --role agent --file f Post from file
31
+ repoview review read <id> Read thread messages + comments
32
+ repoview review list List all threads
33
+
26
34
  Environment:
27
35
  REPO_ROOT, HOST, PORT
28
36
  `);
@@ -55,6 +63,16 @@ if (help) {
55
63
  process.exit(0);
56
64
  }
57
65
 
66
+ // Handle "review" subcommand
67
+ if (parsed.rest[0] === "review") {
68
+ const repoRootForReview =
69
+ repo ??
70
+ process.env.REPO_ROOT ??
71
+ process.cwd();
72
+ await handleReviewCommand(parsed.rest.slice(1), repoRootForReview);
73
+ process.exit(0);
74
+ }
75
+
58
76
  if (port != null && !Number.isFinite(port)) {
59
77
  process.stderr.write("Invalid --port value\n");
60
78
  process.exit(2);
package/src/markdown.js CHANGED
@@ -16,6 +16,145 @@ function escapeHtml(s) {
16
16
  .replaceAll("'", "&#39;");
17
17
  }
18
18
 
19
+ function stripQuotes(s) {
20
+ if (s.length >= 2) {
21
+ const first = s[0];
22
+ const last = s[s.length - 1];
23
+ if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
24
+ return s.slice(1, -1);
25
+ }
26
+ }
27
+ return s;
28
+ }
29
+
30
+ function parseFlowList(raw) {
31
+ return raw
32
+ .slice(1, -1)
33
+ .split(",")
34
+ .map((s) => stripQuotes(s.trim()))
35
+ .filter((s) => s.length > 0);
36
+ }
37
+
38
+ // Lightweight YAML frontmatter parser. Handles the common forms used in
39
+ // markdown frontmatter: `key: value`, quoted strings, `key: [a, b, c]`,
40
+ // and indented `- item` block lists. Returns null when the leading `---`
41
+ // block doesn't look like YAML, so plain horizontal rules pass through.
42
+ function extractFrontmatter(text) {
43
+ const match = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
44
+ if (!match) return { data: null, body: text };
45
+
46
+ const block = match[1];
47
+ const lines = block.split(/\r?\n/);
48
+ const data = {};
49
+ const order = [];
50
+
51
+ for (let i = 0; i < lines.length; i++) {
52
+ const line = lines[i];
53
+ if (!line.trim() || line.trim().startsWith("#")) continue;
54
+ const m = line.match(/^([A-Za-z_][\w-]*)\s*:\s*(.*)$/);
55
+ if (!m) {
56
+ // Anything other than a YAML key on a top-level line means this isn't
57
+ // really frontmatter — bail out and leave the body untouched.
58
+ return { data: null, body: text };
59
+ }
60
+ const key = m[1];
61
+ const raw = m[2].trim();
62
+ if (raw === "") {
63
+ const items = [];
64
+ let j = i + 1;
65
+ while (j < lines.length) {
66
+ const sub = lines[j].match(/^\s+-\s+(.*)$/);
67
+ if (!sub) break;
68
+ items.push(stripQuotes(sub[1].trim()));
69
+ j++;
70
+ }
71
+ if (items.length) {
72
+ data[key] = items;
73
+ order.push(key);
74
+ i = j - 1;
75
+ }
76
+ continue;
77
+ }
78
+ if (raw.startsWith("[") && raw.endsWith("]")) {
79
+ data[key] = parseFlowList(raw);
80
+ } else {
81
+ data[key] = stripQuotes(raw);
82
+ }
83
+ order.push(key);
84
+ }
85
+
86
+ if (order.length === 0) return { data: null, body: text };
87
+ // Replace the frontmatter region with blank lines so downstream source-line
88
+ // numbers stay aligned with the original markdown.
89
+ const consumed = match[0];
90
+ const newlineCount = (consumed.match(/\n/g) || []).length;
91
+ const body = "\n".repeat(newlineCount) + text.slice(consumed.length);
92
+ return { data, order, body };
93
+ }
94
+
95
+ const FRONTMATTER_KNOWN_KEYS = new Set([
96
+ "title",
97
+ "description",
98
+ "summary",
99
+ "subtitle",
100
+ "date",
101
+ "published",
102
+ "updated",
103
+ "author",
104
+ "authors",
105
+ "tags",
106
+ "categories",
107
+ ]);
108
+
109
+ function renderFrontmatter(data, order) {
110
+ const title = data.title;
111
+ const description = data.description ?? data.summary ?? data.subtitle;
112
+ const date = data.date ?? data.published ?? data.updated;
113
+ const authorVal = data.author ?? data.authors;
114
+ const tagsVal = data.tags ?? data.categories;
115
+
116
+ const parts = ['<div class="md-frontmatter">'];
117
+ if (title) parts.push(`<h1 class="md-frontmatter-title">${escapeHtml(title)}</h1>`);
118
+ if (description)
119
+ parts.push(`<p class="md-frontmatter-description">${escapeHtml(description)}</p>`);
120
+
121
+ const meta = [];
122
+ if (date) meta.push(`<span class="md-frontmatter-date">${escapeHtml(date)}</span>`);
123
+ if (authorVal) {
124
+ const authors = Array.isArray(authorVal) ? authorVal : [authorVal];
125
+ if (authors.length) {
126
+ meta.push(
127
+ `<span class="md-frontmatter-author">${authors.map(escapeHtml).join(", ")}</span>`,
128
+ );
129
+ }
130
+ }
131
+ if (tagsVal) {
132
+ const tags = Array.isArray(tagsVal) ? tagsVal : [tagsVal];
133
+ if (tags.length) {
134
+ const items = tags
135
+ .map((t) => `<li class="md-frontmatter-tag">${escapeHtml(t)}</li>`)
136
+ .join("");
137
+ meta.push(`<ul class="md-frontmatter-tags">${items}</ul>`);
138
+ }
139
+ }
140
+ if (meta.length) parts.push(`<div class="md-frontmatter-meta">${meta.join("")}</div>`);
141
+
142
+ const extras = (order || Object.keys(data)).filter((k) => !FRONTMATTER_KNOWN_KEYS.has(k));
143
+ if (extras.length) {
144
+ const rows = extras
145
+ .map((k) => {
146
+ const v = data[k];
147
+ const display = Array.isArray(v) ? v.join(", ") : v;
148
+ return `<dt>${escapeHtml(k)}</dt><dd>${escapeHtml(display)}</dd>`;
149
+ })
150
+ .join("");
151
+ parts.push(`<dl class="md-frontmatter-extra">${rows}</dl>`);
152
+ }
153
+
154
+ parts.push("</div>");
155
+ return parts.join("");
156
+ }
157
+
19
158
  // CommonMark only allows "1." to interrupt a paragraph, but GitHub allows any number.
20
159
  // This preprocessor adds blank lines before ordered lists starting with numbers other than 1.
21
160
  function normalizeOrderedLists(text) {
@@ -278,6 +417,18 @@ export function createMarkdownRenderer() {
278
417
  }
279
418
  });
280
419
 
420
+ // Source line mapping for inline comment anchoring (opt-in via env.emitLineMap)
421
+ md.core.ruler.push("source-line-map", (state) => {
422
+ if (!state.env.emitLineMap) return;
423
+ for (const token of state.tokens) {
424
+ if (token.nesting !== 1) continue; // only opening tokens
425
+ if (token.map && token.map.length === 2) {
426
+ token.attrSet("data-source-line-start", String(token.map[0] + 1));
427
+ token.attrSet("data-source-line-end", String(token.map[1]));
428
+ }
429
+ }
430
+ });
431
+
281
432
  function sanitize(html, env) {
282
433
  const baseDirPosix = env?.baseDirPosix || "";
283
434
  return sanitizeHtml(html, {
@@ -308,7 +459,7 @@ export function createMarkdownRenderer() {
308
459
  "input",
309
460
  ],
310
461
  allowedAttributes: {
311
- "*": ["class", "id", "aria-label", "aria-hidden", "role", "align"],
462
+ "*": ["class", "id", "aria-label", "aria-hidden", "role", "align", "data-source-line-start", "data-source-line-end"],
312
463
  a: ["href", "name", "title", "target", "rel", "tabindex"],
313
464
  img: ["src", "alt", "title", "width", "height", "loading"],
314
465
  input: ["type", "checked", "disabled"],
@@ -347,8 +498,11 @@ export function createMarkdownRenderer() {
347
498
  return {
348
499
  render(markdown, env) {
349
500
  const e = env ?? {};
350
- const html = md.render(markdown ?? "", e);
351
- return sanitize(html, e);
501
+ const source = markdown ?? "";
502
+ const fm = extractFrontmatter(source);
503
+ const fmHtml = fm.data ? renderFrontmatter(fm.data, fm.order) : "";
504
+ const html = md.render(fm.body, e);
505
+ return sanitize(fmHtml + html, e);
352
506
  },
353
507
  renderCodeBlock(text, { languageHint } = {}) {
354
508
  const lang = languageHint && hljs.getLanguage(languageHint) ? languageHint : "";
@@ -0,0 +1,245 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ function generateThreadId(title) {
5
+ const date = new Date().toISOString().slice(0, 10);
6
+ const slug = title
7
+ .toLowerCase()
8
+ .replace(/[^a-z0-9]+/g, "-")
9
+ .replace(/^-|-$/g, "")
10
+ .slice(0, 50);
11
+ return `${date}-${slug}`;
12
+ }
13
+
14
+ function getNextMessageId(existingIds) {
15
+ let max = 0;
16
+ for (const id of existingIds) {
17
+ const n = parseInt(id, 10);
18
+ if (n > max) max = n;
19
+ }
20
+ return String(max + 1).padStart(3, "0");
21
+ }
22
+
23
+ export async function reviewNew({ title, reviewDir }) {
24
+ if (!title) {
25
+ process.stderr.write("Error: --title is required\n");
26
+ process.exit(1);
27
+ }
28
+
29
+ const id = generateThreadId(title);
30
+ const threadDir = path.join(reviewDir, id);
31
+ const messagesDir = path.join(threadDir, "messages");
32
+
33
+ await fs.mkdir(messagesDir, { recursive: true });
34
+
35
+ const now = new Date().toISOString();
36
+ const thread = {
37
+ id,
38
+ title,
39
+ createdAt: now,
40
+ lastActivityAt: now,
41
+ readUntil: null,
42
+ };
43
+
44
+ await fs.writeFile(path.join(threadDir, "thread.json"), JSON.stringify(thread, null, 2) + "\n");
45
+ await fs.writeFile(path.join(threadDir, "comments.json"), JSON.stringify({ comments: [] }, null, 2) + "\n");
46
+
47
+ process.stdout.write(id + "\n");
48
+ }
49
+
50
+ export async function reviewPost({ threadId, role, body, file, reviewDir }) {
51
+ if (!threadId) {
52
+ process.stderr.write("Error: thread-id is required\n");
53
+ process.exit(1);
54
+ }
55
+
56
+ const threadDir = path.join(reviewDir, threadId);
57
+ const messagesDir = path.join(threadDir, "messages");
58
+ const threadFile = path.join(threadDir, "thread.json");
59
+
60
+ try {
61
+ await fs.stat(threadFile);
62
+ } catch {
63
+ process.stderr.write(`Error: thread "${threadId}" not found\n`);
64
+ process.exit(1);
65
+ }
66
+
67
+ let messageBody = body || "";
68
+ if (file) {
69
+ messageBody = await fs.readFile(file, "utf8");
70
+ } else if (!body) {
71
+ // Read from stdin
72
+ const chunks = [];
73
+ for await (const chunk of process.stdin) {
74
+ chunks.push(chunk);
75
+ }
76
+ messageBody = Buffer.concat(chunks).toString("utf8");
77
+ }
78
+
79
+ if (!messageBody.trim()) {
80
+ process.stderr.write("Error: message body is empty\n");
81
+ process.exit(1);
82
+ }
83
+
84
+ // Find next message ID
85
+ let entries = [];
86
+ try {
87
+ entries = await fs.readdir(messagesDir);
88
+ } catch {
89
+ await fs.mkdir(messagesDir, { recursive: true });
90
+ }
91
+ const existingIds = entries.filter((e) => e.endsWith(".json")).map((e) => e.replace(".json", ""));
92
+ const nextId = getNextMessageId(existingIds);
93
+
94
+ const now = new Date().toISOString();
95
+ const messageRole = role || "agent";
96
+ const format = messageRole === "agent" ? "markdown" : "text";
97
+
98
+ const message = {
99
+ id: nextId,
100
+ role: messageRole,
101
+ format,
102
+ body: messageBody,
103
+ createdAt: now,
104
+ };
105
+
106
+ await fs.writeFile(path.join(messagesDir, `${nextId}.json`), JSON.stringify(message, null, 2) + "\n");
107
+
108
+ // Update lastActivityAt in thread.json
109
+ const thread = JSON.parse(await fs.readFile(threadFile, "utf8"));
110
+ thread.lastActivityAt = now;
111
+ await fs.writeFile(threadFile, JSON.stringify(thread, null, 2) + "\n");
112
+
113
+ process.stdout.write(nextId + "\n");
114
+ }
115
+
116
+ export async function reviewRead({ threadId, reviewDir }) {
117
+ if (!threadId) {
118
+ process.stderr.write("Error: thread-id is required\n");
119
+ process.exit(1);
120
+ }
121
+
122
+ const threadDir = path.join(reviewDir, threadId);
123
+ const threadFile = path.join(threadDir, "thread.json");
124
+
125
+ try {
126
+ await fs.stat(threadFile);
127
+ } catch {
128
+ process.stderr.write(`Error: thread "${threadId}" not found\n`);
129
+ process.exit(1);
130
+ }
131
+
132
+ const thread = JSON.parse(await fs.readFile(threadFile, "utf8"));
133
+
134
+ const messagesDir = path.join(threadDir, "messages");
135
+ let messageFiles = [];
136
+ try {
137
+ messageFiles = (await fs.readdir(messagesDir)).filter((f) => f.endsWith(".json")).sort();
138
+ } catch {
139
+ // no messages yet
140
+ }
141
+
142
+ const messages = [];
143
+ for (const f of messageFiles) {
144
+ const msg = JSON.parse(await fs.readFile(path.join(messagesDir, f), "utf8"));
145
+ messages.push(msg);
146
+ }
147
+
148
+ let comments = { comments: [] };
149
+ try {
150
+ comments = JSON.parse(await fs.readFile(path.join(threadDir, "comments.json"), "utf8"));
151
+ } catch {
152
+ // no comments file
153
+ }
154
+
155
+ const result = { thread, messages, comments: comments.comments };
156
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
157
+ }
158
+
159
+ export async function reviewList({ reviewDir }) {
160
+ let entries = [];
161
+ try {
162
+ entries = await fs.readdir(reviewDir, { withFileTypes: true });
163
+ } catch {
164
+ process.stdout.write("[]\n");
165
+ return;
166
+ }
167
+
168
+ const threads = [];
169
+ for (const entry of entries) {
170
+ if (!entry.isDirectory()) continue;
171
+ const threadFile = path.join(reviewDir, entry.name, "thread.json");
172
+ try {
173
+ const thread = JSON.parse(await fs.readFile(threadFile, "utf8"));
174
+ // Count messages
175
+ let messageCount = 0;
176
+ try {
177
+ const msgs = await fs.readdir(path.join(reviewDir, entry.name, "messages"));
178
+ messageCount = msgs.filter((f) => f.endsWith(".json")).length;
179
+ } catch {
180
+ // no messages
181
+ }
182
+ threads.push({ ...thread, messageCount });
183
+ } catch {
184
+ // skip dirs without thread.json
185
+ }
186
+ }
187
+
188
+ // Sort by lastActivityAt, newest first
189
+ threads.sort((a, b) => new Date(b.lastActivityAt) - new Date(a.lastActivityAt));
190
+
191
+ process.stdout.write(JSON.stringify(threads, null, 2) + "\n");
192
+ }
193
+
194
+ export async function handleReviewCommand(argv, repoRoot) {
195
+ const subcommand = argv[0];
196
+ const args = argv.slice(1);
197
+
198
+ // Parse --review-dir flag
199
+ let reviewDir = path.join(repoRoot, ".repoview", "reviews");
200
+ const rest = [];
201
+ for (let i = 0; i < args.length; i++) {
202
+ if (args[i] === "--review-dir") {
203
+ reviewDir = args[++i];
204
+ } else {
205
+ rest.push(args[i]);
206
+ }
207
+ }
208
+
209
+ // Parse subcommand-specific flags
210
+ const flags = {};
211
+ const positional = [];
212
+ for (let i = 0; i < rest.length; i++) {
213
+ const v = rest[i];
214
+ if (v === "--title") flags.title = rest[++i];
215
+ else if (v === "--role") flags.role = rest[++i];
216
+ else if (v === "--body") flags.body = rest[++i];
217
+ else if (v === "--file") flags.file = rest[++i];
218
+ else positional.push(v);
219
+ }
220
+
221
+ switch (subcommand) {
222
+ case "new":
223
+ await reviewNew({ title: flags.title, reviewDir });
224
+ break;
225
+ case "post":
226
+ await reviewPost({
227
+ threadId: positional[0],
228
+ role: flags.role,
229
+ body: flags.body,
230
+ file: flags.file,
231
+ reviewDir,
232
+ });
233
+ break;
234
+ case "read":
235
+ await reviewRead({ threadId: positional[0], reviewDir });
236
+ break;
237
+ case "list":
238
+ await reviewList({ reviewDir });
239
+ break;
240
+ default:
241
+ process.stderr.write(`Unknown review subcommand: ${subcommand}\n`);
242
+ process.stderr.write("Usage: repoview review <new|post|read|list> [options]\n");
243
+ process.exit(1);
244
+ }
245
+ }