@imenam/mcp-documentor 1.0.94 → 1.0.96

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.
Files changed (74) hide show
  1. package/README.md +25 -3
  2. package/dist/shared/read-only.js +1 -0
  3. package/dist/shared/schemas/doc.schema.d.ts +20 -0
  4. package/dist/shared/schemas/doc.schema.js +20 -0
  5. package/dist/src/core/comment-store.d.ts +87 -0
  6. package/dist/src/core/comment-store.js +189 -0
  7. package/dist/src/core/markdown-text.d.ts +7 -0
  8. package/dist/src/core/markdown-text.js +16 -0
  9. package/dist/src/core/publication-store.d.ts +4 -0
  10. package/dist/src/core/publication-store.js +6 -0
  11. package/dist/src/gui-worker.js +87 -5
  12. package/dist/src/index.js +180 -1
  13. package/dist/src/tools/comments.d.ts +29 -0
  14. package/dist/src/tools/comments.js +104 -0
  15. package/gui/dist/assets/{DocContent-B2hgJIHP.js → DocContent-D2Pgvsaw.js} +87 -87
  16. package/gui/dist/assets/{arc-3c1bsell.js → arc-ytJX8b9b.js} +1 -1
  17. package/gui/dist/assets/{architectureDiagram-3BPJPVTR-gOvE5Nh-.js → architectureDiagram-3BPJPVTR-vChxNmWw.js} +1 -1
  18. package/gui/dist/assets/{blockDiagram-GPEHLZMM-d2PyOGgB.js → blockDiagram-GPEHLZMM-D19nVYke.js} +1 -1
  19. package/gui/dist/assets/{c4Diagram-AAUBKEIU-Fq_MnzGd.js → c4Diagram-AAUBKEIU-7kCjxzBQ.js} +1 -1
  20. package/gui/dist/assets/channel-DegAVRd2.js +1 -0
  21. package/gui/dist/assets/{chunk-2J33WTMH-C8rPGvzC.js → chunk-2J33WTMH-CrEHZokt.js} +1 -1
  22. package/gui/dist/assets/{chunk-4BX2VUAB-CMO6CGqC.js → chunk-4BX2VUAB-AGQbPC36.js} +1 -1
  23. package/gui/dist/assets/{chunk-55IACEB6-ewoEucdu.js → chunk-55IACEB6-1dY_RrZJ.js} +1 -1
  24. package/gui/dist/assets/{chunk-727SXJPM-DVlayzbx.js → chunk-727SXJPM-DFrVc2F4.js} +1 -1
  25. package/gui/dist/assets/{chunk-AQP2D5EJ-CahwsWX2.js → chunk-AQP2D5EJ-Bf5AjFYW.js} +1 -1
  26. package/gui/dist/assets/{chunk-FMBD7UC4-CTfqQvPB.js → chunk-FMBD7UC4-ytPdLd3c.js} +1 -1
  27. package/gui/dist/assets/{chunk-ND2GUHAM-D16nBstn.js → chunk-ND2GUHAM-MQj2MK8N.js} +1 -1
  28. package/gui/dist/assets/{chunk-QZHKN3VN-BhYm6b_1.js → chunk-QZHKN3VN-BFpijhFi.js} +1 -1
  29. package/gui/dist/assets/classDiagram-4FO5ZUOK-C8gjytbe.js +1 -0
  30. package/gui/dist/assets/classDiagram-v2-Q7XG4LA2-C8gjytbe.js +1 -0
  31. package/gui/dist/assets/{cose-bilkent-S5V4N54A-BMWhCTQz.js → cose-bilkent-S5V4N54A-DydPBClW.js} +1 -1
  32. package/gui/dist/assets/{dagre-BM42HDAG-B8q9fjYt.js → dagre-BM42HDAG-Dt1NYir9.js} +1 -1
  33. package/gui/dist/assets/{diagram-2AECGRRQ-BEQd6l9g.js → diagram-2AECGRRQ-By8731FV.js} +1 -1
  34. package/gui/dist/assets/{diagram-5GNKFQAL-CYSk25NC.js → diagram-5GNKFQAL-6DboNPr2.js} +1 -1
  35. package/gui/dist/assets/{diagram-KO2AKTUF-pYuzMimB.js → diagram-KO2AKTUF-BlsHXUTP.js} +1 -1
  36. package/gui/dist/assets/{diagram-LMA3HP47-Coes1K5H.js → diagram-LMA3HP47-NlfihhmZ.js} +1 -1
  37. package/gui/dist/assets/{diagram-OG6HWLK6-B9QD3Aih.js → diagram-OG6HWLK6-BuPvvcLW.js} +1 -1
  38. package/gui/dist/assets/{erDiagram-TEJ5UH35-BaTQh6lK.js → erDiagram-TEJ5UH35-Bwh4WcoJ.js} +1 -1
  39. package/gui/dist/assets/{flowDiagram-I6XJVG4X-CD3j0V3z.js → flowDiagram-I6XJVG4X-Cpo3jFt_.js} +1 -1
  40. package/gui/dist/assets/{ganttDiagram-6RSMTGT7-CI8luwIp.js → ganttDiagram-6RSMTGT7-MTzCwVCF.js} +4 -4
  41. package/gui/dist/assets/{gitGraphDiagram-PVQCEYII-CZicCj5w.js → gitGraphDiagram-PVQCEYII-TGZH1N13.js} +1 -1
  42. package/gui/dist/assets/{infoDiagram-5YYISTIA-BBBZLBfU.js → infoDiagram-5YYISTIA-CjtHqsAF.js} +1 -1
  43. package/gui/dist/assets/{ishikawaDiagram-YF4QCWOH-JzhvbF8X.js → ishikawaDiagram-YF4QCWOH-BubX7AqP.js} +1 -1
  44. package/gui/dist/assets/{journeyDiagram-JHISSGLW-z2Fq1AWA.js → journeyDiagram-JHISSGLW-BT5Bu4px.js} +1 -1
  45. package/gui/dist/assets/{kanban-definition-UN3LZRKU-CsERU4RU.js → kanban-definition-UN3LZRKU-CyiNLbBC.js} +1 -1
  46. package/gui/dist/assets/{linear-bwdTeoS5.js → linear-rOmZOzd-.js} +1 -1
  47. package/gui/dist/assets/main-B4PH-HO6.css +1 -0
  48. package/gui/dist/assets/main-CKkycX8v.js +10 -0
  49. package/gui/dist/assets/{mindmap-definition-RKZ34NQL-Hhnd5vi2.js → mindmap-definition-RKZ34NQL-Cvy4YAiE.js} +1 -1
  50. package/gui/dist/assets/{pieDiagram-4H26LBE5-DCSptoQo.js → pieDiagram-4H26LBE5-BKrVYqW5.js} +1 -1
  51. package/gui/dist/assets/public-BOnoF84L.js +1 -0
  52. package/gui/dist/assets/public-BmtG1Lfn.css +1 -0
  53. package/gui/dist/assets/{quadrantDiagram-W4KKPZXB-CFG_TBfZ.js → quadrantDiagram-W4KKPZXB-DG_N325e.js} +1 -1
  54. package/gui/dist/assets/{requirementDiagram-4Y6WPE33-DUHthbad.js → requirementDiagram-4Y6WPE33-oWqsr2x0.js} +1 -1
  55. package/gui/dist/assets/{sankeyDiagram-5OEKKPKP-CcivnX5S.js → sankeyDiagram-5OEKKPKP-B8uXp_JX.js} +1 -1
  56. package/gui/dist/assets/{sequenceDiagram-3UESZ5HK-DD3f-L7G.js → sequenceDiagram-3UESZ5HK-bZUsMsnM.js} +1 -1
  57. package/gui/dist/assets/{stateDiagram-AJRCARHV-DP6QuANT.js → stateDiagram-AJRCARHV-3o_sYDh3.js} +1 -1
  58. package/gui/dist/assets/stateDiagram-v2-BHNVJYJU-CfMfyWVZ.js +1 -0
  59. package/gui/dist/assets/{timeline-definition-PNZ67QCA-DpSvFfP9.js → timeline-definition-PNZ67QCA-BWMaBPME.js} +1 -1
  60. package/gui/dist/assets/{vennDiagram-CIIHVFJN-DU7Clt-k.js → vennDiagram-CIIHVFJN-BuBBjBVe.js} +1 -1
  61. package/gui/dist/assets/{wardley-L42UT6IY-By2c-zj1.js → wardley-L42UT6IY-Da4i2-Py.js} +1 -1
  62. package/gui/dist/assets/{wardleyDiagram-YWT4CUSO-BJeFdhb5.js → wardleyDiagram-YWT4CUSO-Cv3OBAYm.js} +1 -1
  63. package/gui/dist/assets/{xychartDiagram-2RQKCTM6-ZeDSX3nQ.js → xychartDiagram-2RQKCTM6-DFmCIdof.js} +1 -1
  64. package/gui/dist/index.html +3 -3
  65. package/gui/dist/public.html +3 -3
  66. package/package.json +5 -1
  67. package/gui/dist/assets/channel-D0TrBTBU.js +0 -1
  68. package/gui/dist/assets/classDiagram-4FO5ZUOK-BZarwo9C.js +0 -1
  69. package/gui/dist/assets/classDiagram-v2-Q7XG4LA2-BZarwo9C.js +0 -1
  70. package/gui/dist/assets/main-BB0ARNyc.js +0 -10
  71. package/gui/dist/assets/main-DDy8gF40.css +0 -1
  72. package/gui/dist/assets/public-Bds7tw8V.css +0 -1
  73. package/gui/dist/assets/public-D8L9bJEM.js +0 -1
  74. package/gui/dist/assets/stateDiagram-v2-BHNVJYJU-DCHGEDGQ.js +0 -1
