repoview 0.5.0 → 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/package.json +7 -2
- package/public/app.css +760 -0
- package/public/app.js +31 -0
- package/public/review.js +584 -0
- package/src/cli.js +18 -0
- package/src/markdown.js +157 -3
- package/src/review-cli.js +245 -0
- package/src/server.js +366 -0
- package/src/views.js +178 -0
package/src/markdown.js
CHANGED
|
@@ -16,6 +16,145 @@ function escapeHtml(s) {
|
|
|
16
16
|
.replaceAll("'", "'");
|
|
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
|
|
351
|
-
|
|
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
|
+
}
|