vde-open 0.1.2 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -30,7 +30,7 @@ To install from a clone of this repository (pins Node.js 24.21.0 in `mise.toml`)
30
30
  pnpm install --frozen-lockfile
31
31
  pnpm build
32
32
  pnpm test:pack # builds artifacts/vde-open-<version>.tgz and verifies an install in a separate directory
33
- bun add -g "$PWD/artifacts/vde-open-0.1.2.tgz" # use an absolute path: bun add -g resolves relative paths from its global install directory, not the current one
33
+ bun add -g "$PWD/artifacts/vde-open-0.1.5.tgz" # use an absolute path: bun add -g resolves relative paths from its global install directory, not the current one
34
34
  ```
35
35
 
36
36
  ## Developing with a linked CLI
@@ -62,7 +62,7 @@ The same CLI is installed under two names. Both use the same state and daemon.
62
62
 
63
63
  If you already have a different `vo` (another tool's command, an alias, and so on), installing never overwrites or deletes it.
64
64
 
65
- - If the install target's bin directory (for `npm install -g`, npm's global bin) already has another `vo` file, npm stops with `EEXIST`. Do not use `--force`; it replaces the existing `vo`. Install with Bun instead, or into another prefix (for example `npm install -g --prefix ~/.local/vde-open ./artifacts/vde-open-0.1.2.tgz`) and use `vde-open` from that bin directory.
65
+ - If the install target's bin directory (for `npm install -g`, npm's global bin) already has another `vo` file, npm stops with `EEXIST`. Do not use `--force`; it replaces the existing `vo`. Install with Bun instead, or into another prefix (for example `npm install -g --prefix ~/.local/vde-open ./artifacts/vde-open-0.1.5.tgz`) and use `vde-open` from that bin directory.
66
66
  - For a `vo` or alias elsewhere, whichever comes first on `PATH` runs. In that case, use `vde-open`. If you want a short name, define an alias in your shell (for example `alias vdo=vde-open`).
67
67
 
68
68
  ## Basic usage
@@ -115,6 +115,17 @@ Markdown is rendered with TanStack Markdown 1.0.0. It is not fully compatible wi
115
115
  - Code is highlighted only for JS, JSX, TS, TSX, JSON, YAML, HTML, CSS, Bash, and Markdown. Code larger than 256 KiB and other languages are not highlighted.
116
116
  - Documents that cannot be parsed within 2 seconds, or that have more than 100,000 elements or more than 64 levels of nesting, are shown as source.
117
117
 
118
+ ## Exporting Markdown to PDF
119
+
120
+ "Export PDF" in the header of a Markdown document saves the shown revision as a PDF, without a print dialog.
121
+
122
+ - The PDF is an A4 document on a white background (the color theme of the management UI is not used). Every page has the document title in the header and the page number ("3 / 12") in the footer, and the headings become the PDF bookmarks.
123
+ - The daemon prints it with the Google Chrome or Microsoft Edge (131 or later) installed on the machine, run headless with a temporary profile that is removed afterwards. Nothing is downloaded. To use another Chromium-based browser, or one in another location, set `VDE_OPEN_BROWSER` to the absolute path of its executable for the daemon (after changing it, run `vo daemon restart`).
124
+ - The rules of the view apply: raw HTML is shown as text, only images registered for the document are included (others show their alternative text), and links to other local documents become plain text.
125
+ - The file name is the document's file name with `.pdf` (`README.md` → `README.pdf`). A document read from stdin uses its title.
126
+ - Fonts are the ones installed on the machine (San Francisco with Hiragino Sans on macOS, Segoe UI with Yu Gothic on Windows, Noto Sans CJK on Linux), so the PDF looks slightly different on each OS.
127
+ - HTML documents cannot be exported. Printing must finish within 60 seconds.
128
+
118
129
  ## Where the state is stored, and stopping
119
130
 
120
131
  - The state (open documents, revisions, questions and answers) is stored here. Change it with `VDE_OPEN_HOME`.
@@ -143,6 +154,7 @@ The person answers in the answer panel of the management UI. The answers are sub
143
154
  | Exit code 8 (cannot connect to or start the daemon) | Check with `vo daemon status`, and look for leftover files with `vo doctor` |
144
155
  | Images or CSS are not shown | Open "Differences from the original document" and register them with `--assets-root` or `--asset` |
145
156
  | Search does not find a document | Check with `vo list --json` that the document is open and its `searchState` is `ready` |
157
+ | Export PDF says Google Chrome or Microsoft Edge is needed | Install Chrome or Edge 131 or later, or set `VDE_OPEN_BROWSER` to the absolute path of a Chromium-based browser and run `vo daemon restart` |
146
158
 
147
159
  ## Verified scope
148
160
 
@@ -154,6 +166,7 @@ The person answers in the answer panel of the management UI. The answers are sub
154
166
  | Browsers (macOS) | Chromium (Playwright's Chrome Headless Shell): the full suite is verified. Firefox 155 and WebKit 26.6 (Playwright 1.63.0): the view isolation, CSP, HTML bridge, and authentication tests (`pnpm test:e2e:cross`) are verified |
155
167
  | Browsers (not verified) | Other UI interactions in Firefox and WebKit (search, answer panel, narrow screens, a list of 1,000 documents) are not verified |
156
168
  | Markdown syntax | As described in "Markdown display limits" above. Full CommonMark and GFM are not verified |
169
+ | PDF export | macOS (locally): verified with Google Chrome 154 and Microsoft Edge 154. Linux on CI (`ubuntu-latest`): verified with the runner's Google Chrome (e2e). Windows: printing with a real browser is not verified (the pack smoke test checks rendering the print document) |
157
170
 
158
171
  More details: [docs/performance.md](docs/performance.md) (measurements), [docs/architecture.md](docs/architecture.md) and [docs/security-model.md](docs/security-model.md) (design). Development records (in Japanese): [docs/implementation-status.md](docs/implementation-status.md), [docs/dependency-validation.md](docs/dependency-validation.md), and [docs/adr/](docs/adr/).
159
172
 
package/dist/cli.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import { C as decodeStateFile, D as daemonEntryPath, E as resolveStateRoot, O as referencedBlobs, S as renameWithRetry, T as resolveRuntimeLocation, a as DAEMON_LOCK_NAME, b as proofMatches, c as readIpcKey, d as inspectOwner, f as readCurrentLock, g as encodeFrame, h as FrameDecoder, i as requirePrivateDirectoryIfExists, k as version, l as readPointer, m as systemProcessProbe, n as ensurePrivateDirectory, o as POINTER_FILE, p as releaseLock, r as inspectPrivateDirectory, s as START_LOCK_NAME, t as startDaemon, u as acquireLock, v as computeProof, w as currentPathEnvironment, x as removeWithRetry, y as createNonce } from "./main-CqENdUsw.js";
3
- import { A as successEnvelope, C as VdeError, D as ExitCode, E as isVdeError, O as envelopeSchema, T as isErrorCode, Y as LIMITS, k as errorEnvelope, w as exitCodeForError } from "./search-index-__laOomT.js";
2
+ import { C as decodeStateFile, D as daemonEntryPath, E as resolveStateRoot, O as referencedBlobs, S as renameWithRetry, T as resolveRuntimeLocation, a as DAEMON_LOCK_NAME, b as proofMatches, c as readIpcKey, d as inspectOwner, f as readCurrentLock, g as encodeFrame, h as FrameDecoder, i as requirePrivateDirectoryIfExists, k as version, l as readPointer, m as systemProcessProbe, n as ensurePrivateDirectory, o as POINTER_FILE, p as releaseLock, r as inspectPrivateDirectory, s as START_LOCK_NAME, t as startDaemon, u as acquireLock, v as computeProof, w as currentPathEnvironment, x as removeWithRetry, y as createNonce } from "./main-BZ8Pvrn8.js";
3
+ import { A as successEnvelope, C as VdeError, D as ExitCode, E as isVdeError, O as envelopeSchema, T as isErrorCode, Y as LIMITS, k as errorEnvelope, w as exitCodeForError } from "./search-index-B7DqaWh6.js";
4
4
  import fs, { fstatSync } from "node:fs";
5
5
  import { lstat, readFile, readdir, stat as stat$1, writeFile } from "node:fs/promises";
6
6
  import path, { join, resolve } from "node:path";
package/dist/daemon.js CHANGED
@@ -1,5 +1,5 @@
1
- import { k as version, t as startDaemon, w as currentPathEnvironment } from "./main-CqENdUsw.js";
2
- import { E as isVdeError } from "./search-index-__laOomT.js";
1
+ import { k as version, t as startDaemon, w as currentPathEnvironment } from "./main-BZ8Pvrn8.js";
2
+ import { E as isVdeError } from "./search-index-B7DqaWh6.js";
3
3
  //#region src/daemon.ts
4
4
  function notifyParent(message) {
5
5
  if (!process.send) return;
@@ -1,21 +1,23 @@
1
1
  import { r as __toESM, t as __commonJSMin } from "./rolldown-runtime-DC62tzP2.js";
2
- import { $ as literal, A as successEnvelope, B as readParamsSchema, C as VdeError, D as ExitCode, E as isVdeError, F as htmlModeChangeParamsSchema, G as sha256Schema, H as reorderParamsSchema, I as htmlModeSchema, J as watchRuleSchema, K as sourceKindSchema, L as linkOpenParamsSchema, M as closeParamsSchema, N as documentFormatSchema, P as documentIdSchema, Q as boolean, R as listParamsSchema, S as validateAnswers, U as revisionSchema, V as renderGrantParamsSchema, W as searchParamsSchema, X as _enum, Y as LIMITS, Z as array, _ as feedbackWaitParamsSchema, a as uniqueTokens, at as canonicalJson, b as submissionSchema, c as feedbackAckParamsSchema, d as feedbackDraftParamsSchema, et as number, f as feedbackForgetParamsSchema, g as feedbackSubmitParamsSchema, h as feedbackStatusSchema, i as codePointLength, it as ZodError, j as assetRecordSchema, k as errorEnvelope, l as feedbackCancelParamsSchema, m as feedbackListParamsSchema, n as batchesOf, nt as strictObject, o as answersSchema, p as feedbackIdParamsSchema, q as sourceStateSchema, r as partsOf, rt as string, s as cancellationSchema, tt as record, u as feedbackCreateParamsSchema, v as questionnaireSchema, w as exitCodeForError, x as utf8Length, y as requestIdSchema, z as openParamsSchema } from "./search-index-__laOomT.js";
3
- import { a as analyzeDocument, c as classifyReference, d as hasHiddenSegment, f as isValidLogicalPath, i as ParseLimitError, l as dirnameOfLogicalPath, n as scanReferences, o as assetTypeOf, p as roleAllowed, r as extractTitle, s as classifyLink, u as encodeLogicalPath } from "./render-BNZz0CjQ.js";
2
+ import { $ as literal, A as successEnvelope, B as readParamsSchema, C as VdeError, D as ExitCode, E as isVdeError, F as htmlModeChangeParamsSchema, G as sha256Schema, H as reorderParamsSchema, I as htmlModeSchema, J as watchRuleSchema, K as sourceKindSchema, L as linkOpenParamsSchema, M as closeParamsSchema, N as documentFormatSchema, P as documentIdSchema, Q as boolean, R as listParamsSchema, S as validateAnswers, U as revisionSchema, V as renderGrantParamsSchema, W as searchParamsSchema, X as _enum, Y as LIMITS, Z as array, _ as feedbackWaitParamsSchema, a as uniqueTokens, at as canonicalJson, b as submissionSchema, c as feedbackAckParamsSchema, d as feedbackDraftParamsSchema, et as number, f as feedbackForgetParamsSchema, g as feedbackSubmitParamsSchema, h as feedbackStatusSchema, i as codePointLength, it as ZodError, j as assetRecordSchema, k as errorEnvelope, l as feedbackCancelParamsSchema, m as feedbackListParamsSchema, n as batchesOf, nt as strictObject, o as answersSchema, p as feedbackIdParamsSchema, q as sourceStateSchema, r as partsOf, rt as string, s as cancellationSchema, tt as record, u as feedbackCreateParamsSchema, v as questionnaireSchema, w as exitCodeForError, x as utf8Length, y as requestIdSchema, z as openParamsSchema } from "./search-index-B7DqaWh6.js";
3
+ import { C as roleAllowed, _ as dirnameOfLogicalPath, a as ParseLimitError, g as classifyReference, h as classifyLink, m as assetTypeOf, o as analyzeDocument, v as encodeLogicalPath, x as isValidLogicalPath, y as hasHiddenSegment } from "./rendering-rules-DiE2jI7I.js";
4
+ import { n as scanReferences, r as extractTitle } from "./render-BJnuEfHt.js";
4
5
  import { n as measureHeap, t as collectGarbage } from "./heap-C3qHm7SE.js";
5
- import { constants, existsSync, stat, unwatchFile, watch, watchFile } from "node:fs";
6
- import { appendFile, chmod, link, lstat, mkdir, open, readFile, readdir, readlink, realpath, rename, rm, rmdir, stat as stat$1, writeFile } from "node:fs/promises";
6
+ import { accessSync, constants, existsSync, stat, statSync, unwatchFile, watch, watchFile } from "node:fs";
7
+ import { appendFile, chmod, link, lstat, mkdir, mkdtemp, open, readFile, readdir, readlink, realpath, rename, rm, rmdir, stat as stat$1, writeFile } from "node:fs/promises";
7
8
  import * as sp from "node:path";
8
- import { basename, dirname, extname, isAbsolute, join, posix, relative, resolve, sep, win32 } from "node:path";
9
+ import { basename, delimiter, dirname, extname, isAbsolute, join, posix, relative, resolve, sep, win32 } from "node:path";
9
10
  import { EventEmitter } from "node:events";
10
- import { execFile } from "node:child_process";
11
+ import { execFile, spawn } from "node:child_process";
11
12
  import { promisify } from "node:util";
12
13
  import { createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
13
14
  import * as nativeFs from "fs";
14
- import { readdir as readdir$1, readdirSync, realpath as realpath$1, realpathSync, stat as stat$2, statSync } from "fs";
15
+ import { readdir as readdir$1, readdirSync, realpath as realpath$1, realpathSync, stat as stat$2, statSync as statSync$1 } from "fs";
15
16
  import { basename as basename$1, dirname as dirname$1, isAbsolute as isAbsolute$1, normalize, posix as posix$1, relative as relative$1, resolve as resolve$1, sep as sep$1 } from "path";
16
17
  import { fileURLToPath } from "url";
17
18
  import { createRequire } from "module";
18
- import { fileURLToPath as fileURLToPath$1 } from "node:url";
19
+ import { tmpdir, type } from "node:os";
20
+ import { fileURLToPath as fileURLToPath$1, pathToFileURL } from "node:url";
19
21
  import { Worker } from "node:worker_threads";
20
22
  import { performance } from "node:perf_hooks";
21
23
  import { getHeapStatistics } from "node:v8";
@@ -23,7 +25,6 @@ import { STATUS_CODES, ServerResponse, createServer } from "node:http";
23
25
  import { Http2ServerRequest, constants as constants$1 } from "node:http2";
24
26
  import { Readable } from "node:stream";
25
27
  import { createServer as createServer$1 } from "node:net";
26
- import { type } from "node:os";
27
28
  //#region ../../packages/shared/src/bridge.ts
28
29
  const BRIDGE_HELLO = "vde-bridge-hello";
29
30
  const BRIDGE_PORT = "vde-bridge-port";
@@ -154,7 +155,7 @@ function parseStrictJson(text) {
154
155
  }
155
156
  //#endregion
156
157
  //#region package.json
157
- var version = "0.1.2";
158
+ var version = "0.1.5";
158
159
  //#endregion
159
160
  //#region src/documents/cursor.ts
160
161
  function invalidCursor(reason) {
@@ -3354,7 +3355,7 @@ function getOptions(options) {
3354
3355
  realpath: opts.fs.realpath || realpath$1,
3355
3356
  realpathSync: opts.fs.realpathSync || realpathSync,
3356
3357
  stat: opts.fs.stat || stat$2,
3357
- statSync: opts.fs.statSync || statSync
3358
+ statSync: opts.fs.statSync || statSync$1
3358
3359
  });
3359
3360
  if (opts.debug) log("globbing with options:", opts);
3360
3361
  return opts;
@@ -5445,6 +5446,454 @@ var DocumentService = class {
5445
5446
  }
5446
5447
  };
5447
5448
  //#endregion
5449
+ //#region src/export/browser.ts
5450
+ function executableFile(path) {
5451
+ try {
5452
+ accessSync(path, constants.X_OK);
5453
+ return statSync(path).isFile();
5454
+ } catch {
5455
+ return false;
5456
+ }
5457
+ }
5458
+ const MAC_APPS = [
5459
+ "Google Chrome.app/Contents/MacOS/Google Chrome",
5460
+ "Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
5461
+ "Chromium.app/Contents/MacOS/Chromium"
5462
+ ];
5463
+ const LINUX_COMMANDS = [
5464
+ "google-chrome",
5465
+ "google-chrome-stable",
5466
+ "chromium",
5467
+ "chromium-browser",
5468
+ "microsoft-edge",
5469
+ "microsoft-edge-stable"
5470
+ ];
5471
+ const WINDOWS_APPS = ["Google\\Chrome\\Application\\chrome.exe", "Microsoft\\Edge\\Application\\msedge.exe"];
5472
+ function candidates(lookup) {
5473
+ const { env, platform, homeDir } = lookup;
5474
+ if (platform === "darwin") return ["/Applications", join(homeDir, "Applications")].flatMap((dir) => MAC_APPS.map((app) => join(dir, app)));
5475
+ if (platform === "win32") {
5476
+ const roots = [
5477
+ env["ProgramFiles"],
5478
+ env["ProgramFiles(x86)"],
5479
+ env["LOCALAPPDATA"]
5480
+ ].filter((root) => root !== void 0 && root !== "");
5481
+ return WINDOWS_APPS.flatMap((app) => roots.map((root) => `${root}\\${app}`));
5482
+ }
5483
+ const dirs = (env["PATH"] ?? "").split(delimiter).filter((dir) => isAbsolute(dir));
5484
+ return LINUX_COMMANDS.flatMap((command) => dirs.map((dir) => join(dir, command)));
5485
+ }
5486
+ function findBrowser(lookup) {
5487
+ const isExecutable = lookup.isExecutable ?? executableFile;
5488
+ const explicit = lookup.env["VDE_OPEN_BROWSER"];
5489
+ if (explicit !== void 0 && explicit !== "") {
5490
+ if (isAbsolute(explicit) && isExecutable(explicit)) return explicit;
5491
+ throw new VdeError("E_BROWSER_NOT_FOUND", "VDE_OPEN_BROWSER is not the absolute path of an executable file.", { source: "VDE_OPEN_BROWSER" });
5492
+ }
5493
+ const found = candidates(lookup).find((path) => isExecutable(path));
5494
+ if (found !== void 0) return found;
5495
+ throw new VdeError("E_BROWSER_NOT_FOUND", "Exporting a PDF needs Google Chrome or Microsoft Edge. Set VDE_OPEN_BROWSER to use another Chromium-based browser.", { source: "search" });
5496
+ }
5497
+ //#endregion
5498
+ //#region src/export/cdp.ts
5499
+ const MAX_MESSAGE_BYTES = 16777216;
5500
+ var CdpError = class extends Error {
5501
+ method;
5502
+ constructor(method, message) {
5503
+ super(`${method}: ${message}`);
5504
+ this.name = "CdpError";
5505
+ this.method = method;
5506
+ }
5507
+ };
5508
+ function connectCdp(toBrowser, fromBrowser) {
5509
+ let nextId = 1;
5510
+ let failure = null;
5511
+ const pending = /* @__PURE__ */ new Map();
5512
+ const waiters = [];
5513
+ let chunks = [];
5514
+ let buffered = 0;
5515
+ const close = (error) => {
5516
+ if (failure !== null) return;
5517
+ failure = error;
5518
+ for (const waiter of pending.values()) waiter.reject(error);
5519
+ pending.clear();
5520
+ for (const waiter of waiters.splice(0)) waiter.reject(error);
5521
+ };
5522
+ const dispatch = (message) => {
5523
+ if (message.id !== void 0) {
5524
+ const waiter = pending.get(message.id);
5525
+ if (!waiter) return;
5526
+ pending.delete(message.id);
5527
+ if (message.error) waiter.reject(new CdpError(waiter.method, message.error.message ?? "failed"));
5528
+ else waiter.resolve(message.result);
5529
+ return;
5530
+ }
5531
+ const index = waiters.findIndex((waiter) => waiter.method === message.method && waiter.sessionId === message.sessionId && waiter.accept(message.params));
5532
+ if (index === -1) return;
5533
+ const [waiter] = waiters.splice(index, 1);
5534
+ waiter?.resolve(message.params);
5535
+ };
5536
+ fromBrowser.on("data", (chunk) => {
5537
+ if (failure !== null) return;
5538
+ let start = 0;
5539
+ let end = chunk.indexOf(0, start);
5540
+ try {
5541
+ while (end !== -1) {
5542
+ chunks.push(chunk.subarray(start, end));
5543
+ const text = Buffer.concat(chunks).toString("utf8");
5544
+ chunks = [];
5545
+ buffered = 0;
5546
+ dispatch(JSON.parse(text));
5547
+ start = end + 1;
5548
+ end = chunk.indexOf(0, start);
5549
+ }
5550
+ if (start < chunk.length) {
5551
+ chunks.push(chunk.subarray(start));
5552
+ buffered += chunk.length - start;
5553
+ if (buffered > MAX_MESSAGE_BYTES) throw new Error("message too large");
5554
+ }
5555
+ } catch {
5556
+ chunks = [];
5557
+ close(new CdpError("protocol", "the browser sent a message that cannot be read"));
5558
+ }
5559
+ });
5560
+ fromBrowser.on("error", () => void 0);
5561
+ toBrowser.on("error", () => void 0);
5562
+ return {
5563
+ send(method, params = {}, sessionId) {
5564
+ if (failure !== null) return Promise.reject(failure);
5565
+ const id = nextId;
5566
+ nextId += 1;
5567
+ return new Promise((resolve, reject) => {
5568
+ pending.set(id, {
5569
+ method,
5570
+ resolve,
5571
+ reject
5572
+ });
5573
+ toBrowser.write(`${JSON.stringify({
5574
+ id,
5575
+ method,
5576
+ params,
5577
+ ...sessionId === void 0 ? {} : { sessionId }
5578
+ })}\0`);
5579
+ });
5580
+ },
5581
+ waitFor(method, sessionId, accept) {
5582
+ if (failure !== null) return Promise.reject(failure);
5583
+ return new Promise((resolve, reject) => {
5584
+ waiters.push({
5585
+ method,
5586
+ sessionId,
5587
+ accept,
5588
+ resolve,
5589
+ reject
5590
+ });
5591
+ });
5592
+ },
5593
+ close
5594
+ };
5595
+ }
5596
+ //#endregion
5597
+ //#region src/export/pdf-service.ts
5598
+ const BROWSER_FLAGS = [
5599
+ "--headless",
5600
+ "--remote-debugging-pipe",
5601
+ "--no-first-run",
5602
+ "--no-default-browser-check",
5603
+ "--disable-background-networking",
5604
+ "--disable-component-update",
5605
+ "--disable-default-apps",
5606
+ "--disable-extensions",
5607
+ "--disable-sync",
5608
+ "--password-store=basic",
5609
+ "--use-mock-keychain"
5610
+ ];
5611
+ const MIN_BROWSER_VERSION = 131;
5612
+ const EXIT_GRACE_MS = 5e3;
5613
+ const MAX_WAITING = 4;
5614
+ const READ_CHUNK_BYTES = 1048576;
5615
+ const MAX_PAGE_BYTES = 268435456;
5616
+ const TEMP_PREFIX$1 = "vde-open-pdf-";
5617
+ const STALE_TEMP_MS = 36e5;
5618
+ const PDF_HEAD = Buffer.from("%PDF-");
5619
+ const PDF_TAIL = Buffer.from("%%EOF");
5620
+ function exportFailed(reason, details = {}) {
5621
+ return new VdeError("E_EXPORT_FAILED", "The browser could not print the PDF.", {
5622
+ reason,
5623
+ ...details
5624
+ });
5625
+ }
5626
+ const stopping = () => new VdeError("E_DAEMON_STOPPING", "The daemon is stopping.");
5627
+ const cancelled = () => new VdeError("E_INTERRUPTED", "The PDF export was cancelled.");
5628
+ function settledWithin(promise, ms) {
5629
+ return new Promise((resolve) => {
5630
+ const timer = setTimeout(() => resolve(false), ms);
5631
+ promise.then(() => {
5632
+ clearTimeout(timer);
5633
+ resolve(true);
5634
+ });
5635
+ });
5636
+ }
5637
+ async function printPage(cdp, url) {
5638
+ const { product } = await cdp.send("Browser.getVersion");
5639
+ if (Number(/\/(\d+)\./.exec(product)?.[1] ?? "0") < MIN_BROWSER_VERSION) throw new VdeError("E_BROWSER_NOT_FOUND", `Exporting a PDF needs Google Chrome or Microsoft Edge ${String(MIN_BROWSER_VERSION)} or later.`, {
5640
+ source: "version",
5641
+ version: product
5642
+ });
5643
+ const { targetId } = await cdp.send("Target.createTarget", { url: "about:blank" });
5644
+ const { sessionId } = await cdp.send("Target.attachToTarget", {
5645
+ targetId,
5646
+ flatten: true
5647
+ });
5648
+ await cdp.send("Page.enable", {}, sessionId);
5649
+ await cdp.send("Page.setLifecycleEventsEnabled", { enabled: true }, sessionId);
5650
+ const seen = /* @__PURE__ */ new Set();
5651
+ let loaderId = null;
5652
+ const loaded = cdp.waitFor("Page.lifecycleEvent", sessionId, (event) => {
5653
+ if (event.name !== "load") return false;
5654
+ if (loaderId === null) seen.add(event.loaderId);
5655
+ return event.loaderId === loaderId;
5656
+ });
5657
+ loaded.catch(() => void 0);
5658
+ const navigation = await cdp.send("Page.navigate", { url }, sessionId);
5659
+ if (navigation.errorText !== void 0 || navigation.loaderId === void 0) throw exportFailed("load");
5660
+ loaderId = navigation.loaderId;
5661
+ if (!seen.has(loaderId)) await loaded;
5662
+ const { stream } = await cdp.send("Page.printToPDF", {
5663
+ printBackground: true,
5664
+ preferCSSPageSize: true,
5665
+ displayHeaderFooter: false,
5666
+ generateDocumentOutline: true,
5667
+ generateTaggedPDF: true,
5668
+ transferMode: "ReturnAsStream"
5669
+ }, sessionId);
5670
+ const parts = [];
5671
+ for (;;) {
5672
+ const chunk = await cdp.send("IO.read", {
5673
+ handle: stream,
5674
+ size: READ_CHUNK_BYTES
5675
+ }, sessionId);
5676
+ parts.push(Buffer.from(chunk.data, chunk.base64Encoded === true ? "base64" : "latin1"));
5677
+ if (chunk.eof) break;
5678
+ }
5679
+ await cdp.send("IO.close", { handle: stream }, sessionId);
5680
+ return {
5681
+ product,
5682
+ pdf: Buffer.concat(parts)
5683
+ };
5684
+ }
5685
+ function isPdf(pdf) {
5686
+ let end = pdf.length;
5687
+ while (end > 0 && (pdf[end - 1] === 10 || pdf[end - 1] === 13 || pdf[end - 1] === 32)) end -= 1;
5688
+ return pdf.subarray(0, PDF_HEAD.length).equals(PDF_HEAD) && pdf.subarray(end - PDF_TAIL.length, end).equals(PDF_TAIL);
5689
+ }
5690
+ function createPdfService(options) {
5691
+ const { store, documents, printer } = options;
5692
+ const platform = options.platform ?? process.platform;
5693
+ const timeoutMs = options.timeoutMs ?? LIMITS.pdfExportTimeoutMs;
5694
+ const tempRoot = options.tempRoot ?? tmpdir();
5695
+ const maxPageBytes = options.maxPageBytes ?? MAX_PAGE_BYTES;
5696
+ let closed = false;
5697
+ let stopRunning = null;
5698
+ let queue = Promise.resolve();
5699
+ let waiting = 0;
5700
+ const kill = (child) => {
5701
+ if (child.pid === void 0) return;
5702
+ if (platform === "win32") {
5703
+ if (child.exitCode !== null || child.signalCode !== null) return;
5704
+ spawn("taskkill", [
5705
+ "/PID",
5706
+ String(child.pid),
5707
+ "/T",
5708
+ "/F"
5709
+ ], {
5710
+ stdio: "ignore",
5711
+ windowsHide: true
5712
+ }).once("error", () => child.kill());
5713
+ return;
5714
+ }
5715
+ try {
5716
+ process.kill(-child.pid, "SIGKILL");
5717
+ } catch {}
5718
+ };
5719
+ const writePage = async (path, html, slot, images) => {
5720
+ const file = await open(path, "wx", 384);
5721
+ let written = 0;
5722
+ const write = async (text) => {
5723
+ written += Buffer.byteLength(text);
5724
+ if (written > maxPageBytes) throw new VdeError("E_LIMIT_EXCEEDED", "The document is too large to print.", {
5725
+ reason: "page-size",
5726
+ limit: maxPageBytes
5727
+ });
5728
+ await file.write(text);
5729
+ };
5730
+ try {
5731
+ let last = 0;
5732
+ for (const match of html.matchAll(new RegExp(`${slot}-(\\d+)`, "g"))) {
5733
+ await write(html.slice(last, match.index));
5734
+ const image = images[Number(match[1])];
5735
+ if (image !== void 0) {
5736
+ await write(`data:${image.mime};base64,`);
5737
+ await write((await store.readBlob(image.sha256)).toString("base64"));
5738
+ }
5739
+ last = match.index + match[0].length;
5740
+ }
5741
+ await write(html.slice(last));
5742
+ } finally {
5743
+ await file.close();
5744
+ }
5745
+ };
5746
+ const uid = platform === "win32" ? null : process.getuid?.() ?? null;
5747
+ const sweepStale = async () => {
5748
+ const now = Date.now();
5749
+ for (const name of await readdir(tempRoot).catch(() => [])) {
5750
+ if (!name.startsWith(TEMP_PREFIX$1)) continue;
5751
+ const path = join(tempRoot, name);
5752
+ const info = await lstat(path).catch(() => null);
5753
+ if (info === null || !info.isDirectory() || now - info.mtimeMs < STALE_TEMP_MS) continue;
5754
+ if (uid !== null && info.uid !== uid) continue;
5755
+ await rm(path, {
5756
+ recursive: true,
5757
+ force: true
5758
+ }).catch(() => void 0);
5759
+ }
5760
+ };
5761
+ sweepStale();
5762
+ const print = async (browser, directory, url, signal) => {
5763
+ if (closed) throw stopping();
5764
+ if (signal?.aborted === true) throw cancelled();
5765
+ const child = spawn(browser, [
5766
+ ...BROWSER_FLAGS,
5767
+ `--user-data-dir=${join(directory, "profile")}`,
5768
+ "about:blank"
5769
+ ], {
5770
+ stdio: [
5771
+ "ignore",
5772
+ "ignore",
5773
+ "ignore",
5774
+ "pipe",
5775
+ "pipe"
5776
+ ],
5777
+ detached: platform !== "win32",
5778
+ windowsHide: true
5779
+ });
5780
+ const cdp = connectCdp(child.stdio[3], child.stdio[4]);
5781
+ let reason = null;
5782
+ const stop = (why) => {
5783
+ reason ??= why;
5784
+ cdp.close(why);
5785
+ kill(child);
5786
+ };
5787
+ const exited = new Promise((resolve) => {
5788
+ child.once("exit", () => {
5789
+ cdp.close(reason ?? exportFailed("exit"));
5790
+ resolve();
5791
+ });
5792
+ child.once("error", () => {
5793
+ stop(exportFailed("spawn"));
5794
+ resolve();
5795
+ });
5796
+ });
5797
+ const timer = setTimeout(() => stop(exportFailed("timeout", { timeoutMs })), timeoutMs);
5798
+ const onAbort = () => stop(cancelled());
5799
+ signal?.addEventListener("abort", onAbort, { once: true });
5800
+ stopRunning = stop;
5801
+ let printed = null;
5802
+ try {
5803
+ printed = await printPage(cdp, url);
5804
+ await cdp.send("Browser.close").catch(() => void 0);
5805
+ return printed;
5806
+ } catch (error) {
5807
+ if (reason !== null) throw reason;
5808
+ if (error instanceof VdeError) throw error;
5809
+ throw exportFailed("protocol");
5810
+ } finally {
5811
+ clearTimeout(timer);
5812
+ signal?.removeEventListener("abort", onAbort);
5813
+ stopRunning = null;
5814
+ if (printed === null) kill(child);
5815
+ if (!await settledWithin(exited, EXIT_GRACE_MS)) {
5816
+ kill(child);
5817
+ await settledWithin(exited, EXIT_GRACE_MS);
5818
+ }
5819
+ }
5820
+ };
5821
+ const run = async (documentId, revision, signal) => {
5822
+ const started = Date.now();
5823
+ const { record, entry } = documents.describeRevision(documentId, revision);
5824
+ if (entry.format !== "markdown") throw new VdeError("E_UNSUPPORTED_FORMAT", "Only Markdown documents can be exported as PDF.", { format: entry.format });
5825
+ const source = (await store.readBlob(entry.sourceSha256)).toString("utf8");
5826
+ const images = entry.assets.filter((asset) => asset.role === "image" || asset.role === "svg");
5827
+ const slot = randomBytes(16).toString("hex");
5828
+ const html = await printer.print({
5829
+ source,
5830
+ title: record.title,
5831
+ documentLogicalPath: entry.documentLogicalPath,
5832
+ images: images.map((asset) => asset.logicalPath),
5833
+ imageSlot: slot
5834
+ });
5835
+ const browser = options.findBrowser();
5836
+ const directory = await mkdtemp(join(tempRoot, TEMP_PREFIX$1));
5837
+ try {
5838
+ const page = join(directory, "document.html");
5839
+ await writePage(page, html, slot, images);
5840
+ const { product, pdf } = await print(browser, directory, pathToFileURL(page).href, signal);
5841
+ if (!isPdf(pdf)) throw exportFailed("output");
5842
+ options.onEvent?.("pdf.exported", {
5843
+ bytes: pdf.length,
5844
+ elapsedMs: Date.now() - started,
5845
+ browser: product.split("/", 1)[0] ?? ""
5846
+ });
5847
+ return pdf;
5848
+ } finally {
5849
+ await rm(directory, {
5850
+ recursive: true,
5851
+ force: true,
5852
+ maxRetries: 10,
5853
+ retryDelay: 100
5854
+ }).catch((error) => {
5855
+ options.onEvent?.("pdf.cleanup_failed", { code: error.code ?? "unknown" });
5856
+ });
5857
+ }
5858
+ };
5859
+ return {
5860
+ exportMarkdown(documentId, revision, signal) {
5861
+ if (closed) return Promise.reject(stopping());
5862
+ if (waiting >= MAX_WAITING) return Promise.reject(new VdeError("E_LIMIT_EXCEEDED", "Too many PDF exports are waiting.", {
5863
+ reason: "waiting",
5864
+ limit: MAX_WAITING
5865
+ }));
5866
+ let counted = true;
5867
+ const uncount = () => {
5868
+ if (!counted) return;
5869
+ counted = false;
5870
+ waiting -= 1;
5871
+ };
5872
+ waiting += 1;
5873
+ signal?.addEventListener("abort", uncount, { once: true });
5874
+ const result = queue.then(() => {
5875
+ signal?.removeEventListener("abort", uncount);
5876
+ uncount();
5877
+ if (closed) throw stopping();
5878
+ if (signal?.aborted === true) throw cancelled();
5879
+ return run(documentId, revision, signal);
5880
+ });
5881
+ queue = result.catch((error) => {
5882
+ if (error instanceof VdeError && error.code !== "E_INTERRUPTED") options.onEvent?.("pdf.failed", {
5883
+ code: error.code,
5884
+ reason: String(error.details["reason"] ?? error.details["source"] ?? "")
5885
+ });
5886
+ });
5887
+ return result;
5888
+ },
5889
+ async close() {
5890
+ closed = true;
5891
+ stopRunning?.(stopping());
5892
+ await queue;
5893
+ }
5894
+ };
5895
+ }
5896
+ //#endregion
5448
5897
  //#region src/feedback/service.ts
5449
5898
  function sha256$1(text) {
5450
5899
  return createHash("sha256").update(text).digest("hex");
@@ -10806,6 +11255,14 @@ async function startManagementServer(deps, options = {}) {
10806
11255
  })));
10807
11256
  api.post("/documents/:id/focus", async (c) => ok(c, "documents.focus", await deps.documents.focus({ documentId: c.req.param("id") })));
10808
11257
  api.post("/documents/:id/refresh", async (c) => ok(c, "documents.refresh", await deps.documents.refresh({ documentId: c.req.param("id") })));
11258
+ api.post("/documents/:id/pdf", async (c) => {
11259
+ const body = strictObject({ revision: revisionSchema }).parse(await c.req.json());
11260
+ const pdf = await deps.pdf.exportMarkdown(c.req.param("id"), body.revision, c.req.raw.signal);
11261
+ return c.body(new Uint8Array(pdf), 200, {
11262
+ "Content-Type": "application/pdf",
11263
+ "Content-Disposition": "attachment"
11264
+ });
11265
+ });
10809
11266
  api.get("/search", async (c) => {
10810
11267
  const { query, mode, limit, maxBytes, cursor } = c.req.query();
10811
11268
  const documents = c.req.queries("document") ?? [];
@@ -13346,6 +13803,10 @@ function createParseService(options = {}) {
13346
13803
  op: "render",
13347
13804
  input
13348
13805
  }),
13806
+ print: (input) => request({
13807
+ op: "print",
13808
+ input
13809
+ }),
13349
13810
  async close() {
13350
13811
  closed = true;
13351
13812
  collector.close(new VdeError("E_DAEMON_STOPPING", "The daemon is stopping."));
@@ -13709,8 +14170,10 @@ async function startDaemon(options) {
13709
14170
  let preview = null;
13710
14171
  let watcher = null;
13711
14172
  let parse = null;
14173
+ let printer = null;
13712
14174
  let search = null;
13713
14175
  let feedback = null;
14176
+ let pdf = null;
13714
14177
  let announceStopping = () => void 0;
13715
14178
  let lockTimer = null;
13716
14179
  let heapWatch = null;
@@ -13753,9 +14216,11 @@ async function startDaemon(options) {
13753
14216
  await step("drain", () => ipc?.drain());
13754
14217
  announceStopping();
13755
14218
  await step("management", () => management?.close(), SHUTDOWN_CLOSE_TIMEOUT_MS);
14219
+ await step("pdf", () => pdf?.close(), SHUTDOWN_CLOSE_TIMEOUT_MS);
13756
14220
  await step("preview", () => preview?.close(), SHUTDOWN_CLOSE_TIMEOUT_MS);
13757
14221
  await step("store", () => store?.close());
13758
14222
  await step("parse", () => parse?.close(), SHUTDOWN_CLOSE_TIMEOUT_MS);
14223
+ await step("print", () => printer?.close(), SHUTDOWN_CLOSE_TIMEOUT_MS);
13759
14224
  await step("search", () => search?.close(), SHUTDOWN_CLOSE_TIMEOUT_MS);
13760
14225
  await step("ipc", () => ipc?.close());
13761
14226
  let ownsLock = false;
@@ -13855,6 +14320,16 @@ async function startDaemon(options) {
13855
14320
  })
13856
14321
  });
13857
14322
  pruneGrants = () => render.pruneClosed();
14323
+ printer = createParseService({ timeoutMs: LIMITS.printRenderTimeoutMs });
14324
+ const exporting = createPdfService({
14325
+ store: openedStore,
14326
+ documents,
14327
+ printer,
14328
+ findBrowser: () => findBrowser(environment),
14329
+ platform: environment.platform,
14330
+ onEvent: (event, fields) => logger.log(event, fields)
14331
+ });
14332
+ pdf = exporting;
13858
14333
  preview = await startPreviewServer({
13859
14334
  render,
13860
14335
  frameAncestors: () => [managementOrigin, devOrigin].filter((origin) => origin !== null),
@@ -13870,6 +14345,7 @@ async function startDaemon(options) {
13870
14345
  render,
13871
14346
  search: searching,
13872
14347
  feedback: answering,
14348
+ pdf: exporting,
13873
14349
  previewOrigin,
13874
14350
  webRoot: webRootPath(),
13875
14351
  devOrigin,
@@ -13907,7 +14383,8 @@ async function startDaemon(options) {
13907
14383
  const { collectGarbage } = diagnosticsParamsSchema.parse(params ?? {});
13908
14384
  const workers = {
13909
14385
  search: await search?.diagnostics(collectGarbage, stopRequested.signal) ?? null,
13910
- parse: await parse?.diagnostics(collectGarbage, stopRequested.signal) ?? null
14386
+ parse: await parse?.diagnostics(collectGarbage, stopRequested.signal) ?? null,
14387
+ print: await printer?.diagnostics(collectGarbage, stopRequested.signal) ?? null
13911
14388
  };
13912
14389
  const heapUsedBytes = measureHeap(collectGarbage);
13913
14390
  const activeResources = {};