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.
@@ -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 COMMENT_HEADING_REGEX = /^###\s+(CMT-(\d{4,}))\s*$/;
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 createdAt = new Date().toISOString();
23
- const meta = buildMetaPayload(request.range, request.sourceMeta);
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(commentId, createdAt, request.author, request.message, meta64);
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(range, sourceMeta) {
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 (range) {
58
- payload.anchor = "selection";
59
- payload.excerpt = {
60
- start_line: range.startLine,
61
- start_col: range.startCol,
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 (sourceMeta && Object.keys(sourceMeta).length > 0) {
67
- payload.signature = sourceMeta;
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(commentId, timestamp, author, message, meta64) {
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
- return [
78
- `### ${commentId}`,
79
- `<!-- meta64: ${meta64} -->`,
80
- `> _${timestamp} | ${safeAuthor}_`,
81
- ">",
82
- ...normalizedMessageLines.map((line) => `> ${line}`)
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 lines) {
148
- const match = line.trim().match(COMMENT_HEADING_REGEX);
149
- if (!match) {
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 headingMatch = trimmed.match(/^###\s+(CMT-\d{4})\s*$/);
1429
- if (!headingMatch) {
1430
- issues.push(makeIssue("error", "comments", "Invalid comments appendix line. Expected heading like '### CMT-0001'.", relativePath, baseLine + index));
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 (/^###\s+CMT-\d{4}\s*$/.test(nextTrimmed)) {
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 | author_'.`, relativePath, lineNumber));
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 | author_'.`, relativePath, lineNumber));
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(["status", "anchor", "paragraph_index", "signature", "excerpt"]);
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*\|\s*(.+?)_\s*$/);
1618
+ const match = value.trim().match(/^_(.+?)\s*(?:—|\|)\s*(.+?)_\s*$/);
1596
1619
  if (!match) {
1597
1620
  return undefined;
1598
1621
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stego-cli",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
4
4
  "type": "module",
5
5
  "description": "Installable CLI for the Stego writing monorepo workflow.",
6
6
  "license": "Apache-2.0",