package/README.md CHANGED
@@ -11,7 +11,7 @@ Connect it to Cursor, Claude Desktop, or any MCP-compatible agent to let your AI
11
11
  - **Portable code references** — tracked-file paths are stored relative to a code root set per machine and per project (GUI or `set_code_root`), so synced docs resolve their source files on any machine regardless of where the repos live
12
12
  - **Track a branch, not a checkout** — point a project at a GitHub branch from its settings in the GUI and freshness is measured against *published* code: switching branches or editing without committing no longer makes docs look stale, and a server with no copy of the codebase (an MCP gateway) can still verify them
13
13
  - **Semantic search** — find relevant docs by meaning (requires an OpenAI API key)
14
- - **Multi-project support** — organize docs into separate projects; every MCP tool names the project it targets, and the GUI's project selector is a per-browser view preference
14
+ - **Multi-project support** — organize docs into separate projects; every MCP tool names the project it targets, and the GUI's project selector is a per-browser view preference, with a search field that filters the list by name
15
15
  - **GUI** — built-in web interface, either behind the proxy (`PROXY_URL`) or served directly on a local port (`APP_PORT`); with neither, the GUI worker is not started. Navigation runs from an icon rail on the left: the current project's badge (click to switch projects or create one), then Documentation, Search (`Ctrl/⌘ + K`, current or all projects), Outdated, Recent, Export bundles, Publications and Logs, with Sync and Settings at the bottom. Each destination fills the panel next to the rail; the panel's hide button, or a second click on the active icon, folds it away. Drag the panel's right edge to resize it (double-click resets the width); the browser remembers the width. On wide screens the whole interface is capped at 1600 px and centred. The details panel on the right of a document (`Ctrl/⌘ + \`) is open by default from 1280 px wide; showing or hiding it by hand is remembered across reloads until the window crosses that width. Badges show the outdated docs, pending commits and the day's failed GitHub calls. Each open tab polls `GET /api/changes` every 3 seconds and every request is answered immediately: no connection stays open, so many tabs can share the proxy's origin, and changes made by the agent show up within 3 seconds. After a GUI worker restart, or when it has fallen more than 200 changes behind, the tab reloads the server state (`GET /api/initial-state`) and the list of the project it shows
