dsh-figmark 1.2.0 → 1.2.2

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/lib/index.js CHANGED
@@ -1,5 +1,9 @@
1
+ import { createRequire } from "node:module";
1
2
  import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
2
3
  import { basename, dirname, extname, isAbsolute, join, resolve } from "node:path";
4
+ import { createServer, request } from "node:http";
5
+ import { connect } from "node:net";
6
+ import { readFileSync } from "node:fs";
3
7
  import { createHash } from "node:crypto";
4
8
  //#region src/host/guidance.ts
5
9
  /**
@@ -22,31 +26,51 @@ const GUIDANCE = [
22
26
  "用户可能在文档预览里直接标注(箭头/框选/椭圆/手绘/单点),发送给你的消息以【附图标注】开头,正文就是**逐条标注清单**,不需要再去读任何附加文件(标注文件是投递用的临时件,发出即删)。收到时按下列纪律处理:",
23
27
  "",
24
28
  "1. 逐条读消息正文的清单:每条形如 `① 箭头 (10,10) → (40,30),批注:标号指错了`。括号里是图面像素坐标,原点在左上角;框选与圈选给的是**左上角-右下角**,手绘线给的是它画在哪个框里。`批注:` 之后是用户写的**详细说明**——那是这条标注唯一的内容,按它判断意图。有的条目在形状后面紧跟一个圆括号,写着该处落在哪个元素上(SVG 给元素标题/`id`/元素文字,HTML 给 `selector`/元素文字),定位时优先用它。",
29
+ "1a. 若某条写的是**元素**而不是形状(`① 元素 …`),那条标注指的不是\"在这里画个框\",而是**那一个元素本身**:圆括号里给的是选中时抽到的名字(元素标题 / 元素文字 / 选择器 / `<g id=\"patch_2\">` 这样的开标签——最后一种里唯一能搜的就是那个 `id`),按它去源文件里找到那一个元素,改的就是它,不是它周围的一块区域。这类标注没有可挪动的形状,用户说的\"这里\"就是那个元素。",
25
30
  "2. **PDF 是整份文档一份标注**:每条标注都带 `第 N 页`,坐标是那一页自己的(原点在该页左上角)。看每条标注前先确认它的页码。",
26
31
  "3. 若标注超出原图边界,标注图会把越界的那圈纸也画出来,并用**灰色虚线**框住原图真正的边缘:虚线**里面**是原图,**外面**是你画的标注伸出去的部分——那块不属于图,不要把它当成新增的画布内容。有形状的标注(箭头/框选/椭圆/手绘)会附一张标注图(`<图名>.annotated.png`,PDF 则是一份**带批注的 PDF**,其批注是原生 PDF 注释对象:`/Ink` 手绘、`/Square` 框选、`/Circle` 圈选、`/Line` 箭头),可直接打开核对图面位置。**若消息说「本条标注只有位置点,没有标注图」,那就是全部内容:只有坐标加一句说明,直接按原图定位处理,不要去找不存在的附件。**",
27
32
  "4. 逐条处理标注:每条标注都要落到具体的图面改动,并在答复里逐条回应(编号与标注一致)。无法实现的标注要说明原因,不要静默跳过。",
28
33
  "5. 修改附图以**标注所指的生成源**为准(绘制脚本、SVG 源或 `generate_patent_figure` 的入参),不要直接涂改导出的位图;PDF 同理,改的是生成该 PDF 的源头,不是那份带批注的副本。未标注的部分保持不动。",
29
34
  "6. 改完图后重新渲染并核对:图面尺寸/坐标若变化,旧标注的坐标会失准,此时以 `anchor` 与标注文字为准复核一遍,再回报结果。",
30
- "7. 若消息以【HTML 标注】开头,那是**渲染出来的 HTML 文档**:标注画在固定 1024px 宽的渲染面上,`points` 是那个宽度下的渲染像素。这类标注**没有标注图**(浏览器无法把渲染好的文档栅格化成图片),所以定位只能靠 `marks[].anchor.selector`——那是一条**可直接粘贴使用**的精确路径:每段是 `tag:nth-of-type(n)`,遇到 `#id` 就在那里截断,因此它要么以 `body > …` 开头,要么直接是 `#id > …`;`anchor.text` 是该元素的文字,`points` 只作辅助(渲染宽度一变坐标就会漂)。",
31
- "8. 处理 HTML 标注时,改的是**生成这份 HTML 的源文件**(模板、生成脚本或 HTML 本体),不是预览里的渲染结果;渲染面按本插件内联的样式与脚本策略绘制,脚本不会执行、外部资源不会加载,所以别把\"预览里看起来不对\"当成文件本身的问题。"
32
- ].join("\n");
33
- /** Section order used for the Markdown edit-proposal guidance (after the annotation one). */
34
- const EDIT_GUIDANCE_ORDER = 8991;
35
- /** Stable section name, unique in the assembled prompt. */
36
- const EDIT_GUIDANCE_NAME = "dsh-figmark:markdown-edits";
37
- /** The guidance text appended for messages that carry a Markdown difference. */
38
- const EDIT_GUIDANCE = [
39
- "## Markdown 修改建议(dsh-figmark)",
40
- "",
41
- "用户可能在文档预览里直接编辑 Markdown,保存后你会收到一条【文档修改建议】开头、正文是一段 unified diff 的用户消息(`--- a/<文件名>`、`+++ b/<文件名>`,`@@` 头给出两侧的起始行与行数)。收到时按下列纪律处理:",
42
- "",
43
- "1. 先读文件(消息里给出路径)。**用户没有把改动写进磁盘**:磁盘上的文件仍是旧版,`a` 侧是用户打开时的那一版,`b` 侧是用户改完的结果。",
44
- "2. 把 diff 应用到源文件:`-` 行删除、`+` 行写入、空格开头的是上下文(保持原样),逐处落地;不要重写整个文件,diff 之外的部分保持不动。",
45
- "3. 消息里若说「另有 N 处改动未在本消息里展开」,完整结果就是随消息发送的 `<文件名>.edited.md` 附件;以它为准。",
46
- "4. 消息里若带「我的说明:」清单,那是用户对某一行/某一处改动写的理由(形如 `1. 第 6 行(@@ -3,7 +3,7 @@):…`);落地时按它判断意图,说明里提到的行号是**基线(a 侧)**的行号。",
47
- "5. 消息里若说文件在编辑期间被改动过、或 diff 以整块替换呈现,先核对再落地;无法安全应用时说明原因,不要猜着改。",
48
- "6. 落地后逐处回应(顺序与 diff 一致),并说明哪些改动你没有采纳以及为什么。"
35
+ "7. 若消息以【HTML 标注】开头,那是**本机 loopback 服务页**:消息会给出页面地址。标注画在当时的真实页面视口里,`points` 只是当时的视口坐标,页面重排、滚动或更换视口后可能失准;回源定位以 `marks[].anchor.selector` 为主——每段是 `tag:nth-of-type(n)`,遇到 `#id` 就在那里截断,因此它要么以 `body > …` 开头,要么直接是 `#id > …`;`anchor.text` 是该元素当时的文字。SVG 的元素标注同理,只是那条路径以 `<svg>` 元素本身为根(`svg > g:nth-of-type(2) > …`),遇到 `#id` 同样在那里截断——所以它要么以 `svg > …` 开头,要么直接是 `#id > …`。",
36
+ "8. 处理 HTML 标注时,改的是**服务这个页面的源**(模板、组件、样式、生成脚本或静态资源),不是浏览器里的渲染结果。live preview 保留了真实脚本、路由和资源加载,所以页面里的状态与视觉是该服务实际给出的;若页面依赖登录态、外部 API 或一个未启动的服务,先按消息里的地址核对运行条件再改源。"
49
37
  ].join("\n");
38
+ /** Route prefix owned by this plugin. */
39
+ const ROUTE_PREFIX = "/dsh-figmark";
40
+ //#endregion
41
+ //#region src/shared/live.ts
42
+ /**
43
+ * The wire between a rendered page and the panel.
44
+ *
45
+ * A page in the frame is not a document the panel holds: even when the file is on
46
+ * the reader's disk, the frame's own scripts have to run — a deck that animates, a
47
+ * video that plays, a link that means something — and a frame allowed to run scripts
48
+ * must not share the panel's origin. So the frame is opaque to the parent, and the
49
+ * only way across is a small script injected into it and `postMessage` back.
50
+ *
51
+ * This module is that vocabulary, and it is also the fence around it: everything it
52
+ * accepts is parsed field by field, because the other side is a page and a page can
53
+ * lie. A lie must stay a name — never a coordinate the reader never pointed at.
54
+ * @module dsh-figmark/shared/live
55
+ */
56
+ /** Route the panel opens a live page through, under the plugin prefix. */
57
+ const LIVE_ROUTE = "/live";
58
+ /**
59
+ * Largest PNG data URL a live page may send back.
60
+ *
61
+ * The message is held by both browsing contexts while it crosses the frame
62
+ * boundary. Failing below the parser's hard ceiling leaves room for the rest of
63
+ * the object instead of sending an answer the receiving side would discard.
64
+ */
65
+ const MAX_LIVE_CAPTURE_DATA_URL = 155e5;
66
+ //#endregion
67
+ //#region src/shared/services.ts
68
+ /**
69
+ * The small vocabulary shared by the service browser and the Host scan.
70
+ * @module dsh-figmark/shared/services
71
+ */
72
+ /** Route that probes loopback services for the browser. */
73
+ const SERVICE_DISCOVER_ROUTE = "/services";
50
74
  //#endregion
51
75
  //#region src/shared/address.ts
52
76
  /** Documented prefix of every file address. */
@@ -82,8 +106,36 @@ function parseFileAddress(address) {
82
106
  }
83
107
  if (rest.startsWith("absolute/")) return { path: decodeSegment(rest.slice(9)) };
84
108
  }
