stego-cli 0.3.2 → 0.3.4

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/stego-cli.js CHANGED
@@ -8,6 +8,7 @@ import { createInterface } from "node:readline/promises";
8
8
  import { fileURLToPath } from "node:url";
9
9
  import { markdownExporter } from "./exporters/markdown-exporter.js";
10
10
  import { createPandocExporter } from "./exporters/pandoc-exporter.js";
11
+ import { parseCommentAppendix } from "./comments/comment-domain.js";
11
12
  import { runCommentsCommand } from "./comments/comments-command.js";
12
13
  import { CommentsCommandError } from "./comments/errors.js";
13
14
  const STATUS_RANK = {
@@ -767,7 +768,7 @@ function writeInitRootPackageJson(targetRoot) {
767
768
  fs.writeFileSync(path.join(targetRoot, "package.json"), `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
768
769
  }
769
770
  function printUsage() {
770
- console.log(`Stego CLI\n\nCommands:\n init [--force]\n list-projects [--root <path>]\n new-project --project <project-id> [--title <title>] [--root <path>]\n new --project <project-id> [--i <prefix>|-i <prefix>] [--root <path>]\n validate --project <project-id> [--file <project-relative-manuscript-path>] [--root <path>]\n build --project <project-id> [--root <path>]\n check-stage --project <project-id> --stage <draft|revise|line-edit|proof|final> [--file <project-relative-manuscript-path>] [--root <path>]\n lint --project <project-id> [--manuscript|--spine] [--root <path>]\n export --project <project-id> --format <md|docx|pdf|epub> [--output <path>] [--root <path>]\n comments add <manuscript> [--message <text> | --input <path|->] [--author <name>] [--start-line <n> --start-col <n> --end-line <n> --end-col <n>] [--format <text|json>]\n`);
771
+ console.log(`Stego CLI\n\nCommands:\n init [--force]\n list-projects [--root <path>]\n new-project --project <project-id> [--title <title>] [--root <path>]\n new --project <project-id> [--i <prefix>|-i <prefix>] [--root <path>]\n validate --project <project-id> [--file <project-relative-manuscript-path>] [--root <path>]\n build --project <project-id> [--root <path>]\n check-stage --project <project-id> --stage <draft|revise|line-edit|proof|final> [--file <project-relative-manuscript-path>] [--root <path>]\n lint --project <project-id> [--manuscript|--spine] [--root <path>]\n export --project <project-id> --format <md|docx|pdf|epub> [--output <path>] [--root <path>]\n comments read <manuscript> [--format <text|json>]\n comments add <manuscript> [--message <text> | --input <path|->] [--author <name>] [--start-line <n> --start-col <n> --end-line <n> --end-col <n>] [--cursor-line <n>] [--format <text|json>]\n comments reply <manuscript> --comment-id <CMT-####> [--message <text> | --input <path|->] [--author <name>] [--format <text|json>]\n comments set-status <manuscript> --comment-id <CMT-####> --status <open|resolved> [--thread] [--format <text|json>]\n comments delete <manuscript> --comment-id <CMT-####> [--format <text|json>]\n comments clear-resolved <manuscript> [--format <text|json>]\n comments sync-anchors <manuscript> --input <path|-> [--format <text|json>]\n`);
771
772
  }
772
773
  function listProjects() {
773
774
  const ids = getProjectIds();
@@ -1375,252 +1376,29 @@ function parseMetadata(raw, chapterPath, required) {
1375
1376
  };
1376
1377
  }
1377
1378
  function parseStegoCommentsAppendix(body, relativePath, bodyStartLine) {
1378
- const lineEnding = body.includes("\r\n") ? "\r\n" : "\n";
1379
- const lines = body.split(/\r?\n/);
1380
- const startMarker = "<!-- stego-comments:start -->";
1381
- const endMarker = "<!-- stego-comments:end -->";
1382
- const issues = [];
1383
- const startIndexes = findTrimmedLineIndexes(lines, startMarker);
1384
- const endIndexes = findTrimmedLineIndexes(lines, endMarker);
1385
- if (startIndexes.length === 0 && endIndexes.length === 0) {
1386
- return { bodyWithoutComments: body, comments: [], issues };
1387
- }
1388
- if (startIndexes.length !== 1 || endIndexes.length !== 1) {
1389
- if (startIndexes.length !== 1) {
1390
- issues.push(makeIssue("error", "comments", `Expected exactly one '${startMarker}' marker.`, relativePath));
1391
- }
1392
- if (endIndexes.length !== 1) {
1393
- issues.push(makeIssue("error", "comments", `Expected exactly one '${endMarker}' marker.`, relativePath));
1394
- }
1395
- return { bodyWithoutComments: body, comments: [], issues };
1396
- }
1397
- const start = startIndexes[0];
1398
- const end = endIndexes[0];
1399
- if (end <= start) {
1400
- issues.push(makeIssue("error", "comments", `'${endMarker}' must appear after '${startMarker}'.`, relativePath, bodyStartLine + end));
1401
- return { bodyWithoutComments: body, comments: [], issues };
1402
- }
1403
- const blockLines = lines.slice(start + 1, end);
1404
- const comments = parseStegoCommentThreads(blockLines, relativePath, bodyStartLine + start + 1, issues);
1405
- let removeStart = start;
1406
- if (removeStart > 0 && lines[removeStart - 1].trim().length === 0) {
1407
- removeStart -= 1;
1408
- }
1409
- const kept = [...lines.slice(0, removeStart), ...lines.slice(end + 1)];
1410
- while (kept.length > 0 && kept[kept.length - 1].trim().length === 0) {
1411
- kept.pop();
1412
- }
1379
+ const parsed = parseCommentAppendix(body);
1380
+ const issues = parsed.errors.map((error) => parseCommentIssueFromParserError(error, relativePath, bodyStartLine));
1381
+ const comments = parsed.comments.map((comment) => ({
1382
+ id: comment.id,
1383
+ resolved: comment.status === "resolved",
1384
+ thread: comment.thread
1385
+ }));
1413
1386
  return {
1414
- bodyWithoutComments: kept.join(lineEnding),
1387
+ bodyWithoutComments: parsed.contentWithoutComments,
1415
1388
  comments,
1416
1389
  issues
1417
1390
  };
1418
1391
  }
1419
- function parseStegoCommentThreads(lines, relativePath, baseLine, issues) {
1420
- const comments = [];
1421
- let index = 0;
1422
- while (index < lines.length) {
1423
- const trimmed = lines[index].trim();
1424
- if (!trimmed) {
1425
- index += 1;
1426
- continue;
1427
- }
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
- index += 1;
1432
- continue;
1433
- }
1434
- index += 1;
1435
- const rowLines = [];
1436
- const rowLineNumbers = [];
1437
- while (index < lines.length) {
1438
- const nextTrimmed = lines[index].trim();
1439
- if (parseCommentThreadDelimiter(nextTrimmed)) {
1440
- break;
1441
- }
1442
- rowLines.push(lines[index]);
1443
- rowLineNumbers.push(baseLine + index);
1444
- index += 1;
1445
- }
1446
- let resolved;
1447
- let sawMeta64 = false;
1448
- const thread = [];
1449
- let rowIndex = 0;
1450
- while (rowIndex < rowLines.length) {
1451
- const rawRow = rowLines[rowIndex];
1452
- const lineNumber = rowLineNumbers[rowIndex];
1453
- const trimmedRow = rawRow.trim();
1454
- if (!trimmedRow) {
1455
- rowIndex += 1;
1456
- continue;
1457
- }
1458
- if (thread.length > 0) {
1459
- issues.push(makeIssue("error", "comments", `Multiple message blocks found for ${id}. Create a new CMT id for each reply.`, relativePath, lineNumber));
1460
- break;
1461
- }
1462
- if (!sawMeta64) {
1463
- const metaMatch = trimmedRow.match(/^<!--\s*meta64:\s*(\S+)\s*-->\s*$/);
1464
- if (!metaMatch) {
1465
- issues.push(makeIssue("error", "comments", `Invalid comment metadata row '${trimmedRow}'. Expected '<!-- meta64: <base64url-json> -->'.`, relativePath, lineNumber));
1466
- rowIndex += 1;
1467
- continue;
1468
- }
1469
- sawMeta64 = true;
1470
- const decoded = decodeCommentMeta64(metaMatch[1], id, relativePath, lineNumber, issues);
1471
- if (decoded) {
1472
- resolved = decoded.resolved;
1473
- }
1474
- rowIndex += 1;
1475
- continue;
1476
- }
1477
- const headerQuote = extractQuotedLine(rawRow);
1478
- if (headerQuote === undefined) {
1479
- issues.push(makeIssue("error", "comments", `Invalid thread header '${trimmedRow}'. Expected blockquote header like '> _timestamp | author_'.`, relativePath, lineNumber));
1480
- rowIndex += 1;
1481
- continue;
1482
- }
1483
- const header = parseThreadHeader(headerQuote);
1484
- if (!header) {
1485
- issues.push(makeIssue("error", "comments", `Invalid thread header '${headerQuote.trim()}'. Expected '> _timestamp | author_'.`, relativePath, lineNumber));
1486
- rowIndex += 1;
1487
- continue;
1488
- }
1489
- rowIndex += 1;
1490
- while (rowIndex < rowLines.length) {
1491
- const separatorRaw = rowLines[rowIndex];
1492
- const separatorTrimmed = separatorRaw.trim();
1493
- if (!separatorTrimmed) {
1494
- rowIndex += 1;
1495
- continue;
1496
- }
1497
- const separatorQuote = extractQuotedLine(separatorRaw);
1498
- if (separatorQuote !== undefined && separatorQuote.trim().length === 0) {
1499
- rowIndex += 1;
1500
- }
1501
- break;
1502
- }
1503
- const messageLines = [];
1504
- while (rowIndex < rowLines.length) {
1505
- const messageRaw = rowLines[rowIndex];
1506
- const messageLineNumber = rowLineNumbers[rowIndex];
1507
- const messageTrimmed = messageRaw.trim();
1508
- if (!messageTrimmed) {
1509
- rowIndex += 1;
1510
- if (messageLines.length > 0) {
1511
- break;
1512
- }
1513
- continue;
1514
- }
1515
- const messageQuote = extractQuotedLine(messageRaw);
1516
- if (messageQuote === undefined) {
1517
- issues.push(makeIssue("error", "comments", `Invalid thread line '${messageTrimmed}'. Expected blockquote content starting with '>'.`, relativePath, messageLineNumber));
1518
- rowIndex += 1;
1519
- if (messageLines.length > 0) {
1520
- break;
1521
- }
1522
- continue;
1523
- }
1524
- if (parseThreadHeader(messageQuote)) {
1525
- break;
1526
- }
1527
- messageLines.push(messageQuote);
1528
- rowIndex += 1;
1529
- }
1530
- while (messageLines.length > 0 && messageLines[messageLines.length - 1].trim().length === 0) {
1531
- messageLines.pop();
1532
- }
1533
- if (messageLines.length === 0) {
1534
- issues.push(makeIssue("error", "comments", `Thread entry for comment ${id} is missing message text.`, relativePath, lineNumber));
1535
- continue;
1536
- }
1537
- const message = messageLines.join("\n").trim();
1538
- thread.push(`${header.timestamp} | ${header.author} | ${message}`);
1539
- }
1540
- if (!sawMeta64) {
1541
- issues.push(makeIssue("error", "comments", `Comment ${id} is missing metadata row ('<!-- meta64: <base64url-json> -->').`, relativePath));
1542
- resolved = false;
1543
- }
1544
- if (thread.length === 0) {
1545
- issues.push(makeIssue("error", "comments", `Comment ${id} is missing valid blockquote thread entries.`, relativePath));
1546
- }
1547
- comments.push({ id, resolved: Boolean(resolved), thread });
1548
- }
1549
- return comments;
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
- }
1562
- function decodeCommentMeta64(encoded, commentId, relativePath, lineNumber, issues) {
1563
- let rawJson = "";
1564
- try {
1565
- rawJson = Buffer.from(encoded, "base64url").toString("utf8");
1566
- }
1567
- catch {
1568
- issues.push(makeIssue("error", "comments", `Invalid meta64 payload for comment ${commentId}; expected base64url-encoded JSON.`, relativePath, lineNumber));
1569
- return undefined;
1570
- }
1571
- let parsed;
1572
- try {
1573
- parsed = JSON.parse(rawJson);
1574
- }
1575
- catch {
1576
- issues.push(makeIssue("error", "comments", `Invalid meta64 JSON for comment ${commentId}.`, relativePath, lineNumber));
1577
- return undefined;
1578
- }
1579
- if (!isPlainObject(parsed)) {
1580
- issues.push(makeIssue("error", "comments", `Invalid meta64 object for comment ${commentId}.`, relativePath, lineNumber));
1581
- return undefined;
1582
- }
1583
- const allowedKeys = new Set(["status", "anchor", "paragraph_index", "signature", "excerpt"]);
1584
- for (const key of Object.keys(parsed)) {
1585
- if (!allowedKeys.has(key)) {
1586
- issues.push(makeIssue("error", "comments", `meta64 for comment ${commentId} contains unsupported key '${key}'.`, relativePath, lineNumber));
1587
- return undefined;
1588
- }
1589
- }
1590
- const status = typeof parsed.status === "string" ? parsed.status.trim().toLowerCase() : "";
1591
- if (status !== "open" && status !== "resolved") {
1592
- issues.push(makeIssue("error", "comments", `meta64 for comment ${commentId} must include status 'open' or 'resolved'.`, relativePath, lineNumber));
1593
- return undefined;
1594
- }
1595
- return { resolved: status === "resolved" };
1596
- }
1597
- function extractQuotedLine(raw) {
1598
- const quoteMatch = raw.match(/^\s*>\s?(.*)$/);
1599
- if (!quoteMatch) {
1600
- return undefined;
1601
- }
1602
- return quoteMatch[1];
1603
- }
1604
- function parseThreadHeader(value) {
1605
- const match = value.trim().match(/^_(.+?)\s*\|\s*(.+?)_\s*$/);
1606
- if (!match) {
1607
- return undefined;
1608
- }
1609
- const timestamp = match[1].trim();
1610
- const author = match[2].trim();
1611
- if (!timestamp || !author) {
1612
- return undefined;
1613
- }
1614
- return { timestamp, author };
1615
- }
1616
- function findTrimmedLineIndexes(lines, marker) {
1617
- const indexes = [];
1618
- for (let index = 0; index < lines.length; index += 1) {
1619
- if (lines[index].trim() === marker) {
1620
- indexes.push(index);
1621
- }
1392
+ function parseCommentIssueFromParserError(error, relativePath, bodyStartLine) {
1393
+ const lineMatch = error.match(/^Line\\s+(\\d+):\\s+([\\s\\S]+)$/);
1394
+ if (!lineMatch) {
1395
+ return makeIssue("error", "comments", error, relativePath);
1622
1396
  }
1623
- return indexes;
1397
+ const relativeLine = Number.parseInt(lineMatch[1], 10);
1398
+ const absoluteLine = Number.isFinite(relativeLine)
1399
+ ? bodyStartLine + relativeLine - 1
1400
+ : undefined;
1401
+ return makeIssue("error", "comments", lineMatch[2], relativePath, absoluteLine);
1624
1402
  }
1625
1403
  function coerceMetadataValue(value) {
1626
1404
  if (!value) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stego-cli",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
4
4
  "type": "module",
5
5
  "description": "Installable CLI for the Stego writing monorepo workflow.",
6
6
  "license": "Apache-2.0",
@@ -1,182 +0,0 @@
1
- import fs from "node:fs";
2
- import path from "node:path";
3
- import { CommentsCommandError } from "./errors.js";
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;
6
- const START_SENTINEL = "<!-- stego-comments:start -->";
7
- const END_SENTINEL = "<!-- stego-comments:end -->";
8
- /** Adds one stego comment entry to a manuscript and writes the updated file. */
9
- export function addCommentToManuscript(request) {
10
- const absolutePath = path.resolve(request.cwd, request.manuscriptPath);
11
- if (!fs.existsSync(absolutePath) || !fs.statSync(absolutePath).isFile()) {
12
- throw new CommentsCommandError("INVALID_USAGE", 2, `Manuscript file not found: ${request.manuscriptPath}`);
13
- }
14
- const raw = fs.readFileSync(absolutePath, "utf8");
15
- const lineEnding = raw.includes("\r\n") ? "\r\n" : "\n";
16
- const lines = raw.split(/\r?\n/);
17
- const sentinelState = resolveSentinelState(lines);
18
- const hasYamlFrontmatter = raw.startsWith(`---${lineEnding}`) || raw.startsWith("---\n");
19
- if (!sentinelState.hasSentinels && !hasYamlFrontmatter) {
20
- throw new CommentsCommandError("NOT_STEGO_MANUSCRIPT", 3, "File is not recognized as a Stego manuscript (missing frontmatter and comments sentinels).");
21
- }
22
- const commentId = getNextCommentId(lines, sentinelState);
23
- const createdAt = new Date().toISOString();
24
- const meta = buildMetaPayload(request.range, request.sourceMeta);
25
- const meta64 = Buffer.from(JSON.stringify(meta), "utf8").toString("base64url");
26
- const entryLines = buildCommentEntryLines(commentId, createdAt, request.author, request.message, meta64);
27
- const nextLines = applyCommentEntry(lines, sentinelState, entryLines);
28
- const nextContent = `${nextLines.join(lineEnding)}${lineEnding}`;
29
- try {
30
- fs.writeFileSync(absolutePath, nextContent, "utf8");
31
- }
32
- catch (error) {
33
- const message = error instanceof Error ? error.message : String(error);
34
- throw new CommentsCommandError("WRITE_FAILURE", 6, `Failed to update manuscript: ${message}`);
35
- }
36
- const result = {
37
- ok: true,
38
- manuscript: absolutePath,
39
- commentId,
40
- status: "open",
41
- anchor: request.range
42
- ? {
43
- type: "selection",
44
- excerptStartLine: request.range.startLine,
45
- excerptStartCol: request.range.startCol,
46
- excerptEndLine: request.range.endLine,
47
- excerptEndCol: request.range.endCol
48
- }
49
- : { type: "file" },
50
- createdAt
51
- };
52
- return result;
53
- }
54
- function buildMetaPayload(range, sourceMeta) {
55
- const payload = {
56
- status: "open"
57
- };
58
- if (range) {
59
- payload.anchor = "selection";
60
- payload.excerpt = {
61
- start_line: range.startLine,
62
- start_col: range.startCol,
63
- end_line: range.endLine,
64
- end_col: range.endCol
65
- };
66
- }
67
- if (sourceMeta && Object.keys(sourceMeta).length > 0) {
68
- payload.signature = sourceMeta;
69
- }
70
- return payload;
71
- }
72
- function buildCommentEntryLines(commentId, timestamp, author, message, meta64) {
73
- const safeAuthor = author.trim() || "Saurus";
74
- const normalizedMessageLines = message
75
- .replace(/\r\n/g, "\n")
76
- .split("\n")
77
- .map((line) => line.trimEnd());
78
- return [
79
- `<!-- comment: ${commentId} -->`,
80
- `<!-- meta64: ${meta64} -->`,
81
- `> _${timestamp} | ${safeAuthor}_`,
82
- ">",
83
- ...normalizedMessageLines.map((line) => `> ${line}`)
84
- ];
85
- }
86
- function applyCommentEntry(lines, sentinelState, entryLines) {
87
- if (!sentinelState.hasSentinels) {
88
- const baseLines = trimTrailingBlankLines(lines);
89
- return [
90
- ...baseLines,
91
- "",
92
- START_SENTINEL,
93
- "",
94
- ...entryLines,
95
- "",
96
- END_SENTINEL
97
- ];
98
- }
99
- const startIndex = sentinelState.startIndex;
100
- const endIndex = sentinelState.endIndex;
101
- const block = lines.slice(startIndex + 1, endIndex);
102
- const trimmedBlock = trimOuterBlankLines(block);
103
- const nextBlock = trimmedBlock.length > 0
104
- ? [...trimmedBlock, "", ...entryLines]
105
- : [...entryLines];
106
- return [
107
- ...lines.slice(0, startIndex + 1),
108
- ...nextBlock,
109
- ...lines.slice(endIndex)
110
- ];
111
- }
112
- function resolveSentinelState(lines) {
113
- const startIndexes = findTrimmedLineIndexes(lines, START_SENTINEL);
114
- const endIndexes = findTrimmedLineIndexes(lines, END_SENTINEL);
115
- const hasAnySentinel = startIndexes.length > 0 || endIndexes.length > 0;
116
- if (!hasAnySentinel) {
117
- return {
118
- hasSentinels: false,
119
- startIndex: -1,
120
- endIndex: -1
121
- };
122
- }
123
- if (startIndexes.length !== 1 || endIndexes.length !== 1) {
124
- throw new CommentsCommandError("COMMENT_APPENDIX_INVALID", 5, `Expected exactly one '${START_SENTINEL}' and one '${END_SENTINEL}' marker.`);
125
- }
126
- const startIndex = startIndexes[0];
127
- const endIndex = endIndexes[0];
128
- if (endIndex <= startIndex) {
129
- throw new CommentsCommandError("COMMENT_APPENDIX_INVALID", 5, `'${END_SENTINEL}' must appear after '${START_SENTINEL}'.`);
130
- }
131
- return {
132
- hasSentinels: true,
133
- startIndex,
134
- endIndex
135
- };
136
- }
137
- function findTrimmedLineIndexes(lines, target) {
138
- const indexes = [];
139
- for (let index = 0; index < lines.length; index += 1) {
140
- if (lines[index].trim() === target) {
141
- indexes.push(index);
142
- }
143
- }
144
- return indexes;
145
- }
146
- function getNextCommentId(lines, sentinelState) {
147
- const candidateLines = sentinelState.hasSentinels
148
- ? lines.slice(sentinelState.startIndex + 1, sentinelState.endIndex)
149
- : lines;
150
- let maxId = 0;
151
- for (const line of candidateLines) {
152
- const trimmed = line.trim();
153
- const match = trimmed.match(COMMENT_DELIMITER_REGEX) ?? trimmed.match(LEGACY_COMMENT_HEADING_REGEX);
154
- if (!match || !match[2]) {
155
- continue;
156
- }
157
- const numeric = Number.parseInt(match[2], 10);
158
- if (Number.isFinite(numeric) && numeric > maxId) {
159
- maxId = numeric;
160
- }
161
- }
162
- const next = maxId + 1;
163
- return `CMT-${String(next).padStart(4, "0")}`;
164
- }
165
- function trimTrailingBlankLines(lines) {
166
- const copy = [...lines];
167
- while (copy.length > 0 && copy[copy.length - 1].trim().length === 0) {
168
- copy.pop();
169
- }
170
- return copy;
171
- }
172
- function trimOuterBlankLines(lines) {
173
- let start = 0;
174
- let end = lines.length;
175
- while (start < end && lines[start].trim().length === 0) {
176
- start += 1;
177
- }
178
- while (end > start && lines[end - 1].trim().length === 0) {
179
- end -= 1;
180
- }
181
- return lines.slice(start, end);
182
- }