16
16
  - **Proxy-aware** — integrates with the [proxy-setup](https://github.com/Imena/proxy-setup) for GUI exposure
17
17
  - **Links & structure** — `check_links`, `get_doc_tree`, `move_doc`, and `git_diff_doc` for maintenance workflows
@@ -139,6 +139,9 @@ that work on a whole project (`list_docs`, `get_doc_tree`, `check_freshness` wit
139
139
  | `get_code_source` | Show each project's code source (local working copy or GitHub branch) and its code root on this machine |
140
140
  | `set_code_source` | Make a project read its local working copy or one branch of a GitHub repository (checked before saving) |
141
141
  | `set_code_root` | Set the code root of a project, or the machine default, on this machine |
142
+ | `list_comments` | List readers' comments on published documents (`project` required; one doc with `path`; `status`: `all`, `unread` or `detached`) |
143
+ | `mark_comments_read` | Mark comments of a published doc as read, or unread with `read: false`; every comment of the doc when `ids` is omitted (`project` required) |
144
+ | `delete_comments` | Delete comments of a published doc, replies included (`project` required) |
142
145
 
143
146
  ## Images in documents
144
147
 
@@ -175,9 +178,28 @@ A document can be shared, read-only, with anyone who holds its link — no accou
175
178
  - **Unpublish** — revokes the link after a confirmation: it answers 404 at once. Publishing again creates a new link.
176
179
  - **Publications page** — the globe of the rail lists every published document of the current project: version note, publication date, state (*Up to date*, *Changed · v12 → v14*, or *Document removed*), with Update, copy, open and Unpublish on each row. *Changed* keeps only the publications that lag behind their document.
177
180
 
178
- What the reader sees is the viewer's rendering (code highlighting, Mermaid, tables, images) without any action. The page shows the document alone — title, publication date, version and note — and nothing about where it comes from: no project name, no header bar. An *On this page* table of contents of the document's headings sits on the right and follows the scroll; it is hidden below 1024 px wide. Web links stay active and open in a new tab; links to other documents become plain text; in-page anchors work. Images uploaded to the project are copied into the snapshot when it is frozen, so the page never changes until the publication is updated.
181
+ What the reader sees is the viewer's rendering (code highlighting, Mermaid, tables, images) without any action. The page shows the document alone — title, publication date, version and note — and nothing about where it comes from: no project name, no header bar. A **Contents** button lists the document's headings. Web links stay active and open in a new tab; links to other documents become plain text; in-page anchors work. Images uploaded to the project are copied into the snapshot when it is frozen, so the page never changes until the publication is updated.
179
182
 
180
- **The link** is a *capability URL*: its last segment is a 32-byte random token, and holding it is the only authorization. The page is served under `public/<token>/` next to the GUI, or under `/_pub/<route of the GUI>/<token>/` behind the mcp-gateway-manager hub, which relays that prefix without a session. Every public response carries `X-Robots-Tag: noindex, nofollow, noarchive` and `Referrer-Policy: no-referrer` (the token never leaks to the sites the reader opens), and the page has a strict Content-Security-Policy. The page embeds the frozen document: it makes no API call.
183
+ ### Comments
184
+
185
+ Readers can comment on a published document, without an account:
186
+
187
+ - **Commenting** — a click on a word selects it, a drag selects a passage (over several lines if needed); a **Comment** button appears above it. The form takes an optional name, remembered by the browser, and the comment (Ctrl/⌘ + Enter sends it). Readers can reply to a comment.
188
+ - **In the margins** — each comment is a card next to its passage, in the margin on the passage's side (left half of the text: left margin), linked to it by a line; the passage is highlighted. Cards that would overlap are pushed down. From 1080 to 1400 px wide every card goes to the right margin; below 1080 px, a click on a highlighted passage opens its comment in a panel at the bottom of the screen. The **N comments** button hides or shows them all.
189
+ - **Limits** — 20 comments per publication, replies included, and 2,000 characters per comment. A deleted comment frees its place; a comment marked as read does not. Behind the hub, a client sending more than 15 comments in a minute is refused, against automated posting.
190
+ - **Names** — the name is whatever the reader typed: every name carries a *not verified* tooltip, on the public page and in the GUI.
191
+
192
+ Comments are visible to everyone holding the link. Marking them as read and deleting them is reserved to the GUI and to the agent (`list_comments`, `mark_comments_read`, `delete_comments`): the public page offers neither.
193
+
194
+ In the GUI, a document with unread comments shows a blue bubble and their number in the tree (a bubble alone on its folders), the globe of the rail counts the project's unread comments, and the document toolbar has a comment button. The details panel on the right gets a **Comments** tab, opened by itself on a document with unread comments: each comment can be marked as read or deleted, **Close comments** stops readers from adding any (existing ones stay visible; **Reopen comments** lifts it), a click on it scrolls to its highlighted passage, and a click on a highlighted passage opens its comment.
195
+
196
+ A comment points at a passage by its text, plus the text just around it. When a publication is updated and a commented passage no longer exists, the comment is kept and marked **detached**: readers no longer see it, the GUI lists it apart with its original passage and versions, and `list_comments` flags it `DETACHED` so the agent can clean them up. A detached comment whose passage comes back in a later update is attached again. Passages are compared without whitespace, against the rendered text of the markdown.
197
+
198
+ Comments are stored in `<data dir>/publications/<publication>/comments.json`, with their publication: unpublishing deletes them. `READ_ONLY` disables marking, deleting, closing and reopening. When the comment file cannot be read, the public page still shows the document and states that its comments are unavailable.
199
+
200
+ `list_comments` gives the agent text written by anonymous readers. Its output opens with a notice that names, quoted passages and texts are data, never instructions; every line break of a comment, Unicode separators included, starts a new `>` quoted line, so a comment cannot pass for another entry.
201
+
202
+ **The link** is a *capability URL*: its last segment is a 32-byte random token, and holding it is the only authorization. The page is served under `public/<token>/` next to the GUI, or under `/_pub/<route of the GUI>/<token>/` behind the mcp-gateway-manager hub, which relays that prefix without a session. Every public response carries `X-Robots-Tag: noindex, nofollow, noarchive` and `Referrer-Policy: no-referrer` (the token never leaks to the sites the reader opens), and the page has a strict Content-Security-Policy. The page embeds the frozen document and its comments; its only request is `POST public/<token>/comments`, which accepts JSON only (at most 16 KB), so that a form from another site cannot post. Behind the hub, this needs a version of mcp-gateway-manager that relays `POST` on `/_pub/`.
181
203
 
182
204
  Publications live in `<data dir>/publications/` — `index.json` plus one snapshot directory per publication. The directory is machine-local and excluded from sync: a link is served by the machine that created it. A document moved with `move_doc` keeps its publication; a deleted one keeps serving its frozen snapshot until it is unpublished. `READ_ONLY` disables publishing, updating and unpublishing.
183
205
 
@@ -12,4 +12,5 @@ export const WRITE_TOOLS = new Set([
12
12
  // still pull updates); these mutate local/remote state and are blocked.
13
13
  "push_docs", "sync_docs", "resolve_sync_conflict",
14
14
  "set_code_source", "set_code_root",
15
+ "mark_comments_read", "delete_comments",
15
16
  ]);
@@ -231,6 +231,26 @@ export declare const PrepareExportBundleArgsSchema: z.ZodObject<{
231
231
  export declare const DeleteExportBundleArgsSchema: z.ZodObject<{
232
232
  id: z.ZodString;
233
233
  }, z.core.$strip>;
234
+ export declare const ListCommentsArgsSchema: z.ZodObject<{
235
+ project: z.ZodString;
236
+ path: z.ZodOptional<z.ZodString>;
237
+ status: z.ZodDefault<z.ZodOptional<z.ZodEnum<{
238
+ all: "all";
239
+ unread: "unread";
240
+ detached: "detached";
241
+ }>>>;
242
+ }, z.core.$strip>;
243
+ export declare const MarkCommentsReadArgsSchema: z.ZodObject<{
244
+ project: z.ZodString;
245
+ path: z.ZodString;
246
+ ids: z.ZodOptional<z.ZodArray<z.ZodString>>;
247
+ read: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
248
+ }, z.core.$strip>;
249
+ export declare const DeleteCommentsArgsSchema: z.ZodObject<{
250
+ project: z.ZodString;
251
+ path: z.ZodString;
252
+ ids: z.ZodArray<z.ZodString>;
253
+ }, z.core.$strip>;
234
254
  export declare const IPCMessageSchema: z.ZodObject<{
235
255
  type: z.ZodString;
236
256
  correlationId: z.ZodOptional<z.ZodString>;
@@ -215,6 +215,26 @@ export const PrepareExportBundleArgsSchema = z.object({
215
215
  export const DeleteExportBundleArgsSchema = z.object({
216
216
  id: z.string().describe("Bundle identifier to delete (slug)"),
217
217
  });
218
+ export const ListCommentsArgsSchema = z.object({
219
+ project: requiredProject("the comment lookup"),
220
+ path: z.string().optional().describe("Published document whose comments are listed. Every published document of the project if omitted."),
221
+ status: z
222
+ .enum(["all", "unread", "detached"])
223
+ .optional()
224
+ .default("all")
225
+ .describe("'unread': not yet marked as read. 'detached': their passage disappeared when the publication was updated."),
226
+ });
227
+ export const MarkCommentsReadArgsSchema = z.object({
228
+ project: requiredProject("the update"),
229
+ path: z.string().describe("Published document the comments belong to"),
230
+ ids: z.array(z.string()).min(1).optional().describe("Comments to mark, as listed by list_comments. Every comment of the document if omitted."),
231
+ read: z.boolean().optional().default(true).describe("false marks them as unread again"),
232
+ });
233
+ export const DeleteCommentsArgsSchema = z.object({
234
+ project: requiredProject("the deletion"),
235
+ path: z.string().describe("Published document the comments belong to"),
236
+ ids: z.array(z.string()).min(1).describe("Comments to delete, as listed by list_comments. Deleting a comment deletes its replies."),
237
+ });
218
238
  export const IPCMessageSchema = z.object({
219
239
  type: z.string(),
220
240
  correlationId: z.string().optional(),
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Comments left by readers of a public link, in `<data dir>/publications/<publication id>/comments.json`.
3
+ * They live with their publication: unpublishing deletes them.
4
+ *
5
+ * A comment is anchored to a passage of the published text by a quote selector — the passage
6
+ * itself plus the text just before and after it. Replies carry no anchor: they belong to the
7
+ * thread of their root comment. When a publication is updated and its passage no longer exists,
8
+ * the comment is kept and marked detached.
9
+ */
10
+ /** Passage a comment points at, as text of the rendered document. */
11
+ export interface CommentAnchor {
12
+ exact: string;
13
+ /** Text just before the passage, to tell apart two identical passages. */
14
+ prefix: string;
15
+ /** Text just after the passage. */
16
+ suffix: string;
17
+ }
18
+ export interface CommentDetachment {
19
+ at: string;
20
+ /** Version the comment was anchored in. */
21
+ fromVersion: number | null;
22
+ /** Version published without its passage. */
23
+ toVersion: number | null;
24
+ }
25
+ export interface PublicationComment {
26
+ id: string;
27
+ /** Root comment of the thread, null for a root comment. */
28
+ parentId: string | null;
29
+ /** Null for a reply. */
30
+ anchor: CommentAnchor | null;
31
+ /** Name given by the reader, "" when none was given. */
32
+ author: string;
33
+ body: string;
34
+ createdAt: string;
35
+ /** Publication version the comment was written on. */
36
+ version: number | null;
37
+ /** Marked as read by the documentation owner. */
38
+ read: boolean;
39
+ /** Set when the passage disappeared from the published version. Replies follow their root. */
40
+ detached: CommentDetachment | null;
41
+ }
42
+ /** What the public page shows of a comment. */
43
+ export interface PublicComment {
44
+ id: string;
45
+ parentId: string | null;
46
+ anchor: CommentAnchor | null;
47
+ author: string;
48
+ body: string;
49
+ createdAt: string;
50
+ }
51
+ export interface NewCommentInput {
52
+ parentId?: unknown;
53
+ anchor?: unknown;
54
+ author?: unknown;
55
+ body?: unknown;
56
+ }
57
+ /** Comments a publication accepts, replies included. */
58
+ export declare const MAX_COMMENTS = 20;
59
+ export declare const MAX_BODY_LENGTH = 2000;
60
+ export declare const MAX_AUTHOR_LENGTH = 80;
61
+ export declare const MAX_QUOTE_LENGTH = 500;
62
+ export declare const MAX_CONTEXT_LENGTH = 64;
63
+ /** A request a reader can correct (400), or a limit the publication reached (409). */
64
+ export declare class CommentInputError extends Error {
65
+ readonly status: 400 | 409;
66
+ constructor(message: string, status?: 400 | 409);
67
+ }
68
+ export declare function toPublicComment(comment: PublicationComment): PublicComment;
69
+ export declare class CommentStore {
70
+ private readonly dataDir;
71
+ constructor(dataDir: string);
72
+ private filePath;
73
+ list(publicationId: string): PublicationComment[];
74
+ /** Comments the public page shows: detached ones have no passage to sit next to. */
75
+ listPublic(publicationId: string): PublicComment[];
76
+ add(publicationId: string, version: number | null, input: NewCommentInput): PublicationComment;
77
+ /** Marks comments as read or unread; returns how many changed. */
78
+ setRead(publicationId: string, ids: string[] | null, read: boolean): number;
79
+ /** Deletes comments, and the replies of the root comments deleted; returns how many were removed. */
80
+ delete(publicationId: string, ids: string[]): number;
81
+ /**
82
+ * Checks every root comment against the text of a newly published version: a comment whose
83
+ * passage is gone becomes detached, one whose passage is back is attached again.
84
+ */
85
+ reanchor(publicationId: string, publishedText: string, version: number | null): number;
86
+ private write;
87
+ }
@@ -0,0 +1,189 @@
1
+ import crypto from "crypto";
2
+ import fs from "fs";
3
+ import path from "path";
4
+ import { compactText } from "./markdown-text.js";
5
+ /** Comments a publication accepts, replies included. */
6
+ export const MAX_COMMENTS = 20;
7
+ export const MAX_BODY_LENGTH = 2000;
8
+ export const MAX_AUTHOR_LENGTH = 80;
9
+ export const MAX_QUOTE_LENGTH = 500;
10
+ export const MAX_CONTEXT_LENGTH = 64;
11
+ /** A request a reader can correct (400), or a limit the publication reached (409). */
12
+ export class CommentInputError extends Error {
13
+ status;
14
+ constructor(message, status = 400) {
15
+ super(message);
16
+ this.status = status;
17
+ }
18
+ }
19
+ function requireString(value, field) {
20
+ if (typeof value !== "string")
21
+ throw new CommentInputError(`${field} must be a string`);
22
+ return value;
23
+ }
24
+ function optionalString(value, field) {
25
+ if (value === undefined || value === null)
26
+ return "";
27
+ return requireString(value, field);
28
+ }
29
+ function collapseWhitespace(text) {
30
+ return text.replace(/\s+/g, " ").trim();
31
+ }
32
+ function parseAnchor(value) {
33
+ if (!value || typeof value !== "object")
34
+ throw new CommentInputError("anchor is required");
35
+ const raw = value;
36
+ const exact = collapseWhitespace(requireString(raw.exact, "anchor.exact"));
37
+ if (compactText(exact) === "")
38
+ throw new CommentInputError("anchor.exact is empty");
39
+ if (exact.length > MAX_QUOTE_LENGTH) {
40
+ throw new CommentInputError(`The selected passage is limited to ${MAX_QUOTE_LENGTH} characters`);
41
+ }
42
+ const prefix = collapseWhitespace(optionalString(raw.prefix, "anchor.prefix")).slice(-MAX_CONTEXT_LENGTH);
43
+ const suffix = collapseWhitespace(optionalString(raw.suffix, "anchor.suffix")).slice(0, MAX_CONTEXT_LENGTH);
44
+ return { exact, prefix, suffix };
45
+ }
46
+ export function toPublicComment(comment) {
47
+ return {
48
+ id: comment.id,
49
+ parentId: comment.parentId,
50
+ anchor: comment.anchor,
51
+ author: comment.author,
52
+ body: comment.body,
53
+ createdAt: comment.createdAt,
54
+ };
55
+ }
56
+ /** Root comment of a thread, or the comment itself when it is a root. */
57
+ function rootOf(comments, comment) {
58
+ return comment.parentId ? comments.find((c) => c.id === comment.parentId) ?? comment : comment;
59
+ }
60
+ export class CommentStore {
61
+ dataDir;
62
+ constructor(dataDir) {
63
+ this.dataDir = dataDir;
64
+ }
65
+ filePath(publicationId) {
66
+ return path.join(this.dataDir, "publications", publicationId, "comments.json");
67
+ }
68
+ list(publicationId) {
69
+ const file = this.filePath(publicationId);
70
+ if (!fs.existsSync(file))
71
+ return [];
72
+ try {
73
+ const parsed = JSON.parse(fs.readFileSync(file, "utf-8"));
74
+ if (!Array.isArray(parsed))
75
+ throw new Error("not an array");
76
+ return parsed;
77
+ }
78
+ catch (err) {
79
+ throw new Error(`${file} is not a valid comment file (${err.message}) — fix or delete it.`);
80
+ }
81
+ }
82
+ /** Comments the public page shows: detached ones have no passage to sit next to. */
83
+ listPublic(publicationId) {
84
+ const comments = this.list(publicationId);
85
+ return comments.filter((c) => !rootOf(comments, c).detached).map(toPublicComment);
86
+ }
87
+ add(publicationId, version, input) {
88
+ const comments = this.list(publicationId);
89
+ if (comments.length >= MAX_COMMENTS) {
90
+ throw new CommentInputError(`This document accepts no more comments (limit: ${MAX_COMMENTS})`, 409);
91
+ }
92
+ const body = requireString(input.body, "body").trim();
93
+ if (body === "")
94
+ throw new CommentInputError("The comment is empty");
95
+ if (body.length > MAX_BODY_LENGTH) {
96
+ throw new CommentInputError(`A comment is limited to ${MAX_BODY_LENGTH} characters`);
97
+ }
98
+ const author = collapseWhitespace(optionalString(input.author, "author"));
99
+ if (author.length > MAX_AUTHOR_LENGTH) {
100
+ throw new CommentInputError(`A name is limited to ${MAX_AUTHOR_LENGTH} characters`);
101
+ }
102
+ let parentId = null;
103
+ let anchor = null;
104
+ if (input.parentId !== undefined && input.parentId !== null) {
105
+ const parent = comments.find((c) => c.id === requireString(input.parentId, "parentId"));
106
+ const root = parent ? rootOf(comments, parent) : undefined;
107
+ if (!root || root.detached)
108
+ throw new CommentInputError("The comment answered no longer exists");
109
+ parentId = root.id;
110
+ }
111
+ else {
112
+ anchor = parseAnchor(input.anchor);
113
+ }
114
+ const comment = {
115
+ id: crypto.randomBytes(8).toString("hex"),
116
+ parentId,
117
+ anchor,
118
+ author,
119
+ body,
120
+ createdAt: new Date().toISOString(),
121
+ version,
122
+ read: false,
123
+ detached: null,
124
+ };
125
+ this.write(publicationId, [...comments, comment]);
126
+ return comment;
127
+ }
128
+ /** Marks comments as read or unread; returns how many changed. */
129
+ setRead(publicationId, ids, read) {
130
+ const comments = this.list(publicationId);
131
+ const wanted = ids ? new Set(ids) : null;
132
+ let changed = 0;
133
+ const next = comments.map((c) => {
134
+ if ((wanted && !wanted.has(c.id)) || c.read === read)
135
+ return c;
136
+ changed++;
137
+ return { ...c, read };
138
+ });
139
+ if (changed > 0)
140
+ this.write(publicationId, next);
141
+ return changed;
142
+ }
143
+ /** Deletes comments, and the replies of the root comments deleted; returns how many were removed. */
144
+ delete(publicationId, ids) {
145
+ const comments = this.list(publicationId);
146
+ const removed = new Set(ids.filter((id) => comments.some((c) => c.id === id)));
147
+ for (const c of comments)
148
+ if (c.parentId && removed.has(c.parentId))
149
+ removed.add(c.id);
150
+ if (removed.size === 0)
151
+ return 0;
152
+ this.write(publicationId, comments.filter((c) => !removed.has(c.id)));
153
+ return removed.size;
154
+ }
155
+ /**
156
+ * Checks every root comment against the text of a newly published version: a comment whose
157
+ * passage is gone becomes detached, one whose passage is back is attached again.
158
+ */
159
+ reanchor(publicationId, publishedText, version) {
160
+ const comments = this.list(publicationId);
161
+ const text = compactText(publishedText);
162
+ const now = new Date().toISOString();
163
+ let changed = 0;
164
+ const next = comments.map((c) => {
165
+ if (!c.anchor)
166
+ return c;
167
+ const found = text.includes(compactText(c.anchor.exact));
168
+ if (found && c.detached) {
169
+ changed++;
170
+ return { ...c, detached: null };
171
+ }
172
+ if (!found && !c.detached) {
173
+ changed++;
174
+ return { ...c, detached: { at: now, fromVersion: c.version, toVersion: version } };
175
+ }
176
+ return c;
177
+ });
178
+ if (changed > 0)
179
+ this.write(publicationId, next);
180
+ return changed;
181
+ }
182
+ write(publicationId, comments) {
183
+ const file = this.filePath(publicationId);
184
+ fs.mkdirSync(path.dirname(file), { recursive: true });
185
+ const temp = `${file}.${process.pid}.tmp`;
186
+ fs.writeFileSync(temp, JSON.stringify(comments, null, 2), "utf-8");
187
+ fs.renameSync(temp, file);
188
+ }
189
+ }
@@ -0,0 +1,7 @@
1
+ /** Text a reader sees once the markdown is rendered: markup removed, code kept. */
2
+ export declare function markdownText(content: string): string;
3
+ /**
4
+ * Text with every whitespace removed. Rendered text and markdown text differ in the whitespace
5
+ * between blocks; comparing without it makes a passage found in one found in the other.
6
+ */
7
+ export declare function compactText(text: string): string;
@@ -0,0 +1,16 @@
1
+ import { fromMarkdown } from "mdast-util-from-markdown";
2
+ import { gfmFromMarkdown } from "mdast-util-gfm";
3
+ import { gfm } from "micromark-extension-gfm";
4
+ import { toString } from "mdast-util-to-string";
5
+ /** Text a reader sees once the markdown is rendered: markup removed, code kept. */
6
+ export function markdownText(content) {
7
+ const tree = fromMarkdown(content, { extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()] });
8
+ return toString(tree, { includeImageAlt: false });
9
+ }
10
+ /**
11
+ * Text with every whitespace removed. Rendered text and markdown text differ in the whitespace
12
+ * between blocks; comparing without it makes a passage found in one found in the other.
13
+ */
14
+ export function compactText(text) {
15
+ return text.replace(/\s+/g, "");
16
+ }
@@ -28,6 +28,8 @@ export interface Publication {
28
28
  createdAt: string;
29
29
  /** When the current snapshot was frozen. */
30
30
  publishedAt: string;
31
+ /** True when readers can no longer add comments; absent or false when they can. */
32
+ commentsClosed?: boolean;
31
33
  }
32
34
  /** Content and images of a document at the moment it is frozen. */
33
35
  export interface FreezeInput {
@@ -66,6 +68,8 @@ export declare class PublicationStore {
66
68
  update(id: string, input: FreezeInput): Publication;
67
69
  /** Revokes the link: the publication and its snapshots are deleted. */
68
70
  unpublish(id: string): Publication;
71
+ /** Opens or closes the publication to new comments. Existing comments stay visible. */
72
+ setCommentsClosed(id: string, closed: boolean): Publication;
69
73
  /** Follows a document to its new place. Returns true when a publication was moved. */
70
74
  moveDoc(project: string, docId: string, newProject: string, newDocId: string): boolean;
71
75
  private require;
@@ -137,6 +137,12 @@ export class PublicationStore {
137
137
  fs.rmSync(path.join(this.rootDir, id), { recursive: true, force: true });
138
138
  return publication;
139
139
  }
140
+ /** Opens or closes the publication to new comments. Existing comments stay visible. */
141
+ setCommentsClosed(id, closed) {
142
+ const publication = { ...this.require(id), commentsClosed: closed };
143
+ this.writeIndex(this.list().map((p) => (p.id === id ? publication : p)));
144
+ return publication;
145
+ }
140
146
  /** Follows a document to its new place. Returns true when a publication was moved. */
141
147
  moveDoc(project, docId, newProject, newDocId) {
142
148
  const publications = this.list();
@@ -16,6 +16,7 @@ import { createLogger, setupLogging } from "@imenam/mcp-gui-interface";
16
16
  import { resolveLogDir } from "./core/log-dir.js";
17
17
  import { isLogSource, logFileInfo, readGitHubLog, readServerLog } from "./core/log-reader.js";
18
18
  import { isSafeSegment, PublicationStore } from "./core/publication-store.js";
19
+ import { CommentStore, MAX_AUTHOR_LENGTH, MAX_BODY_LENGTH, MAX_COMMENTS, MAX_QUOTE_LENGTH, } from "./core/comment-store.js";
19
20
  import matter from "gray-matter";
20
21
  import JSZip from "jszip";
21
22
  import puppeteer from "puppeteer-core";
@@ -180,6 +181,7 @@ const BROADCAST_TYPES = [
180
181
  "DOCS_CHANGED",
181
182
  "CODE_SOURCES_CHANGED",
182
183
  "PUBLICATIONS_CHANGED",
184
+ "COMMENTS_CHANGED",
183
185
  ];
184
186
  /** Number of pushed messages kept for clients catching up. */
185
187
  const CHANGE_JOURNAL_SIZE = 200;
@@ -237,6 +239,7 @@ const READ_ONLY_WRITE_ROUTES = [
237
239
  { method: "POST", prefix: "/api/code-roots/apply" },
238
240
  { method: "POST", prefix: "/api/publications" },
239
241
  { method: "DELETE", prefix: "/api/publications" },
242
+ { method: "POST", prefix: "/api/comments/" },
240
243
  ];
241
244
  app.use("*", async (c, next) => {
242
245
  if (isReadOnly()) {
@@ -431,11 +434,36 @@ app.post("/api/publications/:publicationId/update", async (c) => {
431
434
  return relay(c, "UPDATE_PUBLICATION", { publicationId: c.req.param("publicationId"), note }, 15000, 400);
432
435
  });
433
436
  app.delete("/api/publications/:publicationId", (c) => relay(c, "UNPUBLISH_DOC", { publicationId: c.req.param("publicationId") }, 15000, 400));
437
+ // --- Comments ---
438
+ // Written by the master; the GUI reads them per document and as counts for the badges.
439
+ app.get("/api/comments/summary", (c) => relay(c, "COMMENTS_SUMMARY", { project: c.req.query("project") }, 15000));
440
+ app.get("/api/comments", (c) => relay(c, "LIST_DOC_COMMENTS", { project: c.req.query("project"), docId: c.req.query("doc") }, 15000, 400));
441
+ app.post("/api/comments/read", async (c) => {
442
+ const { publicationId, ids, read } = await readJson(c);
443
+ return relay(c, "SET_COMMENTS_READ", { publicationId, ids, read }, 15000, 400);
444
+ });
445
+ app.post("/api/comments/delete", async (c) => {
446
+ const { publicationId, ids } = await readJson(c);
447
+ return relay(c, "DELETE_COMMENTS", { publicationId, ids }, 15000, 400);
448
+ });
449
+ app.post("/api/comments/closed", async (c) => {
450
+ const { publicationId, closed } = await readJson(c);
451
+ return relay(c, "SET_COMMENTS_CLOSED", { publicationId, closed }, 15000, 400);
452
+ });
434
453
  // --- Public pages ---
435
454
  // `/public/<token>/` serves a frozen document to anyone holding the link, with no session. It is
436
455
  // the only route a hub relays without authentication: everything it answers stays under it.
437
456
  // Read straight from the publication store, so a page never waits on the master.
438
457
  const publicationStore = new PublicationStore(dataDir);
458
+ const commentStore = new CommentStore(dataDir);
459
+ /** Largest comment request a public page may send. */
460
+ const MAX_COMMENT_REQUEST_BYTES = 16 * 1024;
461
+ const COMMENT_LIMITS = {
462
+ comments: MAX_COMMENTS,
463
+ body: MAX_BODY_LENGTH,
464
+ author: MAX_AUTHOR_LENGTH,
465
+ quote: MAX_QUOTE_LENGTH,
466
+ };
439
467
  /** Tokens are 32 random bytes in base64url. */
440
468
  const PUBLIC_TOKEN = /^[A-Za-z0-9_-]{43}$/;
441
469
  const PUBLIC_HEADERS = {
@@ -477,9 +505,54 @@ function jsonForScript(value) {
477
505
  function escapeHtml(value) {
478
506
  return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
479
507
  }
480
- app.all("/public/*", (c) => {
481
- if (c.req.method !== "GET" && c.req.method !== "HEAD") {
482
- return publicResponse("Method not allowed", 405, { Allow: "GET, HEAD", "Cache-Control": "no-store" });
508
+ /**
509
+ * Comments shown on a public page. An unreadable comment file is reported as `comments: null`:
510
+ * the page then states that its comments are unavailable, and the document stays readable.
511
+ */
512
+ function publicCommentsOf(publicationId) {
513
+ try {
514
+ return { comments: commentStore.listPublic(publicationId), commentsUsed: commentStore.list(publicationId).length };
515
+ }
516
+ catch (err) {
517
+ logger.error(`Comments of publication ${publicationId} are unavailable: ${err.message}`);
518
+ return { comments: null, commentsUsed: 0 };
519
+ }
520
+ }
521
+ function publicJson(value, status = 200) {
522
+ return publicResponse(JSON.stringify(value), status, {
523
+ "Content-Type": "application/json; charset=utf-8",
524
+ "Cache-Control": "no-store",
525
+ });
526
+ }
527
+ /**
528
+ * `POST /public/<token>/comments`: a reader's comment, JSON only. A cross-site form cannot send
529
+ * JSON without a preflight this server never answers, so only the page itself can post.
530
+ */
531
+ async function postPublicComment(c, publicationId) {
532
+ const type = String(c.req.header("content-type") ?? "").toLowerCase();
533
+ if (!type.startsWith("application/json"))
534
+ return publicJson({ error: "Comments are sent as JSON" }, 415);
535
+ const raw = await c.req.text();
536
+ if (Buffer.byteLength(raw) > MAX_COMMENT_REQUEST_BYTES)
537
+ return publicJson({ error: "Request too large" }, 413);
538
+ let input;
539
+ try {
540
+ input = JSON.parse(raw);
541
+ }
542
+ catch {
543
+ return publicJson({ error: "Invalid JSON" }, 400);
544
+ }
545
+ const result = await ipcRequest("ADD_PUBLIC_COMMENT", { publicationId, input }, 15000);
546
+ if (result.error)
547
+ return publicJson({ error: "The comment could not be saved" }, 500);
548
+ if (result.data?.rejected)
549
+ return publicJson({ error: result.data.rejected.message }, result.data.rejected.status);
550
+ return publicJson({ comments: result.data.comments, used: result.data.used });
551
+ }
552
+ app.all("/public/*", async (c) => {
553
+ const method = c.req.method;
554
+ if (method !== "GET" && method !== "HEAD" && method !== "POST") {
555
+ return publicResponse("Method not allowed", 405, { Allow: "GET, HEAD, POST", "Cache-Control": "no-store" });
483
556
  }
484
557
  let segments;
485
558
  try {
@@ -494,6 +567,11 @@ app.all("/public/*", (c) => {
494
567
  const publication = publicationStore.findByToken(token);
495
568
  if (!publication)
496
569
  return publicNotFound();
570
+ if (method === "POST") {
571
+ if (tail.length === 1 && tail[0] === "comments")
572
+ return postPublicComment(c, publication.id);
573
+ return publicResponse("Method not allowed", 405, { Allow: "GET, HEAD", "Cache-Control": "no-store" });
574
+ }
497
575
  // Relative URLs of the page resolve under the token only when its URL ends with a slash.
498
576
  if (tail.length === 0) {
499
577
  return publicResponse(null, 302, { Location: `${encodeURIComponent(token)}/`, "Cache-Control": "no-store" });
@@ -509,11 +587,15 @@ app.all("/public/*", (c) => {
509
587
  note: publication.note,
510
588
  publishedAt: publication.publishedAt,
511
589
  content: publicationStore.readContent(publication),
590
+ ...publicCommentsOf(publication.id),
591
+ commentsClosed: publication.commentsClosed === true,
592
+ commentLimits: COMMENT_LIMITS,
512
593
  };
594
+ // Replacer functions: a `$` pattern in the title or in a comment must stay literal.
513
595
  const html = fs
514
596
  .readFileSync(shellPath, "utf8")
515
- .replace(/<title>[^<]*<\/title>/, `<title>${escapeHtml(publication.title)}</title>`)
516
- .replace("</head>", ` <script id="publication-data" type="application/json">${jsonForScript(data)}</script>\n </head>`);
597
+ .replace(/<title>[^<]*<\/title>/, () => `<title>${escapeHtml(publication.title)}</title>`)
598
+ .replace("</head>", () => ` <script id="publication-data" type="application/json">${jsonForScript(data)}</script>\n </head>`);
517
599
  return publicResponse(html, 200, {
518
600
  "Content-Type": "text/html; charset=utf-8",
519
601
  "Cache-Control": "no-store",