@imenam/mcp-documentor 1.0.94 → 1.0.95
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/README.md +24 -2
- package/dist/shared/read-only.js +1 -0
- package/dist/shared/schemas/doc.schema.d.ts +20 -0
- package/dist/shared/schemas/doc.schema.js +20 -0
- package/dist/src/core/comment-store.d.ts +87 -0
- package/dist/src/core/comment-store.js +189 -0
- package/dist/src/core/markdown-text.d.ts +7 -0
- package/dist/src/core/markdown-text.js +16 -0
- package/dist/src/core/publication-store.d.ts +4 -0
- package/dist/src/core/publication-store.js +6 -0
- package/dist/src/gui-worker.js +87 -5
- package/dist/src/index.js +180 -1
- package/dist/src/tools/comments.d.ts +29 -0
- package/dist/src/tools/comments.js +104 -0
- package/gui/dist/assets/{DocContent-B2hgJIHP.js → DocContent-D2Pgvsaw.js} +87 -87
- package/gui/dist/assets/{arc-3c1bsell.js → arc-ytJX8b9b.js} +1 -1
- package/gui/dist/assets/{architectureDiagram-3BPJPVTR-gOvE5Nh-.js → architectureDiagram-3BPJPVTR-vChxNmWw.js} +1 -1
- package/gui/dist/assets/{blockDiagram-GPEHLZMM-d2PyOGgB.js → blockDiagram-GPEHLZMM-D19nVYke.js} +1 -1
- package/gui/dist/assets/{c4Diagram-AAUBKEIU-Fq_MnzGd.js → c4Diagram-AAUBKEIU-7kCjxzBQ.js} +1 -1
- package/gui/dist/assets/channel-DegAVRd2.js +1 -0
- package/gui/dist/assets/{chunk-2J33WTMH-C8rPGvzC.js → chunk-2J33WTMH-CrEHZokt.js} +1 -1
- package/gui/dist/assets/{chunk-4BX2VUAB-CMO6CGqC.js → chunk-4BX2VUAB-AGQbPC36.js} +1 -1
- package/gui/dist/assets/{chunk-55IACEB6-ewoEucdu.js → chunk-55IACEB6-1dY_RrZJ.js} +1 -1
- package/gui/dist/assets/{chunk-727SXJPM-DVlayzbx.js → chunk-727SXJPM-DFrVc2F4.js} +1 -1
- package/gui/dist/assets/{chunk-AQP2D5EJ-CahwsWX2.js → chunk-AQP2D5EJ-Bf5AjFYW.js} +1 -1
- package/gui/dist/assets/{chunk-FMBD7UC4-CTfqQvPB.js → chunk-FMBD7UC4-ytPdLd3c.js} +1 -1
- package/gui/dist/assets/{chunk-ND2GUHAM-D16nBstn.js → chunk-ND2GUHAM-MQj2MK8N.js} +1 -1
- package/gui/dist/assets/{chunk-QZHKN3VN-BhYm6b_1.js → chunk-QZHKN3VN-BFpijhFi.js} +1 -1
- package/gui/dist/assets/classDiagram-4FO5ZUOK-C8gjytbe.js +1 -0
- package/gui/dist/assets/classDiagram-v2-Q7XG4LA2-C8gjytbe.js +1 -0
- package/gui/dist/assets/{cose-bilkent-S5V4N54A-BMWhCTQz.js → cose-bilkent-S5V4N54A-DydPBClW.js} +1 -1
- package/gui/dist/assets/{dagre-BM42HDAG-B8q9fjYt.js → dagre-BM42HDAG-Dt1NYir9.js} +1 -1
- package/gui/dist/assets/{diagram-2AECGRRQ-BEQd6l9g.js → diagram-2AECGRRQ-By8731FV.js} +1 -1
- package/gui/dist/assets/{diagram-5GNKFQAL-CYSk25NC.js → diagram-5GNKFQAL-6DboNPr2.js} +1 -1
- package/gui/dist/assets/{diagram-KO2AKTUF-pYuzMimB.js → diagram-KO2AKTUF-BlsHXUTP.js} +1 -1
- package/gui/dist/assets/{diagram-LMA3HP47-Coes1K5H.js → diagram-LMA3HP47-NlfihhmZ.js} +1 -1
- package/gui/dist/assets/{diagram-OG6HWLK6-B9QD3Aih.js → diagram-OG6HWLK6-BuPvvcLW.js} +1 -1
- package/gui/dist/assets/{erDiagram-TEJ5UH35-BaTQh6lK.js → erDiagram-TEJ5UH35-Bwh4WcoJ.js} +1 -1
- package/gui/dist/assets/{flowDiagram-I6XJVG4X-CD3j0V3z.js → flowDiagram-I6XJVG4X-Cpo3jFt_.js} +1 -1
- package/gui/dist/assets/{ganttDiagram-6RSMTGT7-CI8luwIp.js → ganttDiagram-6RSMTGT7-MTzCwVCF.js} +4 -4
- package/gui/dist/assets/{gitGraphDiagram-PVQCEYII-CZicCj5w.js → gitGraphDiagram-PVQCEYII-TGZH1N13.js} +1 -1
- package/gui/dist/assets/{infoDiagram-5YYISTIA-BBBZLBfU.js → infoDiagram-5YYISTIA-CjtHqsAF.js} +1 -1
- package/gui/dist/assets/{ishikawaDiagram-YF4QCWOH-JzhvbF8X.js → ishikawaDiagram-YF4QCWOH-BubX7AqP.js} +1 -1
- package/gui/dist/assets/{journeyDiagram-JHISSGLW-z2Fq1AWA.js → journeyDiagram-JHISSGLW-BT5Bu4px.js} +1 -1
- package/gui/dist/assets/{kanban-definition-UN3LZRKU-CsERU4RU.js → kanban-definition-UN3LZRKU-CyiNLbBC.js} +1 -1
- package/gui/dist/assets/{linear-bwdTeoS5.js → linear-rOmZOzd-.js} +1 -1
- package/gui/dist/assets/main-BvGBQ4na.css +1 -0
- package/gui/dist/assets/main-DqcEvJHv.js +10 -0
- package/gui/dist/assets/{mindmap-definition-RKZ34NQL-Hhnd5vi2.js → mindmap-definition-RKZ34NQL-Cvy4YAiE.js} +1 -1
- package/gui/dist/assets/{pieDiagram-4H26LBE5-DCSptoQo.js → pieDiagram-4H26LBE5-BKrVYqW5.js} +1 -1
- package/gui/dist/assets/public-BOnoF84L.js +1 -0
- package/gui/dist/assets/public-BmtG1Lfn.css +1 -0
- package/gui/dist/assets/{quadrantDiagram-W4KKPZXB-CFG_TBfZ.js → quadrantDiagram-W4KKPZXB-DG_N325e.js} +1 -1
- package/gui/dist/assets/{requirementDiagram-4Y6WPE33-DUHthbad.js → requirementDiagram-4Y6WPE33-oWqsr2x0.js} +1 -1
- package/gui/dist/assets/{sankeyDiagram-5OEKKPKP-CcivnX5S.js → sankeyDiagram-5OEKKPKP-B8uXp_JX.js} +1 -1
- package/gui/dist/assets/{sequenceDiagram-3UESZ5HK-DD3f-L7G.js → sequenceDiagram-3UESZ5HK-bZUsMsnM.js} +1 -1
- package/gui/dist/assets/{stateDiagram-AJRCARHV-DP6QuANT.js → stateDiagram-AJRCARHV-3o_sYDh3.js} +1 -1
- package/gui/dist/assets/stateDiagram-v2-BHNVJYJU-CfMfyWVZ.js +1 -0
- package/gui/dist/assets/{timeline-definition-PNZ67QCA-DpSvFfP9.js → timeline-definition-PNZ67QCA-BWMaBPME.js} +1 -1
- package/gui/dist/assets/{vennDiagram-CIIHVFJN-DU7Clt-k.js → vennDiagram-CIIHVFJN-BuBBjBVe.js} +1 -1
- package/gui/dist/assets/{wardley-L42UT6IY-By2c-zj1.js → wardley-L42UT6IY-Da4i2-Py.js} +1 -1
- package/gui/dist/assets/{wardleyDiagram-YWT4CUSO-BJeFdhb5.js → wardleyDiagram-YWT4CUSO-Cv3OBAYm.js} +1 -1
- package/gui/dist/assets/{xychartDiagram-2RQKCTM6-ZeDSX3nQ.js → xychartDiagram-2RQKCTM6-DFmCIdof.js} +1 -1
- package/gui/dist/index.html +3 -3
- package/gui/dist/public.html +3 -3
- package/package.json +5 -1
- package/gui/dist/assets/channel-D0TrBTBU.js +0 -1
- package/gui/dist/assets/classDiagram-4FO5ZUOK-BZarwo9C.js +0 -1
- package/gui/dist/assets/classDiagram-v2-Q7XG4LA2-BZarwo9C.js +0 -1
- package/gui/dist/assets/main-BB0ARNyc.js +0 -10
- package/gui/dist/assets/main-DDy8gF40.css +0 -1
- package/gui/dist/assets/public-Bds7tw8V.css +0 -1
- package/gui/dist/assets/public-D8L9bJEM.js +0 -1
- package/gui/dist/assets/stateDiagram-v2-BHNVJYJU-DCHGEDGQ.js +0 -1
package/README.md
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
|
package/dist/shared/read-only.js
CHANGED
|
@@ -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();
|
package/dist/src/gui-worker.js
CHANGED
|
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
479
507
|
}
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
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",
|