stego-cli 0.3.1 → 0.3.3
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/dist/comments/add-comment.js +235 -30
- package/dist/stego-cli.js +32 -9
- package/package.json +1 -1
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { CommentsCommandError } from "./errors.js";
|
|
4
|
-
const
|
|
4
|
+
const COMMENT_DELIMITER_REGEX = /^<!--\s*comment:\s*(CMT-(\d{4,}))\s*-->\s*$/i;
|
|
5
|
+
const LEGACY_COMMENT_HEADING_REGEX = /^###\s+(CMT-(\d{4,}))\s*$/i;
|
|
5
6
|
const START_SENTINEL = "<!-- stego-comments:start -->";
|
|
6
7
|
const END_SENTINEL = "<!-- stego-comments:end -->";
|
|
7
8
|
/** Adds one stego comment entry to a manuscript and writes the updated file. */
|
|
@@ -18,11 +19,35 @@ export function addCommentToManuscript(request) {
|
|
|
18
19
|
if (!sentinelState.hasSentinels && !hasYamlFrontmatter) {
|
|
19
20
|
throw new CommentsCommandError("NOT_STEGO_MANUSCRIPT", 3, "File is not recognized as a Stego manuscript (missing frontmatter and comments sentinels).");
|
|
20
21
|
}
|
|
21
|
-
const commentId = getNextCommentId(lines);
|
|
22
|
-
const
|
|
23
|
-
const
|
|
22
|
+
const commentId = getNextCommentId(lines, sentinelState);
|
|
23
|
+
const now = new Date();
|
|
24
|
+
const createdAt = now.toISOString();
|
|
25
|
+
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || undefined;
|
|
26
|
+
const timezoneOffsetMinutes = -now.getTimezoneOffset();
|
|
27
|
+
const paragraphIndex = request.range
|
|
28
|
+
? resolveParagraphIndex(lines, lineEnding, sentinelState, request.range)
|
|
29
|
+
: undefined;
|
|
30
|
+
const excerpt = request.range
|
|
31
|
+
? extractExcerptForRange(lines, request.range)
|
|
32
|
+
: undefined;
|
|
33
|
+
const meta = buildMetaPayload({
|
|
34
|
+
createdAt,
|
|
35
|
+
timezone,
|
|
36
|
+
timezoneOffsetMinutes,
|
|
37
|
+
paragraphIndex,
|
|
38
|
+
range: request.range,
|
|
39
|
+
sourceMeta: request.sourceMeta
|
|
40
|
+
});
|
|
24
41
|
const meta64 = Buffer.from(JSON.stringify(meta), "utf8").toString("base64url");
|
|
25
|
-
const entryLines = buildCommentEntryLines(
|
|
42
|
+
const entryLines = buildCommentEntryLines({
|
|
43
|
+
commentId,
|
|
44
|
+
createdAt,
|
|
45
|
+
author: request.author,
|
|
46
|
+
message: request.message,
|
|
47
|
+
meta64,
|
|
48
|
+
paragraphIndex,
|
|
49
|
+
excerpt
|
|
50
|
+
});
|
|
26
51
|
const nextLines = applyCommentEntry(lines, sentinelState, entryLines);
|
|
27
52
|
const nextContent = `${nextLines.join(lineEnding)}${lineEnding}`;
|
|
28
53
|
try {
|
|
@@ -50,37 +75,53 @@ export function addCommentToManuscript(request) {
|
|
|
50
75
|
};
|
|
51
76
|
return result;
|
|
52
77
|
}
|
|
53
|
-
function buildMetaPayload(
|
|
78
|
+
function buildMetaPayload(input) {
|
|
54
79
|
const payload = {
|
|
55
|
-
status: "open"
|
|
80
|
+
status: "open",
|
|
81
|
+
created_at: input.createdAt,
|
|
82
|
+
timezone_offset_minutes: input.timezoneOffsetMinutes
|
|
56
83
|
};
|
|
57
|
-
if (
|
|
58
|
-
payload.
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
end_line: range.endLine,
|
|
63
|
-
end_col: range.endCol
|
|
64
|
-
};
|
|
84
|
+
if (input.timezone) {
|
|
85
|
+
payload.timezone = input.timezone;
|
|
86
|
+
}
|
|
87
|
+
if (input.paragraphIndex !== undefined) {
|
|
88
|
+
payload.paragraph_index = input.paragraphIndex;
|
|
65
89
|
}
|
|
66
|
-
if (
|
|
67
|
-
payload.
|
|
90
|
+
if (input.range) {
|
|
91
|
+
payload.excerpt_start_line = input.range.startLine;
|
|
92
|
+
payload.excerpt_start_col = input.range.startCol;
|
|
93
|
+
payload.excerpt_end_line = input.range.endLine;
|
|
94
|
+
payload.excerpt_end_col = input.range.endCol;
|
|
95
|
+
}
|
|
96
|
+
if (input.sourceMeta && Object.keys(input.sourceMeta).length > 0) {
|
|
97
|
+
payload.signature = input.sourceMeta;
|
|
68
98
|
}
|
|
69
99
|
return payload;
|
|
70
100
|
}
|
|
71
|
-
function buildCommentEntryLines(
|
|
72
|
-
const safeAuthor = author.trim() || "Saurus";
|
|
73
|
-
const normalizedMessageLines = message
|
|
101
|
+
function buildCommentEntryLines(input) {
|
|
102
|
+
const safeAuthor = input.author.trim() || "Saurus";
|
|
103
|
+
const normalizedMessageLines = input.message
|
|
74
104
|
.replace(/\r\n/g, "\n")
|
|
75
105
|
.split("\n")
|
|
76
106
|
.map((line) => line.trimEnd());
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
107
|
+
const displayTimestamp = formatHumanTimestamp(input.createdAt);
|
|
108
|
+
const headerTimestamp = escapeThreadHeaderPart(displayTimestamp);
|
|
109
|
+
const headerAuthor = escapeThreadHeaderPart(safeAuthor);
|
|
110
|
+
const lines = [
|
|
111
|
+
`<!-- comment: ${input.commentId} -->`,
|
|
112
|
+
`<!-- meta64: ${input.meta64} -->`,
|
|
113
|
+
`> _${headerTimestamp} — ${headerAuthor}_`,
|
|
114
|
+
">"
|
|
83
115
|
];
|
|
116
|
+
if (input.paragraphIndex !== undefined && input.excerpt) {
|
|
117
|
+
const truncatedExcerpt = input.excerpt.length > 100
|
|
118
|
+
? `${input.excerpt.slice(0, 100).trimEnd()}…`
|
|
119
|
+
: input.excerpt;
|
|
120
|
+
lines.push(`> > “${truncatedExcerpt}”`);
|
|
121
|
+
lines.push(">");
|
|
122
|
+
}
|
|
123
|
+
lines.push(...normalizedMessageLines.map((line) => `> ${line}`));
|
|
124
|
+
return lines;
|
|
84
125
|
}
|
|
85
126
|
function applyCommentEntry(lines, sentinelState, entryLines) {
|
|
86
127
|
if (!sentinelState.hasSentinels) {
|
|
@@ -142,11 +183,15 @@ function findTrimmedLineIndexes(lines, target) {
|
|
|
142
183
|
}
|
|
143
184
|
return indexes;
|
|
144
185
|
}
|
|
145
|
-
function getNextCommentId(lines) {
|
|
186
|
+
function getNextCommentId(lines, sentinelState) {
|
|
187
|
+
const candidateLines = sentinelState.hasSentinels
|
|
188
|
+
? lines.slice(sentinelState.startIndex + 1, sentinelState.endIndex)
|
|
189
|
+
: lines;
|
|
146
190
|
let maxId = 0;
|
|
147
|
-
for (const line of
|
|
148
|
-
const
|
|
149
|
-
|
|
191
|
+
for (const line of candidateLines) {
|
|
192
|
+
const trimmed = line.trim();
|
|
193
|
+
const match = trimmed.match(COMMENT_DELIMITER_REGEX) ?? trimmed.match(LEGACY_COMMENT_HEADING_REGEX);
|
|
194
|
+
if (!match || !match[2]) {
|
|
150
195
|
continue;
|
|
151
196
|
}
|
|
152
197
|
const numeric = Number.parseInt(match[2], 10);
|
|
@@ -157,6 +202,166 @@ function getNextCommentId(lines) {
|
|
|
157
202
|
const next = maxId + 1;
|
|
158
203
|
return `CMT-${String(next).padStart(4, "0")}`;
|
|
159
204
|
}
|
|
205
|
+
function resolveParagraphIndex(lines, lineEnding, sentinelState, range) {
|
|
206
|
+
const markdownWithoutComments = stripCommentsAppendix(lines, sentinelState, lineEnding);
|
|
207
|
+
const { body, lineOffset } = splitFrontmatterForAnchors(markdownWithoutComments);
|
|
208
|
+
const paragraphs = extractParagraphs(body).map((paragraph) => ({
|
|
209
|
+
...paragraph,
|
|
210
|
+
startLine: paragraph.startLine + lineOffset,
|
|
211
|
+
endLine: paragraph.endLine + lineOffset
|
|
212
|
+
}));
|
|
213
|
+
const matched = findParagraphForLine(paragraphs, range.startLine) ?? findPreviousParagraphForLine(paragraphs, range.startLine);
|
|
214
|
+
return matched?.index;
|
|
215
|
+
}
|
|
216
|
+
function extractExcerptForRange(lines, range) {
|
|
217
|
+
const startLineIndex = range.startLine - 1;
|
|
218
|
+
const endLineIndex = range.endLine - 1;
|
|
219
|
+
if (startLineIndex < 0 || endLineIndex < 0 || startLineIndex >= lines.length || endLineIndex >= lines.length) {
|
|
220
|
+
return undefined;
|
|
221
|
+
}
|
|
222
|
+
if (startLineIndex > endLineIndex || (startLineIndex === endLineIndex && range.startCol >= range.endCol)) {
|
|
223
|
+
return undefined;
|
|
224
|
+
}
|
|
225
|
+
const startLineText = lines[startLineIndex] ?? "";
|
|
226
|
+
const endLineText = lines[endLineIndex] ?? "";
|
|
227
|
+
const safeStartCol = clamp(range.startCol, 0, startLineText.length);
|
|
228
|
+
const safeEndCol = clamp(range.endCol, 0, endLineText.length);
|
|
229
|
+
let selected = "";
|
|
230
|
+
if (startLineIndex === endLineIndex) {
|
|
231
|
+
if (safeStartCol >= safeEndCol) {
|
|
232
|
+
return undefined;
|
|
233
|
+
}
|
|
234
|
+
selected = startLineText.slice(safeStartCol, safeEndCol);
|
|
235
|
+
}
|
|
236
|
+
else {
|
|
237
|
+
const segments = [];
|
|
238
|
+
segments.push(startLineText.slice(safeStartCol));
|
|
239
|
+
for (let lineIndex = startLineIndex + 1; lineIndex < endLineIndex; lineIndex += 1) {
|
|
240
|
+
segments.push(lines[lineIndex] ?? "");
|
|
241
|
+
}
|
|
242
|
+
segments.push(endLineText.slice(0, safeEndCol));
|
|
243
|
+
selected = segments.join("\n");
|
|
244
|
+
}
|
|
245
|
+
const compact = compactExcerpt(selected);
|
|
246
|
+
return compact || undefined;
|
|
247
|
+
}
|
|
248
|
+
function clamp(value, min, max) {
|
|
249
|
+
if (value < min) {
|
|
250
|
+
return min;
|
|
251
|
+
}
|
|
252
|
+
if (value > max) {
|
|
253
|
+
return max;
|
|
254
|
+
}
|
|
255
|
+
return value;
|
|
256
|
+
}
|
|
257
|
+
function compactExcerpt(value, max = 180) {
|
|
258
|
+
const compact = value.replace(/\s+/g, " ").trim();
|
|
259
|
+
if (compact.length <= max) {
|
|
260
|
+
return compact;
|
|
261
|
+
}
|
|
262
|
+
return `${compact.slice(0, max - 1)}…`;
|
|
263
|
+
}
|
|
264
|
+
function stripCommentsAppendix(lines, sentinelState, lineEnding) {
|
|
265
|
+
if (!sentinelState.hasSentinels) {
|
|
266
|
+
return lines.join(lineEnding);
|
|
267
|
+
}
|
|
268
|
+
let removeStart = sentinelState.startIndex;
|
|
269
|
+
if (removeStart > 0 && lines[removeStart - 1].trim().length === 0) {
|
|
270
|
+
removeStart -= 1;
|
|
271
|
+
}
|
|
272
|
+
const keptLines = [...lines.slice(0, removeStart), ...lines.slice(sentinelState.endIndex + 1)];
|
|
273
|
+
while (keptLines.length > 0 && keptLines[keptLines.length - 1].trim().length === 0) {
|
|
274
|
+
keptLines.pop();
|
|
275
|
+
}
|
|
276
|
+
return keptLines.join(lineEnding);
|
|
277
|
+
}
|
|
278
|
+
function splitFrontmatterForAnchors(markdownText) {
|
|
279
|
+
const frontmatterMatch = markdownText.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
|
|
280
|
+
if (!frontmatterMatch) {
|
|
281
|
+
return {
|
|
282
|
+
body: markdownText,
|
|
283
|
+
lineOffset: 0
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
const consumed = frontmatterMatch[0];
|
|
287
|
+
return {
|
|
288
|
+
body: markdownText.slice(consumed.length),
|
|
289
|
+
lineOffset: countLineBreaks(consumed)
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
function countLineBreaks(value) {
|
|
293
|
+
const matches = value.match(/\r?\n/g);
|
|
294
|
+
return matches ? matches.length : 0;
|
|
295
|
+
}
|
|
296
|
+
function extractParagraphs(markdownText) {
|
|
297
|
+
const lines = markdownText.split(/\r?\n/);
|
|
298
|
+
const paragraphs = [];
|
|
299
|
+
let currentStart = -1;
|
|
300
|
+
const currentLines = [];
|
|
301
|
+
const flush = (endIndex) => {
|
|
302
|
+
if (currentStart < 0 || currentLines.length === 0) {
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
const joined = currentLines.join(" ").replace(/\s+/g, " ").trim();
|
|
306
|
+
if (joined.length > 0) {
|
|
307
|
+
paragraphs.push({
|
|
308
|
+
index: paragraphs.length,
|
|
309
|
+
startLine: currentStart + 1,
|
|
310
|
+
endLine: endIndex + 1,
|
|
311
|
+
text: joined
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
currentStart = -1;
|
|
315
|
+
currentLines.length = 0;
|
|
316
|
+
};
|
|
317
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
318
|
+
const trimmed = lines[index].trim();
|
|
319
|
+
if (!trimmed) {
|
|
320
|
+
flush(index - 1);
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
if (currentStart < 0) {
|
|
324
|
+
currentStart = index;
|
|
325
|
+
}
|
|
326
|
+
currentLines.push(trimmed);
|
|
327
|
+
}
|
|
328
|
+
flush(lines.length - 1);
|
|
329
|
+
return paragraphs;
|
|
330
|
+
}
|
|
331
|
+
function findParagraphForLine(paragraphs, lineNumber) {
|
|
332
|
+
return paragraphs.find((paragraph) => lineNumber >= paragraph.startLine && lineNumber <= paragraph.endLine);
|
|
333
|
+
}
|
|
334
|
+
function findPreviousParagraphForLine(paragraphs, lineNumber) {
|
|
335
|
+
for (let index = paragraphs.length - 1; index >= 0; index -= 1) {
|
|
336
|
+
if (paragraphs[index].endLine < lineNumber) {
|
|
337
|
+
return paragraphs[index];
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
return undefined;
|
|
341
|
+
}
|
|
342
|
+
function formatHumanTimestamp(raw) {
|
|
343
|
+
if (!/^\d{4}-\d{2}-\d{2}T/.test(raw)) {
|
|
344
|
+
return raw;
|
|
345
|
+
}
|
|
346
|
+
const date = new Date(raw);
|
|
347
|
+
if (Number.isNaN(date.getTime())) {
|
|
348
|
+
return raw;
|
|
349
|
+
}
|
|
350
|
+
const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
|
351
|
+
const month = months[date.getUTCMonth()];
|
|
352
|
+
const day = date.getUTCDate();
|
|
353
|
+
const year = date.getUTCFullYear();
|
|
354
|
+
const hours24 = date.getUTCHours();
|
|
355
|
+
const hours = hours24 % 12 || 12;
|
|
356
|
+
const minutes = String(date.getUTCMinutes()).padStart(2, "0");
|
|
357
|
+
const ampm = hours24 < 12 ? "AM" : "PM";
|
|
358
|
+
return `${month} ${day}, ${year}, ${hours}:${minutes} ${ampm}`;
|
|
359
|
+
}
|
|
360
|
+
function escapeThreadHeaderPart(value) {
|
|
361
|
+
return value
|
|
362
|
+
.replace(/\\/g, "\\\\")
|
|
363
|
+
.replace(/_/g, "\\_");
|
|
364
|
+
}
|
|
160
365
|
function trimTrailingBlankLines(lines) {
|
|
161
366
|
const copy = [...lines];
|
|
162
367
|
while (copy.length > 0 && copy[copy.length - 1].trim().length === 0) {
|
package/dist/stego-cli.js
CHANGED
|
@@ -1425,19 +1425,18 @@ function parseStegoCommentThreads(lines, relativePath, baseLine, issues) {
|
|
|
1425
1425
|
index += 1;
|
|
1426
1426
|
continue;
|
|
1427
1427
|
}
|
|
1428
|
-
const
|
|
1429
|
-
if (!
|
|
1430
|
-
issues.push(makeIssue("error", "comments", "Invalid comments appendix line. Expected
|
|
1428
|
+
const id = parseCommentThreadDelimiter(trimmed);
|
|
1429
|
+
if (!id) {
|
|
1430
|
+
issues.push(makeIssue("error", "comments", "Invalid comments appendix line. Expected comment delimiter '<!-- comment: CMT-0001 -->'.", relativePath, baseLine + index));
|
|
1431
1431
|
index += 1;
|
|
1432
1432
|
continue;
|
|
1433
1433
|
}
|
|
1434
|
-
const id = headingMatch[1];
|
|
1435
1434
|
index += 1;
|
|
1436
1435
|
const rowLines = [];
|
|
1437
1436
|
const rowLineNumbers = [];
|
|
1438
1437
|
while (index < lines.length) {
|
|
1439
1438
|
const nextTrimmed = lines[index].trim();
|
|
1440
|
-
if (
|
|
1439
|
+
if (parseCommentThreadDelimiter(nextTrimmed)) {
|
|
1441
1440
|
break;
|
|
1442
1441
|
}
|
|
1443
1442
|
rowLines.push(lines[index]);
|
|
@@ -1477,13 +1476,13 @@ function parseStegoCommentThreads(lines, relativePath, baseLine, issues) {
|
|
|
1477
1476
|
}
|
|
1478
1477
|
const headerQuote = extractQuotedLine(rawRow);
|
|
1479
1478
|
if (headerQuote === undefined) {
|
|
1480
|
-
issues.push(makeIssue("error", "comments", `Invalid thread header '${trimmedRow}'. Expected blockquote header like '> _timestamp
|
|
1479
|
+
issues.push(makeIssue("error", "comments", `Invalid thread header '${trimmedRow}'. Expected blockquote header like '> _timestamp — author_'.`, relativePath, lineNumber));
|
|
1481
1480
|
rowIndex += 1;
|
|
1482
1481
|
continue;
|
|
1483
1482
|
}
|
|
1484
1483
|
const header = parseThreadHeader(headerQuote);
|
|
1485
1484
|
if (!header) {
|
|
1486
|
-
issues.push(makeIssue("error", "comments", `Invalid thread header '${headerQuote.trim()}'. Expected '> _timestamp
|
|
1485
|
+
issues.push(makeIssue("error", "comments", `Invalid thread header '${headerQuote.trim()}'. Expected '> _timestamp — author_'.`, relativePath, lineNumber));
|
|
1487
1486
|
rowIndex += 1;
|
|
1488
1487
|
continue;
|
|
1489
1488
|
}
|
|
@@ -1549,6 +1548,17 @@ function parseStegoCommentThreads(lines, relativePath, baseLine, issues) {
|
|
|
1549
1548
|
}
|
|
1550
1549
|
return comments;
|
|
1551
1550
|
}
|
|
1551
|
+
function parseCommentThreadDelimiter(line) {
|
|
1552
|
+
const markerMatch = line.match(/^<!--\s*comment:\s*(CMT-\d{4,})\s*-->\s*$/i);
|
|
1553
|
+
if (markerMatch?.[1]) {
|
|
1554
|
+
return markerMatch[1].toUpperCase();
|
|
1555
|
+
}
|
|
1556
|
+
const legacyHeadingMatch = line.match(/^###\s+(CMT-\d{4,})\s*$/i);
|
|
1557
|
+
if (legacyHeadingMatch?.[1]) {
|
|
1558
|
+
return legacyHeadingMatch[1].toUpperCase();
|
|
1559
|
+
}
|
|
1560
|
+
return undefined;
|
|
1561
|
+
}
|
|
1552
1562
|
function decodeCommentMeta64(encoded, commentId, relativePath, lineNumber, issues) {
|
|
1553
1563
|
let rawJson = "";
|
|
1554
1564
|
try {
|
|
@@ -1570,7 +1580,20 @@ function decodeCommentMeta64(encoded, commentId, relativePath, lineNumber, issue
|
|
|
1570
1580
|
issues.push(makeIssue("error", "comments", `Invalid meta64 object for comment ${commentId}.`, relativePath, lineNumber));
|
|
1571
1581
|
return undefined;
|
|
1572
1582
|
}
|
|
1573
|
-
const allowedKeys = new Set([
|
|
1583
|
+
const allowedKeys = new Set([
|
|
1584
|
+
"status",
|
|
1585
|
+
"created_at",
|
|
1586
|
+
"timezone",
|
|
1587
|
+
"timezone_offset_minutes",
|
|
1588
|
+
"paragraph_index",
|
|
1589
|
+
"excerpt_start_line",
|
|
1590
|
+
"excerpt_start_col",
|
|
1591
|
+
"excerpt_end_line",
|
|
1592
|
+
"excerpt_end_col",
|
|
1593
|
+
"anchor",
|
|
1594
|
+
"excerpt",
|
|
1595
|
+
"signature"
|
|
1596
|
+
]);
|
|
1574
1597
|
for (const key of Object.keys(parsed)) {
|
|
1575
1598
|
if (!allowedKeys.has(key)) {
|
|
1576
1599
|
issues.push(makeIssue("error", "comments", `meta64 for comment ${commentId} contains unsupported key '${key}'.`, relativePath, lineNumber));
|
|
@@ -1592,7 +1615,7 @@ function extractQuotedLine(raw) {
|
|
|
1592
1615
|
return quoteMatch[1];
|
|
1593
1616
|
}
|
|
1594
1617
|
function parseThreadHeader(value) {
|
|
1595
|
-
const match = value.trim().match(/^_(.+?)\s
|
|
1618
|
+
const match = value.trim().match(/^_(.+?)\s*(?:—|\|)\s*(.+?)_\s*$/);
|
|
1596
1619
|
if (!match) {
|
|
1597
1620
|
return undefined;
|
|
1598
1621
|
}
|