85
- /** Route prefix owned by this plugin. */
86
- const ROUTE_PREFIX = "/dsh-figmark";
109
+ //#endregion
110
+ //#region src/shared/path-name.ts
111
+ /**
112
+ * The two path-name readings both halves need, in one place.
113
+ *
114
+ * A path arrives here from the Host (which uses platform separators) or from a
115
+ * browser document (which only ever writes `/`), so every splitter has to accept
116
+ * both. They used to be written out at each call site — once for the address
117
+ * grammar, once for the suffix table, once for a file name in a message — and the
118
+ * copies had already drifted apart on backslashes. One home makes them agree.
119
+ * @module dsh-figmark/shared/path-name
120
+ */
121
+ /**
122
+ * The last segment of a path, whatever separators it uses.
123
+ * @param path - a POSIX- or Windows-separated path.
124
+ * @returns the path's file name.
125
+ */
126
+ function baseNameOf(path) {
127
+ return path.slice(Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")) + 1);
128
+ }
129
+ /**
130
+ * Read a path's lower-case suffix without the dot.
131
+ * @param path - file path or name.
132
+ * @returns the suffix, or an empty string when the name has none.
133
+ */
134
+ function suffixOf(path) {
135
+ const name = baseNameOf(path);
136
+ const dot = name.lastIndexOf(".");
137
+ return dot <= 0 ? "" : name.slice(dot + 1).toLowerCase();
138
+ }
87
139
  //#endregion
88
140
  //#region src/shared/figure-kind.ts
89
141
  /**
@@ -115,24 +167,8 @@ const FIGURE_EXTENSIONS = [
115
167
  "html",
116
168
  "htm"
117
169
  ];
118
- /**
119
- * Read a path's lower-case suffix without the dot.
120
- * @param path - file path or name.
121
- * @returns the suffix, or an empty string when there is none.
122
- */
123
- function suffixOf(path) {
124
- const name = path.slice(Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")) + 1);
125
- const dot = name.lastIndexOf(".");
126
- return dot <= 0 ? "" : name.slice(dot + 1).toLowerCase();
127
- }
128
- /**
129
- * Whether a path names a document this plugin renders.
130
- * @param path - file path.
131
- * @returns true when the suffix is one this plugin annotates.
132
- */
133
- function isFigurePath(path) {
134
- return FIGURE_EXTENSIONS.includes(suffixOf(path));
135
- }
170
+ /** Media type of a document that has pages rather than one surface. */
171
+ const PAGED_MEDIA_TYPE = "application/pdf";
136
172
  /**
137
173
  * Media type of one document path.
138
174
  * @param path - file path.
@@ -140,7 +176,7 @@ function isFigurePath(path) {
140
176
  */
141
177
  function figureMediaType(path) {
142
178
  switch (suffixOf(path)) {
143
- case "pdf": return "application/pdf";
179
+ case "pdf": return PAGED_MEDIA_TYPE;
144
180
  case "svg": return "image/svg+xml";
145
181
  case "png": return "image/png";
146
182
  case "jpg":
@@ -154,113 +190,1150 @@ function figureMediaType(path) {
154
190
  default: return;
155
191
  }
156
192
  }
157
- //#endregion
158
- //#region src/host/sidecar.ts
159
193
  /**
160
- * Annotation files beside one figure.
194
+ * Whether a path names a document this plugin renders.
195
+ * @param path - file path.
196
+ * @returns true when the suffix is one this plugin annotates.
197
+ */
198
+ function isFigurePath(path) {
199
+ return FIGURE_EXTENSIONS.includes(suffixOf(path));
200
+ }
201
+ /**
202
+ * Whether a figure's suffix calls for the paged-document treatment.
161
203
  *
162
- * An annotation never modifies the figure: it is written to sibling files
163
- * (`<name>.annot.json` for the marks and `<name>.annotated.png` — or
164
- * `<name>.annotated.pdf` for a document that has pages — for the flattened
165
- * review artifact). One figure is one document, however many pages it has, so a
166
- * paged document's marks share a single sidecar and name their page inside it.
204
+ * One predicate for the whole plugin: the Host picks the review artifact's file
205
+ * extension from it, the store refuses a review whose kind contradicts it, and the
206
+ * browser half picks which JSON field carries the artifact from it. Reading the
207
+ * media type instead would work today and break the day a second suffix maps to
208
+ * `application/pdf`.
209
+ * @param path - file path.
210
+ * @returns true when the figure is a paged document.
211
+ */
212
+ function isPagedFigurePath(path) {
213
+ return figureMediaType(path) === PAGED_MEDIA_TYPE;
214
+ }
215
+ //#endregion
216
+ //#region src/host/figure-target.ts
217
+ /**
218
+ * Resolving one preview address to a figure that actually exists.
167
219
  *
168
- * The name keeps the figure's own extension (`fig1.svg` → `fig1.svg.annot.json`):
169
- * `fig1.svg` and `fig1.png` in one directory are two different documents, and marks
170
- * drawn on one of them mean nothing on the other, so they must never share a
171
- * sidecar. Every name is derived from the figure path, so the plugin can never be
172
- * asked to write an arbitrary location.
173
- * @module dsh-figmark/host/sidecar
220
+ * This is the only place a request-supplied string becomes a filesystem path, so
221
+ * it is where the plugin's write surface is defined: the address must parse as a
222
+ * file address, must resolve to a suffix this plugin renders, and must name an
223
+ * existing regular file. Anything else is refused with a message the browser can
224
+ * put in front of the reader — and nothing downstream ever accepts a path from the
225
+ * caller again, only the {@link FigureTarget} this module produced.
226
+ * @module dsh-figmark/host/figure-target
174
227
  */
175
- /** Suffix appended to a figure's name for the marks file. */
176
- const ANNOTATION_SUFFIX = ".annot.json";
177
- /** Suffix appended to a figure's name for the flattened review artifact. */
178
- const ANNOTATED_SUFFIX = ".annotated";
179
228
  /**
180
- * Resolve one figure's sidecar paths.
181
- * @param figurePath - absolute path of the annotated figure.
182
- * @returns the sidecar paths; both live in the figure's own directory.
229
+ * Resolve one request address to an existing figure file.
230
+ * @param address - `dsh-resource://file/…` address from the preview tab.
231
+ * @param sessionCwd - Session lookup used for a relative path.
232
+ * @returns the target, or an error message describing why it cannot be annotated.
183
233
  */
184
- function sidecarPaths(figurePath) {
185
- const name = basename(figurePath);
186
- const directory = dirname(figurePath);
187
- const reviewExtension = reviewFileExtension(figurePath);
234
+ async function resolveTarget(address, sessionCwd) {
235
+ const parsed = parseFileAddress(address);
236
+ if (parsed === void 0) return {
237
+ ok: false,
238
+ message: `not a file address: ${address}`
239
+ };
240
+ const sessionId = parsed.sessionId;
241
+ const cwd = sessionId === void 0 ? void 0 : await sessionCwd(sessionId);
242
+ let absolute;
243
+ if (isAbsolute(parsed.path)) absolute = parsed.path;
244
+ else {
245
+ if (cwd === void 0) return {
246
+ ok: false,
247
+ message: `cannot resolve this Session's directory for a relative path: ${address}`
248
+ };
249
+ absolute = resolve(cwd, parsed.path);
250
+ }
251
+ if (!isFigurePath(absolute)) return {
252
+ ok: false,
253
+ message: `not an annotatable figure: ${absolute}`
254
+ };
255
+ const mediaType = figureMediaType(absolute);
256
+ /* v8 ignore next -- isFigurePath already restricted the suffixes figureMediaType covers. */
257
+ if (mediaType === void 0) return {
258
+ ok: false,
259
+ message: `no media type for ${absolute}`
260
+ };
261
+ try {
262
+ if ((await stat(absolute)).isFile()) return {
263
+ ok: true,
264
+ target: {
265
+ address,
266
+ path: absolute,
267
+ mediaType,
268
+ workspace: cwd ?? null
269
+ }
270
+ };
271
+ } catch {}
188
272
  return {
189
- annotation: join(directory, `${name}${ANNOTATION_SUFFIX}`),
190
- review: join(directory, `${name}${ANNOTATED_SUFFIX}${reviewExtension}`)
273
+ ok: false,
274
+ message: `no such figure: ${absolute}`
191
275
  };
192
276
  }
277
+ /** The tag injected into every interactive HTML page. */
278
+ const PICKER_TAG = `<script src="/__dsh_figmark_picker.js"><\/script>`;
279
+ /** Largest HTML document either server will buffer in order to inject the picker. */
280
+ const MAX_HTML_BYTES = 8 * 1024 * 1024;
193
281
  /**
194
- * Suffix of the flattened review artifact for one figure.
282
+ * A page-level policy that would reject the picker script.
195
283
  *
196
- * A paged document is reviewed as a document — the page marks travel as native
197
- * PDF annotations in a copy of the original — while a single-surface figure is
198
- * reviewed as the picture it is.
199
- * @param figurePath - absolute path of the annotated figure.
200
- * @returns the file extension, dot included.
284
+ * The proxy already removes the same policy when it arrives as a response header.
285
+ * HTML can carry the policy in a meta tag as well, and a document-level
286
+ * `script-src 'none'` would otherwise leave a working page with a dead picker.
201
287
  */
202
- function reviewFileExtension(figurePath) {
203
- return extname(figurePath).toLowerCase() === ".pdf" ? ".pdf" : ".png";
204
- }
288
+ const CSP_META = /<meta\b(?=[^>]*\bhttp-equiv\s*=\s*(?:"\s*content-security-policy(?:-report-only)?\s*"|'\s*content-security-policy(?:-report-only)?\s*'|content-security-policy(?:-report-only)?))[^>]*>/giu;
205
289
  /**
206
- * The marks file this plugin derived before the figure's extension became part of
207
- * the name (`fig1.svg` → `fig1.annot.json`). Read-only: a file an earlier version
208
- * wrote stays readable, so an existing annotation never disappears on upgrade.
209
- * @param figurePath - absolute path of the annotated figure.
210
- * @returns the legacy marks file path beside the figure.
290
+ * Remove document-level CSP declarations before the picker is injected.
291
+ * @param html - the page as the source sent it.
292
+ * @returns the page without CSP meta tags.
211
293
  */
212
- function legacyAnnotationPath(figurePath) {
213
- return join(dirname(figurePath), `${basename(figurePath, extname(figurePath))}${ANNOTATION_SUFFIX}`);
294
+ function stripCspMeta(html) {
295
+ return html.replace(CSP_META, "");
214
296
  }
215
297
  /**
216
- * Marks files to read for one figure, most authoritative first: the current name,
217
- * then the legacy one. A figure whose name carries no extension derives both from
218
- * the same name, so the list is deduplicated.
219
- * @param figurePath - absolute path of the annotated figure.
220
- * @returns the candidate marks file paths.
298
+ * Put the picker tag in front of a page's last `</body>`.
299
+ * @param html - the page as the source sent it.
300
+ * @param tag - the tag to inject.
301
+ * @returns the page the frame receives.
221
302
  */
222
- function annotationCandidates(figurePath) {
223
- const current = sidecarPaths(figurePath).annotation;
224
- const legacy = legacyAnnotationPath(figurePath);
225
- return current === legacy ? [current] : [current, legacy];
303
+ function injectPicker(html, tag = PICKER_TAG) {
304
+ const close = html.toLowerCase().lastIndexOf("</body>");
305
+ return close < 0 ? `${html}${tag}` : `${html.slice(0, close)}${tag}${html.slice(close)}`;
226
306
  }
227
307
  /**
228
- * Name pattern of the one-page-per-file sidecars this plugin wrote before a paged
229
- * document became a single annotation.
308
+ * The picker, as the page receives it.
230
309
  *
231
- * Both spellings are matched — the extension-bearing name
232
- * (`fig1.pdf.p2.annot.json`) and the base-name one an even earlier version wrote
233
- * (`fig1.p2.annot.json`) — and the page number is the pattern's first group.
310
+ * On load it marks the document element with `data-dsh-figmark="live"`, so the page
311
+ * (and anyone looking at it) can tell it is being previewed, and it reports that it
312
+ * is ready along with the current scroll — the panel needs both before it can place
313
+ * a single mark. While annotation is armed the pointer is reported once per animation
314
+ * frame and a click names the element instead of following it; while it is not, this
315
+ * script listens and does nothing else.
234
316
  *
235
- * @param figurePath - absolute path of the annotated figure.
236
- * @returns an anchored, case-insensitive pattern over one directory entry's name.
317
+ * Reports go to `window.parent` with target origin `'*'`: the panel is on another
318
+ * origin by construction, and the message carries nothing a hostile page could not
319
+ * already see. The panel validates the shape on arrival — this side only promises to
320
+ * send it.
321
+ *
322
+ * `selector` is positional and nothing else: `tag:nth-of-type(n)` steps joined by
323
+ * `' > '`, rooted at `body` but without naming it, so `body` itself is `'body'`.
324
+ * A positional path survives a page whose ids are regenerated on every render, which
325
+ * is exactly what a live page's ids do — and it is the same grammar the file
326
+ * preview's own path builder writes, so a saved mark resolves against either.
237
327
  */
238
- function legacyPagePattern(figurePath) {
239
- const extension = extname(figurePath);
240
- const alternatives = [.../* @__PURE__ */ new Set([basename(figurePath), basename(figurePath, extension)])].map(escapeForRegExp).join("|");
241
- return new RegExp(`^(?:${alternatives})\\.p(\\d+)${escapeForRegExp(ANNOTATION_SUFFIX)}$`, "iu");
328
+ const PICKER_SCRIPT = `/*! modern-screenshot v4.7.0 | MIT */
329
+ ${readFileSync(createRequire(import.meta.url).resolve("modern-screenshot/dist/index.js"), "utf8")}
330
+ ;(function () {
331
+ if (window.__dshFigmarkPicker) return
332
+ window.__dshFigmarkPicker = true
333
+
334
+ var MARKER = 'dsh-figmark:live'
335
+ var doc = document
336
+ var armed = false
337
+ var pending = 0
338
+ var latest = null
339
+ var latestKey = ''
340
+ var depth = 0
341
+ var lastScroll = ''
342
+ var watched = []
343
+ var lastBounds = ''
344
+ var boundsScheduled = 0
345
+ var lastHover = ''
346
+ var lastSurface
347
+ var surfaceScheduled = 0
348
+ var revealedStyles = []
349
+ var revealSeq = 0
350
+
351
+ // The common spellings presentation frameworks use for the one page in front.
352
+ // This is deliberately a small convention rather than "the largest element":
353
+ // marking an arbitrary large container as a page would hide valid annotations.
354
+ var SURFACE_QUERY = [
355
+ '[data-slide-current="true"]',
356
+ '[data-slide-active="true"]',
357
+ '.slide',
358
+ '.dk-slide',
359
+ '.page.current',
360
+ '.page.active',
361
+ '.page.is-live',
362
+ '.page.live',
363
+ '.swiper-slide',
364
+ '.carousel-item',
365
+ '.reveal .slides section',
366
+ '.remark-slide',
367
+ '.remark-visible',
368
+ '.step.active',
369
+ '[role="tabpanel"]',
370
+ '[data-slide]'
371
+ ].join(',')
372
+
373
+ doc.documentElement.setAttribute('data-dsh-figmark', 'live')
374
+ var cursorStyle = doc.createElement('style')
375
+ cursorStyle.setAttribute('data-dsh-figmark-style', '')
376
+ cursorStyle.textContent = 'html[data-dsh-figmark-armed="true"] *{cursor:crosshair!important}'
377
+ ;(doc.head || doc.documentElement).appendChild(cursorStyle)
378
+
379
+ function pathOf(el) {
380
+ if (el === doc.body) return 'body'
381
+ var steps = []
382
+ for (var node = el; node && node !== doc.body; node = node.parentElement) {
383
+ var index = 1
384
+ for (var sib = node.previousElementSibling; sib; sib = sib.previousElementSibling) {
385
+ if (sib.tagName === node.tagName) index += 1
386
+ }
387
+ steps.unshift(node.tagName.toLowerCase() + ':nth-of-type(' + index + ')')
388
+ }
389
+ return steps.join(' > ')
390
+ }
391
+
392
+ function ladderOf(el) {
393
+ var answer = []
394
+ for (var node = el; node && node !== doc.documentElement; node = node.parentElement) {
395
+ answer.push(node)
396
+ }
397
+ return answer
398
+ }
399
+
400
+ function keyOf(ladder) {
401
+ var parts = []
402
+ for (var i = 0; i < ladder.length; i += 1) parts.push(pathOf(ladder[i]))
403
+ return parts.join('|')
404
+ }
405
+
406
+ function scrollOf() {
407
+ return [window.scrollX || 0, window.scrollY || 0]
408
+ }
409
+
410
+ function send(message) {
411
+ var parent = window.parent
412
+ if (!parent) return
413
+ try { parent.postMessage(message, '*') } catch (error) { return }
414
+ }
415
+
416
+ function report(kind, el, ladder, index) {
417
+ var rect = el.getBoundingClientRect()
418
+ var scroll = scrollOf()
419
+ var selector = pathOf(el)
420
+ var hoverKey = selector + '|' + scroll[0] + ',' + scroll[1] + '|'
421
+ + rect.left + ',' + rect.top + ',' + rect.width + ',' + rect.height
422
+ if (kind === 'hover') {
423
+ if (hoverKey === lastHover) return
424
+ lastHover = hoverKey
425
+ }
426
+ var message = {
427
+ figmark: MARKER,
428
+ kind: kind,
429
+ tag: el.tagName.toLowerCase(),
430
+ selector: selector,
431
+ bbox: [rect.left + scroll[0], rect.top + scroll[1], rect.width, rect.height],
432
+ scroll: scroll,
433
+ level: index + 1,
434
+ count: ladder.length
435
+ }
436
+ var text = (el.textContent || '').replace(/\\s+/g, ' ').trim().slice(0, 80)
437
+ if (text) message.text = text
438
+ if (el.id) message.id = el.id
439
+ var cls = el.getAttribute('class')
440
+ if (cls && cls.trim()) message['class'] = cls.trim()
441
+ if (kind === 'pick') {
442
+ var owner = surfaceOf(el)
443
+ message.surface = owner ? pathOf(owner) : null
444
+ }
445
+ send(message)
446
+ }
447
+
448
+ function choose(ladder) {
449
+ var key = keyOf(ladder)
450
+ if (key !== latestKey) {
451
+ latestKey = key
452
+ depth = 0
453
+ }
454
+ depth = Math.max(0, Math.min(depth, ladder.length - 1))
455
+ return depth
456
+ }
457
+
458
+ function under(event) {
459
+ var el = event.target
460
+ if (!el || el.nodeType !== 1) return null
461
+ return el === doc.documentElement ? null : el
462
+ }
463
+
464
+ function rendered(el) {
465
+ if (!el) return false
466
+ var style = window.getComputedStyle(el)
467
+ if (style.display === 'none'
468
+ || style.visibility === 'hidden'
469
+ || style.visibility === 'collapse'
470
+ || style.contentVisibility === 'hidden'
471
+ || parseFloat(style.opacity) <= 0) return false
472
+ // A slide is often put away by an ancestor's opacity. The element's own computed
473
+ // visibility stays "visible" there, so check the compositing chain as well.
474
+ for (var node = el.parentElement; node; node = node.parentElement) {
475
+ var parentStyle = window.getComputedStyle(node)
476
+ if (parentStyle.display === 'none'
477
+ || parentStyle.contentVisibility === 'hidden'
478
+ || parseFloat(parentStyle.opacity) <= 0) return false
479
+ }
480
+ if (typeof el.checkVisibility === 'function') {
481
+ try {
482
+ return el.checkVisibility({
483
+ checkOpacity: true,
484
+ checkVisibilityCSS: true,
485
+ opacityProperty: true,
486
+ visibilityProperty: true,
487
+ contentVisibilityAuto: true
488
+ })
489
+ } catch (error) {
490
+ return true
491
+ }
492
+ }
493
+ return true
494
+ }
495
+
496
+ function shown(el) {
497
+ if (!rendered(el)) return false
498
+ var rect = el.getBoundingClientRect()
499
+ if (rect.width <= 0 || rect.height <= 0) return false
500
+ var viewportWidth = window.innerWidth || doc.documentElement.clientWidth || 0
501
+ var viewportHeight = window.innerHeight || doc.documentElement.clientHeight || 0
502
+ if (rect.right <= 0 || rect.bottom <= 0 || rect.left >= viewportWidth || rect.top >= viewportHeight) {
503
+ return false
504
+ }
505
+ return true
506
+ }
507
+
508
+ function inViewport(el) {
509
+ var rect = el.getBoundingClientRect()
510
+ var viewportWidth = window.innerWidth || doc.documentElement.clientWidth || 0
511
+ var viewportHeight = window.innerHeight || doc.documentElement.clientHeight || 0
512
+ return rect.left >= -1 && rect.top >= -1
513
+ && rect.right <= viewportWidth + 1 && rect.bottom <= viewportHeight + 1
514
+ }
515
+
516
+ function boxInViewport(box) {
517
+ var scroll = scrollOf()
518
+ var viewportWidth = window.innerWidth || doc.documentElement.clientWidth || 0
519
+ var viewportHeight = window.innerHeight || doc.documentElement.clientHeight || 0
520
+ var x = typeof box[0] === 'number' ? box[0] : 0
521
+ var y = typeof box[1] === 'number' ? box[1] : 0
522
+ var width = typeof box[2] === 'number' ? box[2] : 0
523
+ var height = typeof box[3] === 'number' ? box[3] : 0
524
+ return x >= scroll[0] - 1 && y >= scroll[1] - 1
525
+ && x + width <= scroll[0] + viewportWidth + 1
526
+ && y + height <= scroll[1] + viewportHeight + 1
527
+ }
528
+
529
+ function measure(selector) {
530
+ var el = null
531
+ try { el = doc.querySelector(selector) } catch (error) { el = null }
532
+ if (!el) return { selector: selector, bbox: [0, 0, 0, 0], visible: false }
533
+ var rect = el.getBoundingClientRect()
534
+ var scroll = scrollOf()
535
+ return {
536
+ selector: selector,
537
+ bbox: [rect.left + scroll[0], rect.top + scroll[1], rect.width, rect.height],
538
+ visible: shown(el)
539
+ }
540
+ }
541
+
542
+ function delay(ms) {
543
+ return new Promise(function (resolve) { window.setTimeout(resolve, ms) })
544
+ }
545
+
546
+ function waitForRendered(el, remaining) {
547
+ if (rendered(el)) return Promise.resolve(true)
548
+ if (remaining <= 0) return Promise.resolve(false)
549
+ return delay(45).then(function () { return waitForRendered(el, remaining - 1) })
550
+ }
551
+
552
+ function restoreHidden() {
553
+ for (var i = revealedStyles.length - 1; i >= 0; i -= 1) {
554
+ var item = revealedStyles[i]
555
+ if (item.value) item.el.style.setProperty(item.name, item.value, item.priority)
556
+ else item.el.style.removeProperty(item.name)
557
+ }
558
+ revealedStyles = []
559
+ }
560
+
561
+ function overrideHidden(node, name, value) {
562
+ revealedStyles.push({
563
+ el: node,
564
+ name: name,
565
+ value: node.style.getPropertyValue(name),
566
+ priority: node.style.getPropertyPriority(name)
567
+ })
568
+ node.style.setProperty(name, value, 'important')
569
+ }
570
+
571
+ function revealHidden(el, stop) {
572
+ var outermost = null
573
+ for (var node = el; node && node !== doc.body && node !== doc.documentElement && node !== stop; node = node.parentElement) {
574
+ var style = window.getComputedStyle(node)
575
+ if (style.display === 'none') overrideHidden(node, 'display', 'revert')
576
+ if (style.visibility === 'hidden' || style.visibility === 'collapse') {
577
+ overrideHidden(node, 'visibility', 'visible')
578
+ }
579
+ if (style.contentVisibility === 'hidden') overrideHidden(node, 'content-visibility', 'visible')
580
+ if (parseFloat(style.opacity) <= 0) overrideHidden(node, 'opacity', '1')
581
+ outermost = node
582
+ }
583
+ if (outermost && window.getComputedStyle(outermost).pointerEvents !== 'none') {
584
+ overrideHidden(outermost, 'pointer-events', 'none')
585
+ }
586
+ }
587
+
588
+ function surfaceOf(el) {
589
+ for (var node = el; node && node !== doc.documentElement; node = node.parentElement) {
590
+ try {
591
+ if (node.matches(SURFACE_QUERY)) return node
592
+ } catch (error) {
593
+ return null
594
+ }
595
+ }
596
+ return null
597
+ }
598
+
599
+ function nearestSurface() {
600
+ var surfaces = []
601
+ try { surfaces = Array.prototype.slice.call(doc.querySelectorAll(SURFACE_QUERY)) } catch (error) { return null }
602
+ for (var i = 0; i < surfaces.length; i += 1) {
603
+ var rect = surfaces[i].getBoundingClientRect()
604
+ // A deck often clones every slide into its overview. Those copies keep the
605
+ // same classes but have no layout, so they cannot be navigation targets.
606
+ if (rect.width > 0 && rect.height > 0) return surfaces[i]
607
+ }
608
+ return null
609
+ }
610
+
611
+ function turnToward(target) {
612
+ var surfaces = []
613
+ try { surfaces = Array.prototype.slice.call(doc.querySelectorAll(SURFACE_QUERY)) } catch (error) { return Promise.resolve(false) }
614
+ var wanted = surfaces.indexOf(target)
615
+ var current = visibleSurface()
616
+ var from = current ? surfaces.indexOf(current) : -1
617
+ if (wanted < 0 || from < 0 || wanted === from) return Promise.resolve(shown(target))
618
+ var key = wanted > from ? 'ArrowRight' : 'ArrowLeft'
619
+ var steps = Math.min(32, Math.abs(wanted - from) + 2)
620
+ function next(remaining) {
621
+ if (remaining <= 0 || shown(target)) return Promise.resolve(shown(target))
622
+ var node = doc.activeElement || doc.body || doc.documentElement
623
+ var event = new KeyboardEvent('keydown', {
624
+ key: key,
625
+ code: key,
626
+ keyCode: key === 'ArrowRight' ? 39 : 37,
627
+ which: key === 'ArrowRight' ? 39 : 37,
628
+ bubbles: true,
629
+ cancelable: true
630
+ })
631
+ node.dispatchEvent(event)
632
+ return delay(45).then(function () { return next(remaining - 1) })
633
+ }
634
+ return next(steps)
635
+ }
636
+
637
+ function reveal(requestId, selector, surface, bbox) {
638
+ revealSeq += 1
639
+ var seq = revealSeq
640
+ restoreHidden()
641
+ function current() {
642
+ return seq === revealSeq
643
+ }
644
+ var requestedEl = null
645
+ if (selector) {
646
+ try { requestedEl = doc.querySelector(selector) } catch (error) { requestedEl = null }
647
+ }
648
+ var ownerSurface = null
649
+ var surfaceEl = null
650
+ if (requestedEl) {
651
+ // The element is the strongest evidence of where it lives. A mark made
652
+ // before page ownership was recorded may carry the slide that was open at
653
+ // creation time instead, which would leave us waiting on the wrong page.
654
+ ownerSurface = surfaceOf(requestedEl)
655
+ surfaceEl = ownerSurface
656
+ }
657
+ if (!surfaceEl && requestedEl && !shown(requestedEl)) {
658
+ surfaceEl = nearestSurface()
659
+ }
660
+ if (!surfaceEl && surface) {
661
+ try { surfaceEl = doc.querySelector(surface) } catch (error) { surfaceEl = null }
662
+ if (!surfaceEl) {
663
+ send({ figmark: MARKER, kind: 'reveal-result', requestId: requestId, status: 'wrong-surface' })
664
+ return
665
+ }
666
+ }
667
+ var ready = shown(surfaceEl) ? Promise.resolve(true) : surfaceEl ? turnToward(surfaceEl) : Promise.resolve(true)
668
+ ready.then(function (surfaceReady) {
669
+ if (!current()) return
670
+ if (!surfaceReady) {
671
+ send({ figmark: MARKER, kind: 'reveal-result', requestId: requestId, status: 'wrong-surface' })
672
+ return
673
+ }
674
+ if (selector) {
675
+ var el = requestedEl
676
+ if (!el) {
677
+ send({ figmark: MARKER, kind: 'reveal-result', requestId: requestId, status: 'missing' })
678
+ return
679
+ }
680
+ waitForRendered(el, 4).then(function (elementReady) {
681
+ if (!current()) return false
682
+ if (!elementReady) {
683
+ // A cover, modal, or intro layer can sit outside every slide and stay
684
+ // hidden after the deck has advanced. Restore only the hidden ancestors
685
+ // below the owning surface; the source and the page's state are untouched.
686
+ revealHidden(el, ownerSurface)
687
+ return waitForRendered(el, 10)
688
+ }
689
+ return elementReady
690
+ }).then(function (elementReady) {
691
+ if (!current()) return
692
+ if (!elementReady) {
693
+ restoreHidden()
694
+ send({ figmark: MARKER, kind: 'reveal-result', requestId: requestId, status: 'hidden' })
695
+ return
696
+ }
697
+ // A fully visible element must not move. Centring used to shift decks and
698
+ // fixed-stage pages under the reader even when the mark was already on
699
+ // screen; nearest changes only what is actually clipped.
700
+ if (window.getComputedStyle(el).position !== 'fixed' && !inViewport(el)) {
701
+ try { el.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'auto' }) } catch (error) { el.scrollIntoView() }
702
+ }
703
+ delay(0).then(function () {
704
+ if (!current()) return
705
+ sendScroll()
706
+ var visible = shown(el)
707
+ var answer = {
708
+ figmark: MARKER,
709
+ kind: 'reveal-result',
710
+ requestId: requestId,
711
+ status: visible ? 'revealed' : 'hidden',
712
+ surface: surfaceEl ? pathOf(surfaceEl) : null
713
+ }
714
+ if (visible && revealedStyles.length > 0) answer.temporary = true
715
+ send(answer)
716
+ })
717
+ })
718
+ return
719
+ }
720
+ var x = bbox && typeof bbox[0] === 'number' ? bbox[0] : 0
721
+ var y = bbox && typeof bbox[1] === 'number' ? bbox[1] : 0
722
+ if (!boxInViewport(bbox || [0, 0, 0, 0])) {
723
+ window.scrollTo({
724
+ left: Math.max(0, x - (window.innerWidth || 0) / 2),
725
+ top: Math.max(0, y - (window.innerHeight || 0) / 2),
726
+ behavior: 'auto'
727
+ })
728
+ }
729
+ delay(0).then(function () {
730
+ if (!current()) return
731
+ sendScroll()
732
+ var width = bbox && typeof bbox[2] === 'number' ? bbox[2] : 0
733
+ var height = bbox && typeof bbox[3] === 'number' ? bbox[3] : 0
734
+ var visible = width <= 0 || height <= 0
735
+ || (x + width > window.scrollX && x < window.scrollX + (window.innerWidth || 0)
736
+ && y + height > window.scrollY && y < window.scrollY + (window.innerHeight || 0))
737
+ send({
738
+ figmark: MARKER,
739
+ kind: 'reveal-result',
740
+ requestId: requestId,
741
+ status: visible ? 'revealed' : 'hidden'
742
+ })
743
+ })
744
+ })
745
+ }
746
+
747
+ function sendBounds(force) {
748
+ boundsScheduled = 0
749
+ if (watched.length === 0) return
750
+ var bounds = []
751
+ for (var i = 0; i < watched.length; i += 1) bounds.push(measure(watched[i]))
752
+ var key = JSON.stringify(bounds)
753
+ if (!force && key === lastBounds) return
754
+ lastBounds = key
755
+ send({ figmark: MARKER, kind: 'bounds', bounds: bounds })
756
+ }
757
+
758
+ function scheduleBounds() {
759
+ if (watched.length === 0 || boundsScheduled) return
760
+ // Do not store the request handle here: a test clock may run the callback before
761
+ // requestAnimationFrame returns, which would assign a truthy handle after the
762
+ // callback had already cleared the flag.
763
+ boundsScheduled = 1
764
+ window.requestAnimationFrame(function () { sendBounds(false) })
765
+ }
766
+
767
+ function sendScroll() {
768
+ var scroll = scrollOf()
769
+ var key = scroll[0] + ',' + scroll[1]
770
+ if (key !== lastScroll) {
771
+ lastScroll = key
772
+ send({ figmark: MARKER, kind: 'scroll', scroll: scroll })
773
+ }
774
+ scheduleBounds()
775
+ scheduleSurface()
776
+ }
777
+
778
+ function visibleSurface() {
779
+ var candidates
780
+ try { candidates = doc.querySelectorAll(SURFACE_QUERY) } catch (error) { return null }
781
+ for (var i = 0; i < candidates.length; i += 1) {
782
+ var el = candidates[i]
783
+ if (shown(el)) return el
784
+ }
785
+ return null
786
+ }
787
+
788
+ function sendSurface(force) {
789
+ surfaceScheduled = 0
790
+ var el = visibleSurface()
791
+ var selector = el ? pathOf(el) : null
792
+ var key = selector === null ? '\\u0000none' : selector
793
+ if (selector === null && lastSurface === undefined) return
794
+ if (!force && key === lastSurface) return
795
+ lastSurface = key
796
+ send({ figmark: MARKER, kind: 'surface', selector: selector })
797
+ }
798
+
799
+ function scheduleSurface() {
800
+ if (surfaceScheduled) return
801
+ surfaceScheduled = 1
802
+ window.requestAnimationFrame(function () { sendSurface(false) })
803
+ }
804
+
805
+ doc.addEventListener('pointermove', function (event) {
806
+ if (!armed) return
807
+ var el = under(event)
808
+ if (!el) return
809
+ var ladder = ladderOf(el)
810
+ if (ladder.length === 0) return
811
+ var index = choose(ladder)
812
+ latest = { ladder: ladder, index: index }
813
+ if (pending) return
814
+ pending = 1
815
+ window.requestAnimationFrame(function () {
816
+ pending = 0
817
+ if (!latest) return
818
+ report('hover', latest.ladder[latest.index], latest.ladder, latest.index)
819
+ })
820
+ }, true)
821
+
822
+ // The pointer going off the page takes the name with it: a label left behind over
823
+ // an element the reader's pointer has already left is a label about nothing.
824
+ doc.documentElement.addEventListener('pointerleave', function () {
825
+ if (!armed) return
826
+ latest = null
827
+ lastHover = ''
828
+ send({ figmark: MARKER, kind: 'leave' })
829
+ })
830
+
831
+ doc.addEventListener('click', function (event) {
832
+ if (!armed) return
833
+ var el = under(event)
834
+ if (!el) return
835
+ var ladder = ladderOf(el)
836
+ if (ladder.length === 0) return
837
+ var index = choose(ladder)
838
+ event.preventDefault()
839
+ event.stopImmediatePropagation()
840
+ report('pick', ladder[index], ladder, index)
841
+ }, true)
842
+
843
+ // Arrow keys climb the same ladder the hover box is promising. Escape dismisses
844
+ // the panel's current comment layer; both belong to the frame because that is
845
+ // where the keyboard focus is once the reader points into the page.
846
+ doc.addEventListener('keydown', function (event) {
847
+ if (!armed) return
848
+ if (event.key === 'Escape') {
849
+ event.preventDefault()
850
+ event.stopImmediatePropagation()
851
+ latest = null
852
+ lastHover = ''
853
+ send({ figmark: MARKER, kind: 'cancel' })
854
+ return
855
+ }
856
+ if (!latest) return
857
+ if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
858
+ event.preventDefault()
859
+ event.stopImmediatePropagation()
860
+ depth = Math.max(0, Math.min(latest.ladder.length - 1, depth + (event.key === 'ArrowUp' ? 1 : -1)))
861
+ latest.index = depth
862
+ report('hover', latest.ladder[depth], latest.ladder, depth)
863
+ return
864
+ }
865
+ }, true)
866
+
867
+ window.addEventListener('scroll', sendScroll, true)
868
+ window.addEventListener('resize', sendScroll)
869
+
870
+ function capture(requestId) {
871
+ var root = doc.documentElement
872
+ var documentWidth = Math.max(1, Math.ceil(root.scrollWidth || root.clientWidth || window.innerWidth || 1))
873
+ var documentHeight = Math.max(1, Math.ceil(root.scrollHeight || root.clientHeight || window.innerHeight || 1))
874
+ var bounds = []
875
+ for (var i = 0; i < watched.length; i += 1) bounds.push(measure(watched[i]))
876
+ var surface = visibleSurface()
877
+ // A deck keeps every slide in the same document. Cloning all of them to PNG is
878
+ // both slow and wrong for a screenshot of the page in front: capture the surface
879
+ // subtree when one exists and let the panel place it at its document offset.
880
+ var rect = surface ? surface.getBoundingClientRect() : null
881
+ var scroll = scrollOf()
882
+ var width = rect ? Math.max(1, Math.ceil(rect.width)) : documentWidth
883
+ var height = rect ? Math.max(1, Math.ceil(rect.height)) : documentHeight
884
+ var imageRect = rect
885
+ ? [rect.left + scroll[0], rect.top + scroll[1], width, height]
886
+ : [0, 0, width, height]
887
+ var target = surface || root
888
+ // The parent composes at no more than 1.5x, so a larger source only costs a DOM
889
+ // clone and PNG encode without adding pixels the delivered picture keeps.
890
+ var scale = Math.min(1.5, 1800 / Math.max(width, height))
891
+ var screenshot = window.modernScreenshot
892
+ if (!screenshot || typeof screenshot.domToDataUrl !== 'function') {
893
+ send({
894
+ figmark: MARKER,
895
+ kind: 'capture-result',
896
+ requestId: requestId,
897
+ status: 'failed',
898
+ reason: 'the screenshot renderer is unavailable'
899
+ })
900
+ return
901
+ }
902
+ screenshot.domToDataUrl(target, {
903
+ width: width,
904
+ height: height,
905
+ scale: scale,
906
+ backgroundColor: '#ffffff',
907
+ maximumCanvasSize: 4096,
908
+ timeout: 12000,
909
+ filter: function (node) {
910
+ if (!node || node.nodeType !== 1) return true
911
+ return !node.hasAttribute('data-dsh-figmark-style')
912
+ && !node.hasAttribute('data-da-spot')
913
+ }
914
+ }).then(function (dataUrl) {
915
+ if (typeof dataUrl !== 'string' || dataUrl.length > ${String(MAX_LIVE_CAPTURE_DATA_URL)}) {
916
+ send({
917
+ figmark: MARKER,
918
+ kind: 'capture-result',
919
+ requestId: requestId,
920
+ status: 'failed',
921
+ reason: 'the screenshot is too large'
922
+ })
923
+ return
924
+ }
925
+ send({
926
+ figmark: MARKER,
927
+ kind: 'capture-result',
928
+ requestId: requestId,
929
+ status: 'ok',
930
+ dataUrl: dataUrl,
931
+ document: [documentWidth, documentHeight],
932
+ imageRect: imageRect,
933
+ viewport: [window.innerWidth || width, window.innerHeight || height],
934
+ scroll: scroll,
935
+ scale: scale,
936
+ surface: surface ? pathOf(surface) : null,
937
+ bounds: bounds
938
+ })
939
+ }, function (error) {
940
+ var reason = error && error.message ? String(error.message) : 'the page could not be captured'
941
+ send({
942
+ figmark: MARKER,
943
+ kind: 'capture-result',
944
+ requestId: requestId,
945
+ status: 'failed',
946
+ reason: reason.slice(0, 280)
947
+ })
948
+ })
949
+ }
950
+
951
+ window.addEventListener('message', function (event) {
952
+ var data = event.data
953
+ if (!data || typeof data !== 'object' || data.figmark !== MARKER) return
954
+ if (data.kind === 'mode') {
955
+ armed = !!data.armed
956
+ doc.documentElement.setAttribute('data-dsh-figmark-armed', armed ? 'true' : 'false')
957
+ if (!armed) {
958
+ latest = null
959
+ latestKey = ''
960
+ depth = 0
961
+ lastHover = ''
962
+ }
963
+ scheduleSurface()
964
+ return
965
+ }
966
+ if (data.kind === 'depth' && armed && latest && (data.delta === 1 || data.delta === -1)) {
967
+ event.preventDefault()
968
+ event.stopImmediatePropagation()
969
+ depth = Math.max(0, Math.min(latest.ladder.length - 1, latest.index + data.delta))
970
+ latest.index = depth
971
+ report('hover', latest.ladder[depth], latest.ladder, depth)
972
+ return
973
+ }
974
+ if (data.kind === 'watch' && Array.isArray(data.selectors)) {
975
+ watched = []
976
+ for (var i = 0; i < data.selectors.length && watched.length < 256; i += 1) {
977
+ var selector = data.selectors[i]
978
+ if (typeof selector === 'string' && selector.length > 0 && selector.length <= 2048
979
+ && watched.indexOf(selector) < 0) watched.push(selector)
980
+ }
981
+ lastBounds = ''
982
+ sendBounds(true)
983
+ return
984
+ }
985
+ if (data.kind === 'reveal' && typeof data.requestId === 'string' && data.requestId.length <= 128) {
986
+ var requestedSelector = typeof data.selector === 'string'
987
+ && data.selector.length > 0 && data.selector.length <= 2048 ? data.selector : null
988
+ var requestedSurface = typeof data.surface === 'string'
989
+ && data.surface.length > 0 && data.surface.length <= 2048 ? data.surface : null
990
+ var requestedBox = Array.isArray(data.bbox) && data.bbox.length === 4 ? data.bbox : [0, 0, 0, 0]
991
+ reveal(data.requestId, requestedSelector, requestedSurface, requestedBox)
992
+ return
993
+ }
994
+ if (data.kind === 'capture' && typeof data.requestId === 'string'
995
+ && data.requestId.length > 0 && data.requestId.length <= 128) {
996
+ capture(data.requestId)
997
+ return
998
+ }
999
+ if (data.kind === 'release-reveal') {
1000
+ revealSeq += 1
1001
+ restoreHidden()
1002
+ }
1003
+ })
1004
+
1005
+ if (window.MutationObserver) {
1006
+ new window.MutationObserver(function () {
1007
+ scheduleBounds()
1008
+ scheduleSurface()
1009
+ }).observe(doc.documentElement, {
1010
+ subtree: true,
1011
+ childList: true,
1012
+ attributes: true,
1013
+ attributeFilter: ['class', 'style', 'hidden', 'aria-hidden', 'aria-current']
1014
+ })
1015
+ }
1016
+ window.addEventListener('hashchange', scheduleSurface)
1017
+ window.addEventListener('popstate', scheduleSurface)
1018
+ window.setInterval(function () {
1019
+ sendBounds(false)
1020
+ sendSurface(false)
1021
+ }, 250)
1022
+
1023
+ send({ figmark: MARKER, kind: 'ready', scroll: scrollOf() })
1024
+ scheduleSurface()
1025
+ })()
1026
+ `;
1027
+ //#endregion
1028
+ //#region src/host/live-proxy.ts
1029
+ /**
1030
+ * A live page, served from this machine so the panel can frame it.
1031
+ *
1032
+ * The panel cannot simply point a frame at somebody's dev server. A frame is
1033
+ * usually refused outright by the page's own `X-Frame-Options` or CSP, and even
1034
+ * when it loads, a cross-origin frame is opaque: the panel can look at it and
1035
+ * nothing more. Neither is acceptable — the reader has to point at elements in
1036
+ * the page, which takes a script running inside it.
1037
+ *
1038
+ * So the page is fetched by the Host instead. The frame points at
1039
+ * `http://127.0.0.1:<port>`, that origin is the proxy's, and the proxy is a
1040
+ * pass-through with three edits: policies that would forbid framing or the
1041
+ * injected script are dropped (headers and HTML meta tags alike), same-origin
1042
+ * redirects are kept on the proxy origin, and the script tag is put in front of
1043
+ * `</body>` in HTML. Everything else — methods, bodies, assets, the dev server's
1044
+ * own websocket-free HMR client — crosses untouched, which is what keeps a
1045
+ * script-driven page behaving like the page it is.
1046
+ *
1047
+ * Only this machine's own servers are reachable through it. A proxy that
1048
+ * accepted any origin would be an open relay running inside the app, so a
1049
+ * target has to be loopback by name *and* be plain `http:`: the plugin's route
1050
+ * is not the place to invent TLS to a local server.
1051
+ * @module dsh-figmark/host/live-proxy
1052
+ */
1053
+ /** What the frame shows when the page stopped answering. */
1054
+ const DOWN = "the page is not answering";
1055
+ /** What the frame shows when a page is too big to rewrite. */
1056
+ const TOO_BIG = `the page is larger than ${MAX_HTML_BYTES} bytes, so the picker was not injected`;
1057
+ /**
1058
+ * Headers the proxy always drops from an answer.
1059
+ *
1060
+ * The first two would forbid the injected script or the frame itself; the third
1061
+ * is the pre-CSP way of saying the same thing. Nothing else is touched: the
1062
+ * page's own policy on its assets is its business.
1063
+ */
1064
+ const STRIPPED = [
1065
+ "content-security-policy",
1066
+ "content-security-policy-report-only",
1067
+ "x-frame-options"
1068
+ ];
1069
+ /** The host names that mean this machine. */
1070
+ const LOOPBACK = /* @__PURE__ */ new Set([
1071
+ "localhost",
1072
+ "127.0.0.1",
1073
+ "[::1]"
1074
+ ]);
1075
+ /**
1076
+ * Serve one page from this machine.
1077
+ * @param url - the address the reader typed, e.g. `http://localhost:5173`.
1078
+ * @returns the proxy, already listening.
1079
+ * @throws {Error} when the address is not a loopback `http:` page.
1080
+ */
1081
+ async function openLive(url) {
1082
+ if (url.protocol !== "http:") throw new Error(`only a server on this machine can be opened, and ${url.protocol} is not http`);
1083
+ if (!LOOPBACK.has(url.hostname)) throw new Error(`only a server on this machine can be opened, and ${url.hostname} is not one of its loopback names`);
1084
+ const target = new URL(url.href);
1085
+ const sockets = /* @__PURE__ */ new Set();
1086
+ let proxyOrigin = "";
1087
+ const server = createServer((req, res) => {
1088
+ serve(target, proxyOrigin, req, res);
1089
+ });
1090
+ server.on("upgrade", (req, socket, head) => {
1091
+ upgrade(target, req, socket, head, sockets);
1092
+ });
1093
+ server.on("connection", (socket) => {
1094
+ sockets.add(socket);
1095
+ socket.on("close", () => {
1096
+ sockets.delete(socket);
1097
+ });
1098
+ });
1099
+ await listen(server);
1100
+ const { port } = server.address();
1101
+ proxyOrigin = `http://127.0.0.1:${port}`;
1102
+ let closed = false;
1103
+ return {
1104
+ url: `${proxyOrigin}${target.pathname}${target.search}`,
1105
+ close() {
1106
+ if (closed) return;
1107
+ closed = true;
1108
+ for (const socket of sockets) socket.destroy();
1109
+ sockets.clear();
1110
+ server.close();
1111
+ }
1112
+ };
242
1113
  }
243
- /** Escape one literal so it matches itself inside a regular expression. */
244
- function escapeForRegExp(value) {
245
- return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
1114
+ /**
1115
+ * Hand one upgrade request to the target, socket to socket.
1116
+ * @param target - the page being served.
1117
+ * @param req - the upgrade request from the frame.
1118
+ * @param socket - the frame's own socket.
1119
+ * @param head - bytes the client already sent past the headers.
1120
+ * @param sockets - every socket this proxy holds, so closing it closes these too.
1121
+ */
1122
+ function upgrade(target, req, socket, head, sockets) {
1123
+ const upstream = connect(target.port === "" ? 80 : Number(target.port), target.hostname, () => {
1124
+ const lines = [`${req.method} ${req.url} HTTP/1.1`];
1125
+ for (let i = 0; i < req.rawHeaders.length; i += 2) {
1126
+ const name = req.rawHeaders[i];
1127
+ if (name.toLowerCase() === "host") continue;
1128
+ lines.push(`${name}: ${req.rawHeaders[i + 1]}`);
1129
+ }
1130
+ lines.push(`host: ${target.host}`);
1131
+ upstream.write(`${lines.join("\r\n")}\r\n\r\n`);
1132
+ if (head.length > 0) upstream.write(head);
1133
+ upstream.pipe(socket);
1134
+ socket.pipe(upstream);
1135
+ });
1136
+ sockets.add(upstream);
1137
+ upstream.on("close", () => {
1138
+ sockets.delete(upstream);
1139
+ });
1140
+ upstream.on("error", () => {
1141
+ socket.destroy();
1142
+ });
1143
+ socket.on("error", () => {
1144
+ upstream.destroy();
1145
+ });
246
1146
  }
247
1147
  /**
248
- * Whether a stored document is the annotation of this very figure.
1148
+ * Bind a server to a free port on the loopback interface.
1149
+ * @param server - the server to bind.
1150
+ */
1151
+ async function listen(server) {
1152
+ await new Promise((resolve, reject) => {
1153
+ server.once("error", reject);
1154
+ server.listen(0, "127.0.0.1", () => {
1155
+ resolve();
1156
+ });
1157
+ });
1158
+ }
1159
+ /**
1160
+ * Answer one request on the proxy.
1161
+ * @param target - the page being served.
1162
+ * @param proxyOrigin - this proxy's own origin, used for same-origin redirects.
1163
+ * @param req - the request from the frame.
1164
+ * @param res - the answer to it.
1165
+ */
1166
+ function serve(target, proxyOrigin, req, res) {
1167
+ if (req.method === "GET" && req.url === "/__dsh_figmark_picker.js") {
1168
+ sendPicker(res);
1169
+ return;
1170
+ }
1171
+ forward(target, proxyOrigin, req, res);
1172
+ }
1173
+ /**
1174
+ * Answer the request for the picker itself.
1175
+ * @param res - the answer to the frame.
1176
+ */
1177
+ function sendPicker(res) {
1178
+ res.writeHead(200, {
1179
+ "content-type": "application/javascript; charset=utf-8",
1180
+ "cache-control": "no-store",
1181
+ "content-length": Buffer.byteLength(PICKER_SCRIPT)
1182
+ });
1183
+ res.end(PICKER_SCRIPT);
1184
+ }
1185
+ /**
1186
+ * Ask the target for what the frame asked for, and hand the answer back.
249
1187
  *
250
- * A sidecar sits beside its figure and both names derive from the figure path, so
251
- * the file name — extension included, and case-insensitive because one file may be
252
- * spelled `FIG1.SVG` — is the whole identity. The check matters most for the legacy
253
- * name, which two figures sharing a base name also share.
1188
+ * A refused connection, a reset and a name that does not resolve all arrive on
1189
+ * the request's `error` event, which is the only place they can be turned into
1190
+ * an answer: without it, the frame would sit on a connection that never starts.
1191
+ * @param target - the page being served.
1192
+ * @param proxyOrigin - this proxy's own origin.
1193
+ * @param req - the request from the frame.
1194
+ * @param res - the answer to it.
1195
+ */
1196
+ function forward(target, proxyOrigin, req, res) {
1197
+ const headers = { ...req.headers };
1198
+ headers["host"] = target.host;
1199
+ delete headers["accept-encoding"];
1200
+ const upstream = request(target, {
1201
+ method: req.method,
1202
+ path: req.url,
1203
+ headers
1204
+ });
1205
+ upstream.on("response", (answer) => {
1206
+ returnAnswer(target, proxyOrigin, res, answer);
1207
+ });
1208
+ upstream.on("error", () => {
1209
+ fail(res, DOWN);
1210
+ });
1211
+ req.on("error", () => {
1212
+ upstream.destroy();
1213
+ });
1214
+ req.pipe(upstream);
1215
+ }
1216
+ /**
1217
+ * Hand one answer from the target back to the frame.
1218
+ * @param target - the page being served.
1219
+ * @param proxyOrigin - this proxy's own origin.
1220
+ * @param res - the answer to the frame.
1221
+ * @param answer - the target's answer.
1222
+ */
1223
+ function returnAnswer(target, proxyOrigin, res, answer) {
1224
+ if (isHtml(answer.headers)) {
1225
+ holdHtml(target, proxyOrigin, res, answer);
1226
+ return;
1227
+ }
1228
+ res.writeHead(statusOf(answer), withoutStripped(answer.headers, target, proxyOrigin));
1229
+ answer.on("error", () => {
1230
+ res.destroy();
1231
+ });
1232
+ answer.pipe(res);
1233
+ }
1234
+ /**
1235
+ * Buffer an HTML page, put the picker tag in it, and send it on.
1236
+ * @param target - the page being served.
1237
+ * @param proxyOrigin - this proxy's own origin.
1238
+ * @param res - the answer to the frame.
1239
+ * @param answer - the target's answer.
1240
+ */
1241
+ function holdHtml(target, proxyOrigin, res, answer) {
1242
+ const chunks = [];
1243
+ let size = 0;
1244
+ answer.on("data", (chunk) => {
1245
+ size += chunk.length;
1246
+ if (size > 8388608) {
1247
+ answer.destroy();
1248
+ fail(res, TOO_BIG);
1249
+ return;
1250
+ }
1251
+ chunks.push(chunk);
1252
+ });
1253
+ answer.on("error", () => {
1254
+ res.destroy();
1255
+ });
1256
+ answer.on("end", () => {
1257
+ const body = injectPicker(stripCspMeta(Buffer.concat(chunks).toString("utf8")), PICKER_TAG);
1258
+ const headers = withoutStripped(answer.headers, target, proxyOrigin);
1259
+ delete headers["content-length"];
1260
+ delete headers["transfer-encoding"];
1261
+ headers["content-length"] = Buffer.byteLength(body);
1262
+ res.writeHead(statusOf(answer), headers);
1263
+ res.end(body);
1264
+ });
1265
+ }
1266
+ /**
1267
+ * Whether an answer is a page the picker has to be injected into.
1268
+ * @param headers - the target's headers.
1269
+ * @returns true for an HTML body, whatever else the type says.
1270
+ */
1271
+ function isHtml(headers) {
1272
+ const type = headers["content-type"];
1273
+ return typeof type === "string" && type.toLowerCase().startsWith("text/html");
1274
+ }
1275
+ /**
1276
+ * Copy headers, dropping the ones that would keep the frame from loading the page.
1277
+ * @param source - the target's headers.
1278
+ * @param target - the page being served.
1279
+ * @param proxyOrigin - this proxy's own origin.
1280
+ * @returns the headers to send to the frame.
1281
+ */
1282
+ function withoutStripped(source, target, proxyOrigin) {
1283
+ const headers = { ...source };
1284
+ for (const name of STRIPPED) delete headers[name];
1285
+ const location = source.location;
1286
+ if (location !== void 0) try {
1287
+ const redirect = new URL(location, target);
1288
+ if (redirect.origin === target.origin) headers["location"] = `${proxyOrigin}${redirect.pathname}${redirect.search}${redirect.hash}`;
1289
+ } catch {}
1290
+ return headers;
1291
+ }
1292
+ /**
1293
+ * The status code of an answer.
254
1294
  *
255
- * @param document - stored document to test.
256
- * @param figurePath - absolute path of the figure being opened.
257
- * @returns true when the document names this figure.
1295
+ * The `response` event only fires once a status line has been parsed, so the code
1296
+ * is always there; the type is optional because a message can be built by hand.
1297
+ * @param answer - the target's answer.
1298
+ * @returns its status code.
258
1299
  */
259
- function annotationTargetsFigure(document, figurePath) {
260
- return basename(document.figure.path).toLowerCase() === basename(figurePath).toLowerCase();
1300
+ function statusOf(answer) {
1301
+ return answer.statusCode;
1302
+ }
1303
+ /**
1304
+ * Answer the frame with a sentence instead of a page.
1305
+ * @param res - the answer to the frame.
1306
+ * @param message - the sentence to show.
1307
+ */
1308
+ function fail(res, message) {
1309
+ /* v8 ignore next 8 -- both callers answer before they have written a status line;
1310
+ the branch stays because a truncated answer is the only honest thing to send if
1311
+ that ever stops being true. */
1312
+ if (res.headersSent) {
1313
+ res.destroy();
1314
+ return;
1315
+ }
1316
+ res.writeHead(502, {
1317
+ "content-type": "text/plain; charset=utf-8",
1318
+ "cache-control": "no-store",
1319
+ "content-length": Buffer.byteLength(message)
1320
+ });
1321
+ res.end(message);
261
1322
  }
262
1323
  //#endregion
263
1324
  //#region src/shared/annotation.ts
1325
+ /**
1326
+ * Longest note one mark may carry, in characters.
1327
+ *
1328
+ * Lives in `shared` because both halves need the same number for different
1329
+ * reasons: the browser stops the reader from typing past it, so the field cannot
1330
+ * grow into a sidecar the Host will refuse; the Host refuses one anyway, so a
1331
+ * client that forgot cannot write a file the next open cannot read. A single
1332
+ * number in one place is what keeps those two from drifting apart.
1333
+ */
1334
+ const MAX_DETAIL_CHARS = 4e3;
1335
+ /** Longest overall note a document may carry, in characters. */
1336
+ const MAX_SUMMARY_CHARS = 4e3;
264
1337
  /** Thinnest stroke a mark can be drawn at, as a multiple of the default width. */
265
1338
  const WEIGHT_MIN = .5;
266
1339
  /**
@@ -272,36 +1345,332 @@ const WEIGHT_MIN = .5;
272
1345
  function clampWeight(value) {
273
1346
  return Math.min(8, Math.max(WEIGHT_MIN, value));
274
1347
  }
275
- //#endregion
276
- //#region src/host/store.ts
277
1348
  /**
278
- * Reading and writing one figure's annotation sidecar.
1349
+ * The kinds in the order the rail hands them out, which is the order the list reads
1350
+ * in.
279
1351
  *
280
- * The marks file is JSON owned by this plugin; the review artifact is the same
281
- * marks applied to the figure (a PNG for a single-surface figure, a copy of the
282
- * document carrying native PDF annotations for one that has pages). Both are
283
- * written through a temporary file and a rename, so a reader never observes a
284
- * half-written document.
285
- * @module dsh-figmark/host/store
1352
+ * The list is gathered by kind rather than left in the order the marks were drawn,
1353
+ * because its numbers are per kind: drawn in an evening of circling and dropping
1354
+ * pins, a list of six read 1, 2, 1, 1, 2, 3 — the count kept starting over wherever
1355
+ * the hand happened to change tool. Gathered, the same six read 1, 2, 3, 1, 1, 2,
1356
+ * and each run of numbers belongs to one shape.
286
1357
  */
287
- /** Maximum accepted sidecar size; a document larger than this is not one we wrote. */
288
- const MAX_ANNOTATION_BYTES = 4 * 1024 * 1024;
289
- /** Maximum accepted flattened review artifact. */
290
- const MAX_REVIEW_BYTES = 24 * 1024 * 1024;
291
- /** Longest element selector one anchor may carry. */
292
- const MAX_SELECTOR_CHARS = 400;
293
- /** Kinds a mark may declare. */
294
- const MARK_KINDS = [
1358
+ const MARK_KIND_ORDER = [
295
1359
  "arrow",
296
1360
  "rect",
297
1361
  "ellipse",
298
1362
  "pen",
299
- "point"
1363
+ "point",
1364
+ "element"
300
1365
  ];
301
- /** Content hash of one file. */
302
- async function fileDigest(path) {
303
- const bytes = await readFile(path);
304
- return createHash("sha256").update(bytes).digest("hex");
1366
+ //#endregion
1367
+ //#region src/host/route-http.ts
1368
+ /**
1369
+ * Largest accepted request body.
1370
+ *
1371
+ * Twice the review cap: the artifact travels base64-encoded inside a JSON
1372
+ * envelope, and base64 costs four characters for every three bytes. Anything past
1373
+ * this is refused before it is buffered, which is what keeps a hostile body from
1374
+ * being an allocation.
1375
+ */
1376
+ const MAX_BODY_BYTES$1 = 24 * 1024 * 1024 * 2;
1377
+ /**
1378
+ * Whether a request carries the plugin guard and a same-origin posture.
1379
+ *
1380
+ * A browser that does not send `Sec-Fetch-Site` at all cannot be origin-checked,
1381
+ * and the header alone is still a preflight requirement for any cross-origin
1382
+ * attempt — so an absent value passes and a present one must be same-origin.
1383
+ * @param req - incoming request.
1384
+ * @returns true when the request may proceed.
1385
+ */
1386
+ function guarded(req) {
1387
+ if (req.headers["x-dsh-figmark"] === void 0) return false;
1388
+ const site = req.headers["sec-fetch-site"];
1389
+ if (typeof site !== "string") return true;
1390
+ return site === "same-origin" || site === "none";
1391
+ }
1392
+ /**
1393
+ * Write one JSON response.
1394
+ * @param res - response to own.
1395
+ * @param status - HTTP status code.
1396
+ * @param body - JSON-serializable payload.
1397
+ */
1398
+ function sendJson(res, status, body) {
1399
+ const text = JSON.stringify(body);
1400
+ res.writeHead(status, {
1401
+ "content-type": "application/json; charset=utf-8",
1402
+ "cache-control": "no-store"
1403
+ });
1404
+ res.end(text);
1405
+ }
1406
+ /**
1407
+ * Write one refusal.
1408
+ * @param res - response to own.
1409
+ * @param status - HTTP status code.
1410
+ * @param error - what went wrong, in the Host's own words.
1411
+ */
1412
+ function sendFailure(res, status, error) {
1413
+ sendJson(res, status, {
1414
+ ok: false,
1415
+ error
1416
+ });
1417
+ }
1418
+ /**
1419
+ * Read a request body with a byte cap.
1420
+ * @param req - incoming request.
1421
+ * @returns the body text.
1422
+ * @throws {Error} when the body exceeds the cap.
1423
+ */
1424
+ async function readBody(req) {
1425
+ const chunks = [];
1426
+ let total = 0;
1427
+ for await (const chunk of req) {
1428
+ const buffer = chunk;
1429
+ total += buffer.length;
1430
+ if (total > MAX_BODY_BYTES$1) throw new Error(`request body exceeds ${MAX_BODY_BYTES$1} bytes`);
1431
+ chunks.push(buffer);
1432
+ }
1433
+ return Buffer.concat(chunks).toString("utf8");
1434
+ }
1435
+ //#endregion
1436
+ //#region src/host/review-payload.ts
1437
+ /** First bytes of every PDF file, used to tell one payload from another. */
1438
+ const PDF_HEADER = "%PDF-";
1439
+ /**
1440
+ * Read the one flattened review artifact a save may carry.
1441
+ *
1442
+ * The kind is taken from the payload, and the store refuses a pair that
1443
+ * contradicts the figure's own suffix — so a caller cannot name PNG bytes as the
1444
+ * review of a PDF.
1445
+ *
1446
+ * @param body - parsed request body.
1447
+ * @returns the artifact, or undefined when the save carried none.
1448
+ * @throws {Error} when both payloads are present, or the PDF bytes are not a PDF.
1449
+ */
1450
+ function readReview(body) {
1451
+ const image = typeof body["reviewImage"] === "string" ? body["reviewImage"] : "";
1452
+ const pdf = typeof body["annotatedPdf"] === "string" ? body["annotatedPdf"] : "";
1453
+ if (image !== "" && pdf !== "") throw new Error("a save carries one review artifact, not both");
1454
+ if (pdf !== "") {
1455
+ const bytes = new Uint8Array(Buffer.from(pdf, "base64"));
1456
+ if (!Buffer.from(bytes.subarray(0, 5)).toString("latin1").startsWith(PDF_HEADER)) throw new Error("the annotated document is not a PDF");
1457
+ return {
1458
+ kind: "pdf",
1459
+ bytes
1460
+ };
1461
+ }
1462
+ if (image !== "") return {
1463
+ kind: "image",
1464
+ bytes: new Uint8Array(Buffer.from(image, "base64"))
1465
+ };
1466
+ }
1467
+ //#endregion
1468
+ //#region src/host/service-discovery.ts
1469
+ /**
1470
+ * Find local HTTP services without exposing an open network relay.
1471
+ *
1472
+ * The scan is deliberately bounded: loopback only, a short list of common ports,
1473
+ * ports named by the current workspace's package scripts, and recently used URLs.
1474
+ * It never scans 1-65535 and never follows a candidate to another host.
1475
+ * @module dsh-figmark/host/service-discovery
1476
+ */
1477
+ /** Ports common development servers use when the package does not say. */
1478
+ const COMMON_PORTS = [
1479
+ 3e3,
1480
+ 3001,
1481
+ 4173,
1482
+ 4321,
1483
+ 5173,
1484
+ 5174,
1485
+ 8e3,
1486
+ 8080,
1487
+ 8888,
1488
+ 8898
1489
+ ];
1490
+ /** Most pages one scan reads at once. */
1491
+ const CONCURRENCY = 8;
1492
+ /** Most candidates one scan will consider. */
1493
+ const MAX_CANDIDATES = 32;
1494
+ /** Longest title retained from a page. */
1495
+ const MAX_TITLE = 80;
1496
+ /** Most body bytes one probe will read before closing the response. */
1497
+ const MAX_BODY_BYTES = 64 * 1024;
1498
+ /** Absolute lifetime of one probe, including connect, response, and body. */
1499
+ const PROBE_TIMEOUT_MS = 450;
1500
+ /** Whether a URL is plain HTTP on this machine. */
1501
+ function loopback(url) {
1502
+ return url.protocol === "http:" && (url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]");
1503
+ }
1504
+ /** A normalized candidate URL, or undefined when it is not a loopback HTTP page. */
1505
+ function candidateOf(value, source) {
1506
+ try {
1507
+ const url = new URL(value);
1508
+ return loopback(url) ? {
1509
+ url: url.href,
1510
+ source
1511
+ } : void 0;
1512
+ } catch {
1513
+ return;
1514
+ }
1515
+ }
1516
+ /** Ports explicitly named by the workspace's package scripts. */
1517
+ async function workspacePorts(cwd) {
1518
+ if (cwd === void 0) return [];
1519
+ try {
1520
+ const raw = JSON.parse(await readFile(join(cwd, "package.json"), "utf8"));
1521
+ return [...Object.values(raw.scripts ?? {}).filter((value) => typeof value === "string").join(" ").matchAll(/(?:--port(?:=|\s+)|PORT=)([1-9][0-9]{1,4})/gu)].map((match) => Number(match[1])).filter((port) => Number.isInteger(port) && port > 0 && port < 65536);
1522
+ } catch {
1523
+ return [];
1524
+ }
1525
+ }
1526
+ /** Extract a short title from the beginning of an HTML answer. */
1527
+ function titleOf(html) {
1528
+ const match = /<title\b[^>]*>([\s\S]*?)<\/title>/iu.exec(html);
1529
+ if (match === null) return void 0;
1530
+ const title = match[0].replace(/^<title\b[^>]*>/iu, "").replace(/<\/title>$/iu, "").replace(/<[^>]+>/gu, " ").replace(/&amp;/gu, "&").replace(/&lt;/gu, "<").replace(/&gt;/gu, ">").replace(/&quot;/gu, "\"").replace(/&#39;/gu, "'").replace(/\s+/gu, " ").trim();
1531
+ return title === "" ? void 0 : title.slice(0, MAX_TITLE);
1532
+ }
1533
+ /** Ask one address for its title; undefined means nothing answered there. */
1534
+ function probe(candidate) {
1535
+ return new Promise((resolve) => {
1536
+ const url = new URL(candidate.url);
1537
+ let settled = false;
1538
+ let deadline;
1539
+ const finish = (answer) => {
1540
+ if (settled) return;
1541
+ settled = true;
1542
+ clearTimeout(deadline);
1543
+ resolve(answer);
1544
+ };
1545
+ const req = request({
1546
+ protocol: "http:",
1547
+ hostname: url.hostname,
1548
+ port: url.port === "" ? 80 : Number(url.port),
1549
+ path: `${url.pathname}${url.search}`,
1550
+ method: "GET",
1551
+ headers: { accept: "text/html,application/xhtml+xml" }
1552
+ }, (res) => {
1553
+ const chunks = [];
1554
+ let bytes = 0;
1555
+ const finishResponse = () => {
1556
+ /* v8 ignore next -- Node populates statusCode before emitting `response`. */
1557
+ const status = res.statusCode ?? 0;
1558
+ if (status < 200 || status >= 500) {
1559
+ finish(void 0);
1560
+ return;
1561
+ }
1562
+ const title = titleOf(Buffer.concat(chunks).toString("utf8"));
1563
+ finish({
1564
+ url: candidate.url,
1565
+ source: candidate.source,
1566
+ ...title === void 0 ? {} : { title }
1567
+ });
1568
+ };
1569
+ res.on("data", (chunk) => {
1570
+ const kept = chunk.subarray(0, MAX_BODY_BYTES - bytes);
1571
+ chunks.push(kept);
1572
+ bytes += kept.byteLength;
1573
+ if (bytes < MAX_BODY_BYTES) return;
1574
+ finishResponse();
1575
+ res.destroy();
1576
+ });
1577
+ res.on("end", finishResponse);
1578
+ res.on("error", () => {
1579
+ finish(void 0);
1580
+ });
1581
+ });
1582
+ deadline = setTimeout(() => {
1583
+ finish(void 0);
1584
+ req.destroy();
1585
+ }, PROBE_TIMEOUT_MS);
1586
+ req.on("error", () => {
1587
+ finish(void 0);
1588
+ });
1589
+ req.end();
1590
+ });
1591
+ }
1592
+ /** Probe candidates with a fixed number of workers. */
1593
+ async function probeAll(candidates) {
1594
+ const answers = [];
1595
+ let next = 0;
1596
+ const work = async () => {
1597
+ for (;;) {
1598
+ const index = next;
1599
+ next += 1;
1600
+ const candidate = candidates[index];
1601
+ if (candidate === void 0) return;
1602
+ const answer = await probe(candidate);
1603
+ if (answer !== void 0) answers.push(answer);
1604
+ }
1605
+ };
1606
+ await Promise.all(Array.from({ length: Math.min(CONCURRENCY, candidates.length) }, work));
1607
+ return answers;
1608
+ }
1609
+ /**
1610
+ * Discover reachable local HTTP services.
1611
+ * @param request - the browser's session and recent addresses.
1612
+ * @param sessionCwd - resolves that session's workspace.
1613
+ * @returns the reachable services, de-duplicated by URL.
1614
+ */
1615
+ async function discoverServices(request, sessionCwd) {
1616
+ const ports = await workspacePorts(await sessionCwd(request.sessionId));
1617
+ const candidates = [];
1618
+ const seen = /* @__PURE__ */ new Set();
1619
+ const add = (candidate) => {
1620
+ if (candidate === void 0 || seen.has(candidate.url) || candidates.length >= MAX_CANDIDATES) return;
1621
+ seen.add(candidate.url);
1622
+ candidates.push(candidate);
1623
+ };
1624
+ for (const recent of request.recent ?? []) add(candidateOf(recent, "recent"));
1625
+ for (const port of ports) add(candidateOf(`http://127.0.0.1:${String(port)}/`, "workspace"));
1626
+ for (const port of COMMON_PORTS) add(candidateOf(`http://127.0.0.1:${String(port)}/`, "common"));
1627
+ const services = await probeAll(candidates);
1628
+ const rank = {
1629
+ recent: 0,
1630
+ workspace: 1,
1631
+ common: 2
1632
+ };
1633
+ return services.sort((left, right) => rank[left.source] - rank[right.source] || new URL(left.url).port.localeCompare(new URL(right.url).port, void 0, { numeric: true }));
1634
+ }
1635
+ //#endregion
1636
+ //#region src/host/store-schema.ts
1637
+ /**
1638
+ * The wire schema of one annotation document.
1639
+ *
1640
+ * Everything that crosses the HTTP boundary is JSON somebody else wrote, so it is
1641
+ * validated field by field before any of it reaches disk. This module owns that
1642
+ * one job: turning unknown JSON into {@link AnnotationDocument} or refusing it
1643
+ * with a message that names the field. It deliberately knows nothing about files
1644
+ * — the read and write modules call it, never the other way round.
1645
+ *
1646
+ * The two figure shapes are held apart here rather than tolerated downstream: a
1647
+ * document is either one surface (width and height) or a paged one (pageCount),
1648
+ * and a mark may name a page only when its figure has pages. A reader that got
1649
+ * both would have to guess which coordinate space the marks live in.
1650
+ * @module dsh-figmark/host/store-schema
1651
+ */
1652
+ /** Longest element selector one anchor may carry. */
1653
+ const MAX_SELECTOR_CHARS = 400;
1654
+ /** Longest class list one anchor may carry. */
1655
+ const MAX_ANCHOR_CLASS_CHARS = 160;
1656
+ /**
1657
+ * A note as it is stored: trimmed, and refused when it is longer than a paragraph.
1658
+ *
1659
+ * The cap is enforced rather than truncated. A writer that silently cut a pasted
1660
+ * paragraph would hand the reader back a different sentence than the one they
1661
+ * wrote, with no way to tell which half survived; refusing says so and keeps the
1662
+ * screen's copy intact. Absent and empty are the same thing — an empty note is no
1663
+ * note, and writing one would make the file say something the reader did not.
1664
+ * @param value - the field as it arrived.
1665
+ * @param what - which field, for the refusal message.
1666
+ * @returns the note, or undefined when there is none.
1667
+ * @throws {Error} when the note is longer than a paragraph.
1668
+ */
1669
+ function readNote(value, what, max) {
1670
+ if (typeof value !== "string") return void 0;
1671
+ if (value.length > max) throw new Error(`the annotation ${what} is longer than ${String(max)} characters`);
1672
+ const trimmed = value.trim();
1673
+ return trimmed === "" ? void 0 : trimmed;
305
1674
  }
306
1675
  /**
307
1676
  * Whether a value is a finite figure coordinate pair.
@@ -334,22 +1703,29 @@ function readAnchor(value) {
334
1703
  tag: raw["tag"],
335
1704
  bbox: raw["bbox"],
336
1705
  ...typeof raw["id"] === "string" ? { id: raw["id"] } : {},
1706
+ ...typeof raw["class"] === "string" ? { class: raw["class"].slice(0, MAX_ANCHOR_CLASS_CHARS) } : {},
337
1707
  ...typeof raw["title"] === "string" ? { title: raw["title"] } : {},
338
1708
  ...typeof raw["text"] === "string" ? { text: raw["text"] } : {},
339
1709
  ...typeof raw["selector"] === "string" ? { selector: raw["selector"].slice(0, MAX_SELECTOR_CHARS) } : {}
340
1710
  };
341
1711
  }
342
1712
  /**
343
- * Read one optional rotation from the wire.
1713
+ * Read one optional stroke weight from the wire.
344
1714
  * @param value - the field as it arrived.
345
- * @returns the angle in degrees, or undefined when absent.
346
- * @throws {Error} when the field is present but is not a usable angle.
1715
+ * @returns the weight, or undefined when absent.
1716
+ * @throws {Error} when the field is present but is not a usable multiple.
347
1717
  */
348
1718
  function readWeight(value) {
349
1719
  if (value === void 0) return void 0;
350
1720
  if (typeof value !== "number" || !Number.isFinite(value)) throw new Error("annotation mark weight must be a finite multiple of the default width");
351
1721
  return clampWeight(value);
352
1722
  }
1723
+ /**
1724
+ * Read one optional rotation from the wire.
1725
+ * @param value - the field as it arrived.
1726
+ * @returns the angle in degrees, or undefined when absent.
1727
+ * @throws {Error} when the field is present but is not a finite number.
1728
+ */
353
1729
  function readRotation(value) {
354
1730
  if (value === void 0) return void 0;
355
1731
  if (typeof value !== "number" || !Number.isFinite(value)) throw new Error("annotation mark rotation must be a finite number of degrees");
@@ -369,6 +1745,19 @@ function readPositiveInteger(raw, field) {
369
1745
  return value;
370
1746
  }
371
1747
  /**
1748
+ * Read one optional positive size from the wire.
1749
+ * @param raw - the figure object as it arrived.
1750
+ * @param field - field name, used in the failure message.
1751
+ * @returns the size, or undefined when the field is absent.
1752
+ * @throws {Error} when the field is present but is not a positive number.
1753
+ */
1754
+ function readSize(raw, field) {
1755
+ const value = raw[field];
1756
+ if (value === void 0) return void 0;
1757
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) throw new Error(`annotation figure ${field} must be a positive number`);
1758
+ return value;
1759
+ }
1760
+ /**
372
1761
  * Validate one mark from the wire.
373
1762
  *
374
1763
  * A mark names the page it was drawn on exactly when its figure is a paged
@@ -385,7 +1774,7 @@ function readMark(value, index, figure) {
385
1774
  if (typeof value !== "object" || value === null) throw new Error(`annotation mark ${index} must be an object`);
386
1775
  const raw = value;
387
1776
  const kind = raw["kind"];
388
- if (typeof kind !== "string" || !MARK_KINDS.includes(kind)) throw new Error(`annotation mark ${index} has unknown kind ${JSON.stringify(kind)}`);
1777
+ if (typeof kind !== "string" || !MARK_KIND_ORDER.includes(kind)) throw new Error(`annotation mark ${index} has unknown kind ${JSON.stringify(kind)}`);
389
1778
  if (typeof raw["id"] !== "string" || raw["id"] === "") throw new Error(`annotation mark ${index} needs an id`);
390
1779
  if (typeof raw["color"] !== "string") throw new Error(`annotation mark ${index} needs a color`);
391
1780
  const points = raw["points"];
@@ -394,15 +1783,18 @@ function readMark(value, index, figure) {
394
1783
  if (page !== void 0 && figure.width !== void 0) throw new Error(`annotation mark ${index} names a page, but its figure is one surface`);
395
1784
  if (page !== void 0 && figure.pageCount !== void 0 && page > figure.pageCount) throw new Error(`annotation mark ${index} is on page ${page} of a ${figure.pageCount}-page document`);
396
1785
  const anchor = readAnchor(raw["anchor"]);
1786
+ const surface = typeof raw["surface"] === "string" ? raw["surface"].slice(0, MAX_SELECTOR_CHARS) : void 0;
397
1787
  const rotation = readRotation(raw["rotation"]);
398
1788
  const weight = readWeight(raw["weight"]);
1789
+ const detail = readNote(raw["detail"], "mark note", MAX_DETAIL_CHARS);
399
1790
  return {
400
1791
  id: raw["id"],
401
1792
  kind,
402
1793
  color: raw["color"],
403
1794
  points,
404
- ...typeof raw["detail"] === "string" ? { detail: raw["detail"] } : {},
1795
+ ...detail === void 0 ? {} : { detail },
405
1796
  ...page === void 0 ? {} : { page },
1797
+ ...surface === void 0 ? {} : { surface },
406
1798
  ...rotation === void 0 ? {} : { rotation },
407
1799
  ...weight === void 0 ? {} : { weight },
408
1800
  ...anchor === void 0 ? {} : { anchor }
@@ -435,6 +1827,7 @@ function readAnnotationDocument(value) {
435
1827
  const marks = raw["marks"];
436
1828
  if (!Array.isArray(marks)) throw new Error("annotation document needs a marks array");
437
1829
  const updatedAt = typeof raw["updatedAt"] === "string" ? raw["updatedAt"] : (/* @__PURE__ */ new Date()).toISOString();
1830
+ const summary = readNote(raw["summary"], "document note", MAX_SUMMARY_CHARS);
438
1831
  return {
439
1832
  version: 1,
440
1833
  figure: {
@@ -454,21 +1847,141 @@ function readAnnotationDocument(value) {
454
1847
  ...width === void 0 ? {} : { width },
455
1848
  ...pageCount === void 0 ? {} : { pageCount }
456
1849
  })),
457
- ...typeof raw["summary"] === "string" && raw["summary"] !== "" ? { summary: raw["summary"] } : {}
1850
+ ...summary === void 0 ? {} : { summary }
1851
+ };
1852
+ }
1853
+ //#endregion
1854
+ //#region src/host/sidecar.ts
1855
+ /**
1856
+ * Annotation files beside one figure.
1857
+ *
1858
+ * An annotation never modifies the figure: it is written to sibling files
1859
+ * (`<name>.annot.json` for the marks and `<name>.annotated.png` — or
1860
+ * `<name>.annotated.pdf` for a document that has pages — for the flattened
1861
+ * review artifact). One figure is one document, however many pages it has, so a
1862
+ * paged document's marks share a single sidecar and name their page inside it.
1863
+ *
1864
+ * The name keeps the figure's own extension (`fig1.svg` → `fig1.svg.annot.json`):
1865
+ * `fig1.svg` and `fig1.png` in one directory are two different documents, and marks
1866
+ * drawn on one of them mean nothing on the other, so they must never share a
1867
+ * sidecar. Every name is derived from the figure path, so the plugin can never be
1868
+ * asked to write an arbitrary location.
1869
+ * @module dsh-figmark/host/sidecar
1870
+ */
1871
+ /** Suffix appended to a figure's name for the marks file. */
1872
+ const ANNOTATION_SUFFIX = ".annot.json";
1873
+ /** Suffix appended to a figure's name for the flattened review artifact. */
1874
+ const ANNOTATED_SUFFIX = ".annotated";
1875
+ /**
1876
+ * Resolve one figure's sidecar paths.
1877
+ * @param figurePath - absolute path of the annotated figure.
1878
+ * @returns the sidecar paths; both live in the figure's own directory.
1879
+ */
1880
+ function sidecarPaths(figurePath) {
1881
+ const name = basename(figurePath);
1882
+ const directory = dirname(figurePath);
1883
+ const reviewExtension = reviewFileExtension(figurePath);
1884
+ return {
1885
+ annotation: join(directory, `${name}${ANNOTATION_SUFFIX}`),
1886
+ review: join(directory, `${name}${ANNOTATED_SUFFIX}${reviewExtension}`)
458
1887
  };
459
1888
  }
460
1889
  /**
461
- * Read one optional positive size from the wire.
462
- * @param raw - the figure object as it arrived.
463
- * @param field - field name, used in the failure message.
464
- * @returns the size, or undefined when the field is absent.
465
- * @throws {Error} when the field is present but is not a positive number.
1890
+ * Suffix of the flattened review artifact for one figure.
1891
+ *
1892
+ * A paged document is reviewed as a document — the page marks travel as native
1893
+ * PDF annotations in a copy of the original — while a single-surface figure is
1894
+ * reviewed as the picture it is.
1895
+ * @param figurePath - absolute path of the annotated figure.
1896
+ * @returns the file extension, dot included.
1897
+ */
1898
+ function reviewFileExtension(figurePath) {
1899
+ return isPagedFigurePath(figurePath) ? ".pdf" : ".png";
1900
+ }
1901
+ /**
1902
+ * The marks file this plugin derived before the figure's extension became part of
1903
+ * the name (`fig1.svg` → `fig1.annot.json`). Read-only: a file an earlier version
1904
+ * wrote stays readable, so an existing annotation never disappears on upgrade.
1905
+ * @param figurePath - absolute path of the annotated figure.
1906
+ * @returns the legacy marks file path beside the figure.
1907
+ */
1908
+ function legacyAnnotationPath(figurePath) {
1909
+ return join(dirname(figurePath), `${basename(figurePath, extname(figurePath))}${ANNOTATION_SUFFIX}`);
1910
+ }
1911
+ /**
1912
+ * Marks files to read for one figure, most authoritative first: the current name,
1913
+ * then the legacy one. A figure whose name carries no extension derives both from
1914
+ * the same name, so the list is deduplicated.
1915
+ * @param figurePath - absolute path of the annotated figure.
1916
+ * @returns the candidate marks file paths.
1917
+ */
1918
+ function annotationCandidates(figurePath) {
1919
+ const current = sidecarPaths(figurePath).annotation;
1920
+ const legacy = legacyAnnotationPath(figurePath);
1921
+ return current === legacy ? [current] : [current, legacy];
1922
+ }
1923
+ /**
1924
+ * Name pattern of the one-page-per-file sidecars this plugin wrote before a paged
1925
+ * document became a single annotation.
1926
+ *
1927
+ * Both spellings are matched — the extension-bearing name
1928
+ * (`fig1.pdf.p2.annot.json`) and the base-name one an even earlier version wrote
1929
+ * (`fig1.p2.annot.json`) — and the page number is the pattern's first group.
1930
+ *
1931
+ * @param figurePath - absolute path of the annotated figure.
1932
+ * @returns an anchored, case-insensitive pattern over one directory entry's name.
1933
+ */
1934
+ function legacyPagePattern(figurePath) {
1935
+ const extension = extname(figurePath);
1936
+ const alternatives = [.../* @__PURE__ */ new Set([basename(figurePath), basename(figurePath, extension)])].map(escapeForRegExp).join("|");
1937
+ return new RegExp(`^(?:${alternatives})\\.p(\\d+)${escapeForRegExp(ANNOTATION_SUFFIX)}$`, "iu");
1938
+ }
1939
+ /** Escape one literal so it matches itself inside a regular expression. */
1940
+ function escapeForRegExp(value) {
1941
+ return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
1942
+ }
1943
+ /**
1944
+ * Whether a stored document is the annotation of this very figure.
1945
+ *
1946
+ * A sidecar sits beside its figure and both names derive from the figure path, so
1947
+ * the file name — extension included, and case-insensitive because one file may be
1948
+ * spelled `FIG1.SVG` — is the whole identity. The check matters most for the legacy
1949
+ * name, which two figures sharing a base name also share.
1950
+ *
1951
+ * @param document - stored document to test.
1952
+ * @param figurePath - absolute path of the figure being opened.
1953
+ * @returns true when the document names this figure.
466
1954
  */
467
- function readSize(raw, field) {
468
- const value = raw[field];
469
- if (value === void 0) return void 0;
470
- if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) throw new Error(`annotation figure ${field} must be a positive number`);
471
- return value;
1955
+ function annotationTargetsFigure(document, figurePath) {
1956
+ return basename(document.figure.path).toLowerCase() === basename(figurePath).toLowerCase();
1957
+ }
1958
+ //#endregion
1959
+ //#region src/host/store-read.ts
1960
+ /**
1961
+ * Reading one figure's annotation sidecar, including the shapes older versions wrote.
1962
+ *
1963
+ * Three stored shapes have to read back as one document, because a figure
1964
+ * annotated by an earlier version must not lose its marks on upgrade: the current
1965
+ * sidecar name, the legacy base-name sidecar, and the per-page files a paged
1966
+ * document was once split into. All of them are read-only here — a save always
1967
+ * writes the current name — so the upgrade is invisible to the reader.
1968
+ *
1969
+ * Nothing in this module decides where a sidecar lives: the candidate names come
1970
+ * from the sidecar module, which derives every one of them from the figure path.
1971
+ * @module dsh-figmark/host/store-read
1972
+ */
1973
+ /**
1974
+ * Content hash of one file.
1975
+ *
1976
+ * Read whole rather than streamed: the caller compares it against the hash the
1977
+ * browser recorded when the marks were drawn, and a figure large enough for the
1978
+ * difference to matter is not one this plugin renders in a sidebar.
1979
+ * @param path - absolute path of the file.
1980
+ * @returns the SHA-256 digest as lower-case hex.
1981
+ */
1982
+ async function fileDigest(path) {
1983
+ const bytes = await readFile(path);
1984
+ return createHash("sha256").update(bytes).digest("hex");
472
1985
  }
473
1986
  /**
474
1987
  * Read one figure's saved annotation.
@@ -478,15 +1991,27 @@ function readSize(raw, field) {
478
1991
  * that share a base name would otherwise read each other's marks.
479
1992
  *
480
1993
  * @param figurePath - absolute path of the figure.
481
- * @returns the document, or null when none was stored or it is unreadable.
1994
+ * @returns the document and any sidecar that was present but unusable.
482
1995
  */
483
1996
  async function readAnnotation(figurePath) {
1997
+ const unreadable = [];
484
1998
  for (const candidate of annotationCandidates(figurePath)) {
485
- const document = await readSidecar(candidate);
486
- if (document !== null && annotationTargetsFigure(document, figurePath)) return document;
1999
+ const read = await readSidecar(candidate);
2000
+ if (read === "unreadable") unreadable.push(candidate);
2001
+ else if (read !== null && annotationTargetsFigure(read, figurePath)) return {
2002
+ annotation: read,
2003
+ unreadable
2004
+ };
487
2005
  }
488
- if (figureMediaType(figurePath) !== "application/pdf") return null;
489
- return await readLegacyPages(figurePath);
2006
+ if (!isPagedFigurePath(figurePath)) return {
2007
+ annotation: null,
2008
+ unreadable
2009
+ };
2010
+ const merged = await readLegacyPages(figurePath);
2011
+ return {
2012
+ annotation: merged.annotation,
2013
+ unreadable: [...unreadable, ...merged.unreadable]
2014
+ };
490
2015
  }
491
2016
  /**
492
2017
  * Restate the per-page sidecars an earlier version wrote as one document.
@@ -501,7 +2026,7 @@ async function readAnnotation(figurePath) {
501
2026
  * time it saves.
502
2027
  *
503
2028
  * @param figurePath - absolute path of the figure.
504
- * @returns the merged document, or null when no readable page file exists.
2029
+ * @returns the merged document, when any page file was readable.
505
2030
  */
506
2031
  async function readLegacyPages(figurePath) {
507
2032
  const directory = dirname(figurePath);
@@ -510,51 +2035,64 @@ async function readLegacyPages(figurePath) {
510
2035
  try {
511
2036
  entries = await readdir(directory);
512
2037
  } catch {
513
- return null;
2038
+ return {
2039
+ annotation: null,
2040
+ unreadable: []
2041
+ };
514
2042
  }
515
2043
  const pages = entries.map((name) => ({
516
2044
  name,
517
2045
  page: Number(pattern.exec(name)?.[1])
518
2046
  })).filter((entry) => Number.isInteger(entry.page) && entry.page > 0).sort((left, right) => left.page - right.page);
519
2047
  const read = [];
2048
+ const unreadable = [];
520
2049
  for (const entry of pages) {
521
- const document = await readSidecar(join(directory, entry.name));
522
- if (document !== null && annotationTargetsFigure(document, figurePath)) read.push({
2050
+ const path = join(directory, entry.name);
2051
+ const document = await readSidecar(path);
2052
+ if (document === "unreadable") unreadable.push(path);
2053
+ else if (document !== null && annotationTargetsFigure(document, figurePath)) read.push({
523
2054
  page: entry.page,
524
2055
  document
525
2056
  });
526
2057
  }
527
2058
  const first = read[0];
528
- if (first === void 0) return null;
2059
+ if (first === void 0) return {
2060
+ annotation: null,
2061
+ unreadable
2062
+ };
529
2063
  let summary;
530
2064
  for (const entry of read) if (entry.document.summary !== void 0) summary = entry.document.summary;
531
2065
  return {
532
- version: 1,
533
- figure: {
534
- address: first.document.figure.address,
535
- path: first.document.figure.path,
536
- mediaType: first.document.figure.mediaType,
537
- sha256: first.document.figure.sha256,
538
- pageCount: read.reduce((highest, entry) => Math.max(highest, entry.page), first.page)
2066
+ annotation: {
2067
+ version: 1,
2068
+ figure: {
2069
+ address: first.document.figure.address,
2070
+ path: first.document.figure.path,
2071
+ mediaType: first.document.figure.mediaType,
2072
+ sha256: first.document.figure.sha256,
2073
+ pageCount: read.reduce((highest, entry) => Math.max(highest, entry.page), first.page)
2074
+ },
2075
+ createdAt: read.reduce((earliest, entry) => entry.document.createdAt < earliest ? entry.document.createdAt : earliest, first.document.createdAt),
2076
+ updatedAt: read.reduce((latest, entry) => entry.document.updatedAt > latest ? entry.document.updatedAt : latest, first.document.updatedAt),
2077
+ marks: read.flatMap((entry) => entry.document.marks.map((mark) => ({
2078
+ ...mark,
2079
+ page: entry.page
2080
+ }))),
2081
+ ...summary === void 0 ? {} : { summary }
539
2082
  },
540
- createdAt: read.reduce((earliest, entry) => entry.document.createdAt < earliest ? entry.document.createdAt : earliest, first.document.createdAt),
541
- updatedAt: read.reduce((latest, entry) => entry.document.updatedAt > latest ? entry.document.updatedAt : latest, first.document.updatedAt),
542
- marks: read.flatMap((entry) => entry.document.marks.map((mark) => ({
543
- ...mark,
544
- page: entry.page
545
- }))),
546
- ...summary === void 0 ? {} : { summary }
2083
+ unreadable
547
2084
  };
548
2085
  }
549
2086
  /**
550
2087
  * Parse one marks file into this plugin's document shape.
551
2088
  * @param sidecarPath - absolute path of the marks file.
552
- * @returns the document, or null when the file is missing, oversized, or unreadable.
2089
+ * @returns the document, null when there is no readable file, or `'unreadable'`
2090
+ * when the file is there and is not something this plugin will read.
553
2091
  */
554
2092
  async function readSidecar(sidecarPath) {
555
2093
  let text;
556
2094
  try {
557
- if ((await stat(sidecarPath)).size > MAX_ANNOTATION_BYTES) return null;
2095
+ if ((await stat(sidecarPath)).size > 4194304) return "unreadable";
558
2096
  text = await readFile(sidecarPath, "utf8");
559
2097
  } catch {
560
2098
  return null;
@@ -611,6 +2149,45 @@ function bridgeV2Document(raw) {
611
2149
  summary: raw["summary"]
612
2150
  };
613
2151
  }
2152
+ //#endregion
2153
+ //#region src/host/store-write.ts
2154
+ /**
2155
+ * Writing one figure's annotation sidecar, and removing it again.
2156
+ *
2157
+ * The two files are written through a temporary name and a rename so a reader
2158
+ * never observes a half-written document: a preview opening while an autosave
2159
+ * runs reads either the previous document or the new one, never a truncated one.
2160
+ *
2161
+ * Both file names are derived here from the figure path, never passed in, and the
2162
+ * review artifact's kind is checked against the figure's own kind before anything
2163
+ * is touched — a PNG review of a PDF would be a file no reader could open.
2164
+ * @module dsh-figmark/host/store-write
2165
+ */
2166
+ /**
2167
+ * How many scratch files this process has named.
2168
+ *
2169
+ * The clock is not enough on its own: two saves started in the same millisecond
2170
+ * share a pid and a millisecond, took the same scratch name, and one of them renamed
2171
+ * the other's half-written bytes into place — or found nothing to rename and failed a
2172
+ * save that had every right to succeed. A counter inside the process cannot repeat,
2173
+ * and the pid and clock still keep two *processes* apart.
2174
+ */
2175
+ let scratchCount = 0;
2176
+ /**
2177
+ * Replace one file atomically.
2178
+ *
2179
+ * The temporary name carries the pid, a counter and the clock so two saves that
2180
+ * overlap cannot pick the same scratch file; the rename is the moment the new
2181
+ * content becomes observable.
2182
+ * @param path - absolute path of the file to replace.
2183
+ * @param content - text or bytes to write.
2184
+ */
2185
+ async function writeAtomically(path, content) {
2186
+ scratchCount += 1;
2187
+ const temporary = `${path}.tmp-${process.pid}-${scratchCount}-${Date.now()}`;
2188
+ await writeFile(temporary, content, typeof content === "string" ? "utf8" : void 0);
2189
+ await rename(temporary, path);
2190
+ }
614
2191
  /**
615
2192
  * Write one figure's annotation, and optionally its flattened review artifact.
616
2193
  * @param figurePath - absolute path of the annotated figure.
@@ -621,18 +2198,14 @@ function bridgeV2Document(raw) {
621
2198
  */
622
2199
  async function writeAnnotation(figurePath, document, review) {
623
2200
  const paths = sidecarPaths(figurePath);
624
- if (review !== void 0 && review.kind !== (reviewFileExtension(figurePath) === ".pdf" ? "pdf" : "image")) throw new Error(`a ${reviewFileExtension(figurePath)} review artifact cannot be written from ${review.kind} bytes`);
2201
+ if (review !== void 0 && review.kind !== (isPagedFigurePath(figurePath) ? "pdf" : "image")) throw new Error(`a ${reviewFileExtension(figurePath)} review artifact cannot be written from ${review.kind} bytes`);
625
2202
  await mkdir(dirname(paths.annotation), { recursive: true });
626
- const temporary = `${paths.annotation}.tmp-${process.pid}-${Date.now()}`;
627
- await writeFile(temporary, `${JSON.stringify(document, null, 2)}\n`, "utf8");
628
- await rename(temporary, paths.annotation);
2203
+ await writeAtomically(paths.annotation, `${JSON.stringify(document, null, 2)}\n`);
629
2204
  if (review === void 0) return {
630
2205
  paths,
631
2206
  wroteReview: false
632
2207
  };
633
- const reviewTemporary = `${paths.review}.tmp-${process.pid}-${Date.now()}`;
634
- await writeFile(reviewTemporary, review.bytes);
635
- await rename(reviewTemporary, paths.review);
2208
+ await writeAtomically(paths.review, review.bytes);
636
2209
  return {
637
2210
  paths,
638
2211
  wroteReview: true
@@ -654,7 +2227,7 @@ async function writeAnnotation(figurePath, document, review) {
654
2227
  async function removeAnnotation(figurePath) {
655
2228
  const paths = sidecarPaths(figurePath);
656
2229
  let removed = false;
657
- for (const path of [paths.annotation, paths.review]) try {
2230
+ for (const path of await allSidecarNames(figurePath)) try {
658
2231
  await rm(path, { force: false });
659
2232
  removed = true;
660
2233
  } catch (error) {
@@ -665,239 +2238,273 @@ async function removeAnnotation(figurePath) {
665
2238
  removed
666
2239
  };
667
2240
  }
668
- //#endregion
669
- //#region src/host/routes.ts
670
2241
  /**
671
- * HTTP routes of the annotator.
2242
+ * Every file this plugin may have written beside one figure, oldest spelling first.
672
2243
  *
673
- * The routes live outside the `/api` channel, so they carry their own guard: a
674
- * required request header (which a cross-origin form or image request cannot
675
- * set without a preflight this plugin never answers) plus an origin check on
676
- * browsers that send `Sec-Fetch-Site`. Writes are limited to the two sidecar
677
- * files derived from a figure that already exists.
678
- * @module dsh-figmark/host/routes
2244
+ * The read path accepts more names than the write path produces: the legacy
2245
+ * base-name sidecar (`fig1.annot.json` beside `fig1.svg`), and for a paged document
2246
+ * the one-file-per-page sidecars an earlier version wrote. Removing only the two
2247
+ * current names left those behind — and because the reader merges them back in, a
2248
+ * cleared figure reopened with its marks on it, which is the bug in finding 1.
2249
+ * Whatever the reader will read has to be what a clear removes.
2250
+ * @param figurePath - absolute path of the figure whose sidecars are removed.
2251
+ * @returns the paths to unlink.
679
2252
  */
680
- /** Largest accepted request body. */
681
- const MAX_BODY_BYTES = MAX_REVIEW_BYTES * 2;
682
- /** First bytes of every PDF file, used to tell one payload from another. */
683
- const PDF_HEADER = "%PDF-";
2253
+ async function allSidecarNames(figurePath) {
2254
+ const paths = sidecarPaths(figurePath);
2255
+ const names = [
2256
+ paths.annotation,
2257
+ paths.review,
2258
+ legacyAnnotationPath(figurePath)
2259
+ ];
2260
+ if (isPagedFigurePath(figurePath)) {
2261
+ const pattern = legacyPagePattern(figurePath);
2262
+ let entries;
2263
+ try {
2264
+ entries = await readdir(dirname(figurePath));
2265
+ } catch {
2266
+ entries = [];
2267
+ }
2268
+ for (const entry of entries) if (pattern.test(entry)) names.push(join(dirname(figurePath), entry));
2269
+ }
2270
+ return [...new Set(names)];
2271
+ }
2272
+ //#endregion
2273
+ //#region src/host/routes.ts
2274
+ /** The route a live page is opened and closed on. */
2275
+ const LIVE_PATH = `${ROUTE_PREFIX}${LIVE_ROUTE}`;
2276
+ /** The route that probes bounded loopback candidates. */
2277
+ const SERVICES_PATH = `${ROUTE_PREFIX}${SERVICE_DISCOVER_ROUTE}`;
684
2278
  /**
685
- * Resolve one request address to an existing figure file.
686
- * @param address - `dsh-resource://file/…` address from the preview tab.
687
- * @param deps - Session lookup used for a relative path.
688
- * @returns the target, or an error message describing why it cannot be annotated.
2279
+ * Resolve the figure one request names, as an answer rather than an exception.
2280
+ * @param address - the address the request carried, empty when it carried none.
2281
+ * @param deps - Session lookup.
2282
+ * @returns the target, or the 404 refusal to send.
689
2283
  */
690
- async function resolveTarget(address, deps) {
691
- const parsed = parseFileAddress(address);
692
- if (parsed === void 0) return {
2284
+ async function targetOf(address, deps) {
2285
+ const resolved = await resolveTarget(address, deps.sessionCwd);
2286
+ return resolved.ok ? {
2287
+ ok: true,
2288
+ body: resolved.target
2289
+ } : {
693
2290
  ok: false,
694
- message: `not a file address: ${address}`
2291
+ status: 404,
2292
+ error: resolved.message
695
2293
  };
696
- const sessionId = parsed.sessionId;
697
- const cwd = sessionId === void 0 ? void 0 : await deps.sessionCwd(sessionId);
698
- let absolute;
699
- if (isAbsolute(parsed.path)) absolute = parsed.path;
700
- else {
701
- if (cwd === void 0) return {
702
- ok: false,
703
- message: `cannot resolve this Session's directory for a relative path: ${address}`
704
- };
705
- absolute = resolve(cwd, parsed.path);
706
- }
707
- if (!isFigurePath(absolute)) return {
708
- ok: false,
709
- message: `not an annotatable figure: ${absolute}`
2294
+ }
2295
+ /**
2296
+ * Read the figure's current hash and its saved annotation.
2297
+ * @param target - the resolved figure.
2298
+ * @returns the read body.
2299
+ */
2300
+ async function readAnswer(target) {
2301
+ const saved = await readAnnotation(target.path);
2302
+ const sha256 = await fileDigest(target.path);
2303
+ return {
2304
+ ok: true,
2305
+ figure: {
2306
+ ...target,
2307
+ sha256
2308
+ },
2309
+ annotation: saved.annotation,
2310
+ ...saved.unreadable.length === 0 ? {} : { unreadable: saved.unreadable }
710
2311
  };
711
- const mediaType = figureMediaType(absolute);
712
- /* v8 ignore next -- isFigurePath already restricted the suffixes figureMediaType covers. */
713
- if (mediaType === void 0) return {
2312
+ }
2313
+ /**
2314
+ * Write the annotation one request carried.
2315
+ * @param target - the resolved figure.
2316
+ * @param raw - the parsed request body.
2317
+ * @returns the save body, or the refusal to send.
2318
+ */
2319
+ async function saveAnswer(target, raw) {
2320
+ const document = readAnnotationDocument(raw["annotation"]);
2321
+ const review = readReview(raw);
2322
+ if (review !== void 0 && review.bytes.byteLength > 25165824) return {
714
2323
  ok: false,
715
- message: `no media type for ${absolute}`
2324
+ status: 413,
2325
+ error: "the review artifact is too large"
716
2326
  };
717
- try {
718
- if (!(await stat(absolute)).isFile()) return {
719
- ok: false,
720
- message: `not a regular file: ${absolute}`
721
- };
722
- } catch {
723
- return {
724
- ok: false,
725
- message: `no such figure: ${absolute}`
726
- };
727
- }
2327
+ const saved = await writeAnnotation(target.path, document, review);
728
2328
  return {
729
2329
  ok: true,
730
- target: {
731
- address,
732
- path: absolute,
733
- mediaType,
734
- workspace: cwd ?? null
2330
+ body: {
2331
+ ok: true,
2332
+ annotationPath: saved.paths.annotation,
2333
+ reviewPath: saved.wroteReview ? saved.paths.review : null
735
2334
  }
736
2335
  };
737
2336
  }
738
2337
  /**
739
- * Read the one flattened review artifact a save may carry.
740
- *
741
- * The kind is taken from the payload, and the store refuses a pair that
742
- * contradicts the figure's own suffix — so a caller cannot name PNG bytes as the
743
- * review of a PDF.
744
- *
745
- * @param body - parsed request body.
746
- * @returns the artifact, or undefined when the save carried none.
747
- * @throws {Error} when both payloads are present, or the PDF bytes are not a PDF.
2338
+ * Remove the sidecars of one figure.
2339
+ * @param target - the resolved figure.
2340
+ * @returns the removal body.
748
2341
  */
749
- function readReview(body) {
750
- const image = typeof body["reviewImage"] === "string" ? body["reviewImage"] : "";
751
- const pdf = typeof body["annotatedPdf"] === "string" ? body["annotatedPdf"] : "";
752
- if (image !== "" && pdf !== "") throw new Error("a save carries one review artifact, not both");
753
- if (pdf !== "") {
754
- const bytes = new Uint8Array(Buffer.from(pdf, "base64"));
755
- if (!Buffer.from(bytes.subarray(0, 5)).toString("latin1").startsWith(PDF_HEADER)) throw new Error("the annotated document is not a PDF");
756
- return {
757
- kind: "pdf",
758
- bytes
759
- };
760
- }
761
- if (image !== "") return {
762
- kind: "image",
763
- bytes: new Uint8Array(Buffer.from(image, "base64"))
2342
+ async function removeAnswer(target) {
2343
+ const removed = await removeAnnotation(target.path);
2344
+ return {
2345
+ ok: true,
2346
+ annotationPath: removed.paths.annotation,
2347
+ reviewPath: removed.paths.review,
2348
+ removed: removed.removed
764
2349
  };
765
2350
  }
766
2351
  /**
767
- * Whether a request carries the plugin guard and a same-origin posture.
768
- * @param req - incoming request.
769
- * @returns true when the request may proceed.
770
- */
771
- function guarded(req) {
772
- if (req.headers["x-dsh-figmark"] === void 0) return false;
773
- const site = req.headers["sec-fetch-site"];
774
- if (typeof site !== "string") return true;
775
- return site === "same-origin" || site === "none";
776
- }
777
- /**
778
- * Write one JSON response.
779
- * @param res - response to own.
780
- * @param status - HTTP status code.
781
- * @param body - JSON-serializable payload.
782
- */
783
- function sendJson(res, status, body) {
784
- const text = JSON.stringify(body);
785
- res.writeHead(status, {
786
- "content-type": "application/json; charset=utf-8",
787
- "cache-control": "no-store"
788
- });
789
- res.end(text);
790
- }
791
- /**
792
- * Read a request body with a byte cap.
793
- * @param req - incoming request.
794
- * @returns the body text.
795
- * @throws {Error} when the body exceeds the cap.
796
- */
797
- async function readBody(req) {
798
- const chunks = [];
799
- let total = 0;
800
- for await (const chunk of req) {
801
- const buffer = chunk;
802
- total += buffer.length;
803
- if (total > MAX_BODY_BYTES) throw new Error(`request body exceeds ${MAX_BODY_BYTES} bytes`);
804
- chunks.push(buffer);
805
- }
806
- return Buffer.concat(chunks).toString("utf8");
807
- }
808
- /**
809
2352
  * Build the route handlers this plugin registers.
810
2353
  * @param deps - Session lookup.
2354
+ * @param liveRuntime - live-page operation, replaceable at the listener boundary in tests.
811
2355
  * @returns one handler per route path.
812
2356
  */
813
- function createHandlers(deps) {
2357
+ function createHandlers(deps, liveRuntime = { open: openLive }) {
2358
+ /**
2359
+ * The page being served to the panel, when one is.
2360
+ *
2361
+ * One at a time, because the panel shows one frame: a second page would leave
2362
+ * the first proxy listening with nothing pointing at it. The state lives here,
2363
+ * per composition, so two hosts in one process never share a frame.
2364
+ */
2365
+ let live;
2366
+ /** The latest open request admitted, even while its listener is still starting. */
2367
+ let liveOpenSequence = 0;
2368
+ /**
2369
+ * Open the page one request names, or close whatever page is open.
2370
+ * @param req - the request naming the operation.
2371
+ * @param requestUrl - the already parsed request target.
2372
+ * @returns the answer to send.
2373
+ */
2374
+ const liveAnswer = async (req, requestUrl) => {
2375
+ if (req.method === "POST") {
2376
+ const sequence = liveOpenSequence + 1;
2377
+ liveOpenSequence = sequence;
2378
+ const body = JSON.parse(await readBody(req));
2379
+ const proxy = await liveRuntime.open(new URL(typeof body["url"] === "string" ? body["url"] : ""));
2380
+ if (sequence !== liveOpenSequence) {
2381
+ proxy.close();
2382
+ return {
2383
+ ok: false,
2384
+ status: 409,
2385
+ error: "a newer local page superseded this one"
2386
+ };
2387
+ }
2388
+ live?.close();
2389
+ live = proxy;
2390
+ return {
2391
+ ok: true,
2392
+ body: {
2393
+ ok: true,
2394
+ url: proxy.url
2395
+ }
2396
+ };
2397
+ }
2398
+ if (req.method === "DELETE") {
2399
+ const expected = requestUrl.searchParams.get("url");
2400
+ if (expected === null) liveOpenSequence += 1;
2401
+ if (live !== void 0 && (expected === null || expected === live.url)) {
2402
+ live.close();
2403
+ live = void 0;
2404
+ }
2405
+ return {
2406
+ ok: true,
2407
+ body: { ok: true }
2408
+ };
2409
+ }
2410
+ return {
2411
+ ok: false,
2412
+ status: 405,
2413
+ error: `method ${String(req.method)} is not allowed`
2414
+ };
2415
+ };
2416
+ /**
2417
+ * Scan the bounded local candidates the browser asked about.
2418
+ * @param req - a POST carrying a session and optional recent URLs.
2419
+ * @returns reachable services, or a method refusal.
2420
+ */
2421
+ const serviceAnswer = async (req) => {
2422
+ if (req.method !== "POST") return {
2423
+ ok: false,
2424
+ status: 405,
2425
+ error: `method ${String(req.method)} is not allowed`
2426
+ };
2427
+ const raw = JSON.parse(await readBody(req));
2428
+ const sessionId = typeof raw["sessionId"] === "string" ? raw["sessionId"] : "";
2429
+ if (sessionId === "") return {
2430
+ ok: false,
2431
+ status: 400,
2432
+ error: "service discovery needs a session"
2433
+ };
2434
+ const recent = Array.isArray(raw["recent"]) ? raw["recent"].filter((value) => typeof value === "string").slice(0, 16) : void 0;
2435
+ return {
2436
+ ok: true,
2437
+ body: {
2438
+ ok: true,
2439
+ services: await discoverServices({
2440
+ sessionId,
2441
+ ...recent === void 0 ? {} : { recent }
2442
+ }, deps.sessionCwd)
2443
+ }
2444
+ };
2445
+ };
814
2446
  const annotation = async (req, res) => {
815
2447
  if (!guarded(req)) {
816
- sendJson(res, 403, {
817
- ok: false,
818
- error: "missing plugin guard header"
819
- });
2448
+ sendFailure(res, 403, "missing plugin guard header");
820
2449
  return;
821
2450
  }
822
- const url = new URL(req.url ?? "/", "http://localhost");
823
2451
  try {
824
- if (req.method === "GET") {
825
- const resolved = await resolveTarget(url.searchParams.get("address") ?? "", deps);
826
- if (!resolved.ok) {
827
- sendJson(res, 404, {
828
- ok: false,
829
- error: resolved.message
830
- });
831
- return;
832
- }
833
- const [sha256, saved] = await Promise.all([fileDigest(resolved.target.path), readAnnotation(resolved.target.path)]);
834
- sendJson(res, 200, {
835
- ok: true,
836
- figure: {
837
- ...resolved.target,
838
- sha256
839
- },
840
- annotation: saved
841
- });
842
- return;
843
- }
844
- if (req.method === "POST") {
845
- const body = JSON.parse(await readBody(req));
846
- const resolved = await resolveTarget(typeof body["address"] === "string" ? body["address"] : "", deps);
847
- if (!resolved.ok) {
848
- sendJson(res, 404, {
849
- ok: false,
850
- error: resolved.message
851
- });
852
- return;
853
- }
854
- const document = readAnnotationDocument(body["annotation"]);
855
- const review = readReview(body);
856
- if (review !== void 0 && review.bytes.byteLength > 25165824) {
857
- sendJson(res, 413, {
858
- ok: false,
859
- error: "the review artifact is too large"
860
- });
861
- return;
862
- }
863
- const saved = await writeAnnotation(resolved.target.path, document, review);
864
- sendJson(res, 200, {
865
- ok: true,
866
- annotationPath: saved.paths.annotation,
867
- reviewPath: saved.wroteReview ? saved.paths.review : null
868
- });
869
- return;
870
- }
871
- if (req.method === "DELETE") {
872
- const resolved = await resolveTarget(url.searchParams.get("address") ?? "", deps);
873
- if (!resolved.ok) {
874
- sendJson(res, 404, {
875
- ok: false,
876
- error: resolved.message
877
- });
878
- return;
879
- }
880
- const removed = await removeAnnotation(resolved.target.path);
881
- sendJson(res, 200, {
882
- ok: true,
883
- annotationPath: removed.paths.annotation,
884
- reviewPath: removed.paths.review,
885
- removed: removed.removed
886
- });
2452
+ const url = new URL(req.url ?? "/", "http://localhost");
2453
+ const answer = url.pathname === LIVE_PATH ? await liveAnswer(req, url) : url.pathname === SERVICES_PATH ? await serviceAnswer(req) : await route(req, url, deps);
2454
+ if (answer.ok) {
2455
+ sendJson(res, 200, answer.body);
887
2456
  return;
888
2457
  }
889
- sendJson(res, 405, {
890
- ok: false,
891
- error: `method ${req.method ?? ""} is not allowed`
892
- });
2458
+ sendFailure(res, answer.status, answer.error);
893
2459
  } catch (error) {
894
- sendJson(res, 400, {
895
- ok: false,
896
- error: error instanceof Error ? error.message : String(error)
897
- });
2460
+ sendFailure(res, 400, error instanceof Error ? error.message : String(error));
2461
+ }
2462
+ };
2463
+ return {
2464
+ annotation,
2465
+ close() {
2466
+ liveOpenSequence += 1;
2467
+ live?.close();
2468
+ live = void 0;
898
2469
  }
899
2470
  };
900
- return { annotation };
2471
+ }
2472
+ /**
2473
+ * Dispatch one guarded request to the operation its method names.
2474
+ * @param req - incoming request.
2475
+ * @param url - the request's URL.
2476
+ * @param deps - Session lookup.
2477
+ * @returns the answer to send.
2478
+ */
2479
+ async function route(req, url, deps) {
2480
+ const address = url.searchParams.get("address") ?? "";
2481
+ if (req.method === "GET") {
2482
+ const target = await targetOf(address, deps);
2483
+ if (!target.ok) return target;
2484
+ return {
2485
+ ok: true,
2486
+ body: await readAnswer(target.body)
2487
+ };
2488
+ }
2489
+ if (req.method === "POST") {
2490
+ const body = JSON.parse(await readBody(req));
2491
+ const target = await targetOf(typeof body["address"] === "string" ? body["address"] : "", deps);
2492
+ if (!target.ok) return target;
2493
+ return await saveAnswer(target.body, body);
2494
+ }
2495
+ if (req.method === "DELETE") {
2496
+ const target = await targetOf(address, deps);
2497
+ if (!target.ok) return target;
2498
+ return {
2499
+ ok: true,
2500
+ body: await removeAnswer(target.body)
2501
+ };
2502
+ }
2503
+ return {
2504
+ ok: false,
2505
+ status: 405,
2506
+ error: `method ${req.method ?? ""} is not allowed`
2507
+ };
901
2508
  }
902
2509
  //#endregion
903
2510
  //#region src/host/session-cwd.ts
@@ -969,23 +2576,23 @@ function apply(ctx) {
969
2576
  workspaceRegistry: ctx.get("workspaceRegistry")
970
2577
  });
971
2578
  const handlers = createHandlers({ sessionCwd: (sessionId) => resolveSessionCwd(sessionId, sources()) });
972
- ctx.effect(() => webServer.register({
973
- kind: "prefix",
974
- path: ROUTE_PREFIX,
975
- handler: handlers.annotation
976
- }), "dsh-figmark: annotation route");
2579
+ ctx.effect(() => {
2580
+ const unregister = webServer.register({
2581
+ kind: "prefix",
2582
+ path: ROUTE_PREFIX,
2583
+ handler: handlers.annotation
2584
+ });
2585
+ return () => {
2586
+ unregister();
2587
+ handlers.close();
2588
+ };
2589
+ }, "dsh-figmark: annotation route");
977
2590
  ctx.effect(() => systemPrompt.section({
978
2591
  name: GUIDANCE_NAME,
979
2592
  order: GUIDANCE_ORDER,
980
2593
  text: GUIDANCE,
981
2594
  stable: true
982
2595
  }), "dsh-figmark: guidance");
983
- ctx.effect(() => systemPrompt.section({
984
- name: EDIT_GUIDANCE_NAME,
985
- order: EDIT_GUIDANCE_ORDER,
986
- text: EDIT_GUIDANCE,
987
- stable: true
988
- }), "dsh-figmark: edit guidance");
989
2596
  }
990
2597
  //#endregion
991
2598
  export { apply, inject, name };