@karmaniverous/jeeves-server 3.0.0-0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (260) hide show
  1. package/.env.local +13 -0
  2. package/.env.local.template +13 -0
  3. package/.tsbuildinfo +1 -0
  4. package/CHANGELOG.md +450 -0
  5. package/about.md +82 -0
  6. package/client/README.md +73 -0
  7. package/client/eslint.config.js +23 -0
  8. package/client/index.html +14 -0
  9. package/client/package-lock.json +5181 -0
  10. package/client/package.json +60 -0
  11. package/client/public/vite.svg +1 -0
  12. package/client/src/App.tsx +22 -0
  13. package/client/src/components/AccountMenu.tsx +167 -0
  14. package/client/src/components/ActionDropdown.tsx +120 -0
  15. package/client/src/components/CodeEditor.tsx +143 -0
  16. package/client/src/components/CodeViewer.tsx +113 -0
  17. package/client/src/components/ConfirmDialog.tsx +32 -0
  18. package/client/src/components/DirectoryRow.tsx +62 -0
  19. package/client/src/components/DirectoryTable.tsx +42 -0
  20. package/client/src/components/DownloadDropdown.tsx +116 -0
  21. package/client/src/components/DriveList.tsx +54 -0
  22. package/client/src/components/EmbeddedDiagramPanzoom.ts +28 -0
  23. package/client/src/components/FileContentView.tsx +155 -0
  24. package/client/src/components/InlineSvgPanzoom.ts +60 -0
  25. package/client/src/components/LazyDiagram.ts +93 -0
  26. package/client/src/components/LinkDropdown.tsx +134 -0
  27. package/client/src/components/MarkdownView.tsx +115 -0
  28. package/client/src/components/MermaidViewer.tsx +21 -0
  29. package/client/src/components/PlantUmlViewer.tsx +21 -0
  30. package/client/src/components/SearchModal.tsx +424 -0
  31. package/client/src/components/SvgViewer.tsx +107 -0
  32. package/client/src/components/TabBar.tsx +96 -0
  33. package/client/src/components/layout/Header.tsx +270 -0
  34. package/client/src/components/panzoom.ts +203 -0
  35. package/client/src/components/renderableUtils.ts +15 -0
  36. package/client/src/components/runner/JobTable.tsx +153 -0
  37. package/client/src/components/runner/RunHistory.tsx +140 -0
  38. package/client/src/components/runner/StatsBar.tsx +43 -0
  39. package/client/src/components/runner/StatusPill.tsx +27 -0
  40. package/client/src/components/runner/jobTableUtils.ts +65 -0
  41. package/client/src/components/scrollUtils.ts +39 -0
  42. package/client/src/components/ui/alert-dialog.tsx +107 -0
  43. package/client/src/components/ui/button.tsx +40 -0
  44. package/client/src/components/ui/dropdown-menu.tsx +79 -0
  45. package/client/src/components/ui/input.tsx +26 -0
  46. package/client/src/components/useActionState.ts +43 -0
  47. package/client/src/hooks/useFileBrowser.ts +102 -0
  48. package/client/src/hooks/useFileData.ts +78 -0
  49. package/client/src/hooks/useScrollAnchor.ts +70 -0
  50. package/client/src/hooks/useShareSettings.ts +22 -0
  51. package/client/src/hooks/useTopBar.ts +27 -0
  52. package/client/src/index.css +281 -0
  53. package/client/src/lib/AuthContext.ts +27 -0
  54. package/client/src/lib/api.ts +239 -0
  55. package/client/src/lib/auth.tsx +50 -0
  56. package/client/src/lib/codeBlockCm6.ts +129 -0
  57. package/client/src/lib/codeBlockCopy.ts +43 -0
  58. package/client/src/lib/codemirror.ts +77 -0
  59. package/client/src/lib/runner-api.ts +172 -0
  60. package/client/src/lib/svg.ts +50 -0
  61. package/client/src/lib/theme.ts +34 -0
  62. package/client/src/lib/utils.ts +6 -0
  63. package/client/src/main.tsx +11 -0
  64. package/client/src/pages/FileBrowser.tsx +135 -0
  65. package/client/src/pages/Home.tsx +46 -0
  66. package/client/src/pages/Runner.tsx +151 -0
  67. package/client/src/pages/RunnerJob.tsx +170 -0
  68. package/client/tsconfig.app.json +32 -0
  69. package/client/tsconfig.json +7 -0
  70. package/client/tsconfig.node.json +26 -0
  71. package/client/vite.config.ts +35 -0
  72. package/content/privacy.md +61 -0
  73. package/content/terms.md +41 -0
  74. package/dist/client/assets/CodeEditor-0XHVI8Nu.js +1 -0
  75. package/dist/client/assets/CodeViewer-CykMVsfX.js +1 -0
  76. package/dist/client/assets/index--MBieNJA.js +1 -0
  77. package/dist/client/assets/index-BENeXQI_.js +1 -0
  78. package/dist/client/assets/index-BbBpoOxz.js +1 -0
  79. package/dist/client/assets/index-BdV9g5AM.js +6 -0
  80. package/dist/client/assets/index-BjAilRri.js +2 -0
  81. package/dist/client/assets/index-BqbhWo2I.js +3 -0
  82. package/dist/client/assets/index-CVbycZ0H.js +1 -0
  83. package/dist/client/assets/index-Cs5oz2oJ.js +5 -0
  84. package/dist/client/assets/index-D8KZVveX.js +1 -0
  85. package/dist/client/assets/index-DC4HMHxY.js +13 -0
  86. package/dist/client/assets/index-DbMebkkd.css +1 -0
  87. package/dist/client/assets/index-DcY2RXqX.js +1 -0
  88. package/dist/client/assets/index-Duy-tZYV.js +1 -0
  89. package/dist/client/assets/index-Dw7rDFmE.js +7 -0
  90. package/dist/client/assets/index-FlCUvrjv.js +2 -0
  91. package/dist/client/assets/index-K6OVmfhg.js +1 -0
  92. package/dist/client/assets/index-LjwgzZ7F.js +62 -0
  93. package/dist/client/assets/index-MLwyFRN0.js +1 -0
  94. package/dist/client/assets/index-OpqBpSjn.js +1 -0
  95. package/dist/client/assets/index-SsHei0HE.js +1 -0
  96. package/dist/client/assets/index-uQa2yckk.js +1 -0
  97. package/dist/client/assets/index-udkXoIER.js +1 -0
  98. package/dist/client/index.html +15 -0
  99. package/dist/client/vite.svg +1 -0
  100. package/dist/src/auth/google.js +57 -0
  101. package/dist/src/auth/keys.js +185 -0
  102. package/dist/src/auth/resolve.js +102 -0
  103. package/dist/src/auth/session.js +57 -0
  104. package/dist/src/cli/commands/config.js +100 -0
  105. package/dist/src/cli/commands/config.test.js +84 -0
  106. package/dist/src/cli/commands/service.js +93 -0
  107. package/dist/src/cli/commands/start.js +24 -0
  108. package/dist/src/cli/index.js +20 -0
  109. package/dist/src/config/index.js +90 -0
  110. package/dist/src/config/loadConfig.test.js +127 -0
  111. package/dist/src/config/resolve.js +134 -0
  112. package/dist/src/config/resolve.test.js +148 -0
  113. package/dist/src/config/schema.js +159 -0
  114. package/dist/src/config/substituteEnvVars.js +45 -0
  115. package/dist/src/config/substituteEnvVars.test.js +51 -0
  116. package/dist/src/config/types.js +5 -0
  117. package/dist/src/routes/api/auth-status.js +56 -0
  118. package/dist/src/routes/api/diagrams.js +35 -0
  119. package/dist/src/routes/api/directory.js +93 -0
  120. package/dist/src/routes/api/drives.js +15 -0
  121. package/dist/src/routes/api/export.js +218 -0
  122. package/dist/src/routes/api/fileContent.js +286 -0
  123. package/dist/src/routes/api/index.js +33 -0
  124. package/dist/src/routes/api/linkInfo.js +71 -0
  125. package/dist/src/routes/api/linkInfo.test.js +104 -0
  126. package/dist/src/routes/api/middleware.js +117 -0
  127. package/dist/src/routes/api/raw.js +38 -0
  128. package/dist/src/routes/api/runner.js +59 -0
  129. package/dist/src/routes/api/search.js +236 -0
  130. package/dist/src/routes/api/sharing.js +203 -0
  131. package/dist/src/routes/api/status.js +68 -0
  132. package/dist/src/routes/api/status.test.js +62 -0
  133. package/dist/src/routes/auth.js +99 -0
  134. package/dist/src/routes/event.js +77 -0
  135. package/dist/src/routes/event.test.js +206 -0
  136. package/dist/src/routes/health.js +10 -0
  137. package/dist/src/routes/keys.js +129 -0
  138. package/dist/src/routes/path/index.js +17 -0
  139. package/dist/src/routes/static.js +30 -0
  140. package/dist/src/server.js +90 -0
  141. package/dist/src/services/deepShareLinks.js +163 -0
  142. package/dist/src/services/diagramCache.js +104 -0
  143. package/dist/src/services/embeddedDiagrams.js +136 -0
  144. package/dist/src/services/eventLog.js +55 -0
  145. package/dist/src/services/eventLog.test.js +113 -0
  146. package/dist/src/services/eventQueue.js +154 -0
  147. package/dist/src/services/eventQueue.test.js +104 -0
  148. package/dist/src/services/export.js +220 -0
  149. package/dist/src/services/exportCache.js +196 -0
  150. package/dist/src/services/markdown.js +147 -0
  151. package/dist/src/services/mermaid.js +97 -0
  152. package/dist/src/services/plantuml.js +145 -0
  153. package/dist/src/services/puppeteer.js +156 -0
  154. package/dist/src/util/breadcrumbs.js +22 -0
  155. package/dist/src/util/crypto.js +56 -0
  156. package/dist/src/util/crypto.test.js +99 -0
  157. package/dist/src/util/fileDetection.js +66 -0
  158. package/dist/src/util/fileDetection.test.js +89 -0
  159. package/dist/src/util/formatters.js +43 -0
  160. package/dist/src/util/formatters.test.js +83 -0
  161. package/dist/src/util/packageVersion.js +25 -0
  162. package/dist/src/util/platform.js +148 -0
  163. package/dist/src/util/state.js +46 -0
  164. package/dist/vitest.config.js +12 -0
  165. package/favicon.svg +3 -0
  166. package/guides/access-decision-flow.mmd +24 -0
  167. package/guides/access-decision-flow.svg +1 -0
  168. package/guides/api-integration.md +236 -0
  169. package/guides/deployment.md +287 -0
  170. package/guides/event-gateway.md +204 -0
  171. package/guides/event-gateway.mmd +17 -0
  172. package/guides/event-gateway.svg +1 -0
  173. package/guides/exports.md +239 -0
  174. package/guides/setup.md +313 -0
  175. package/guides/sharing.md +204 -0
  176. package/jeeves-server.config.template.json +25 -0
  177. package/package.json +124 -0
  178. package/scripts/download-plantuml.js +70 -0
  179. package/src/auth/google.ts +93 -0
  180. package/src/auth/keys.ts +252 -0
  181. package/src/auth/resolve.ts +157 -0
  182. package/src/auth/session.ts +77 -0
  183. package/src/cli/commands/config.test.ts +107 -0
  184. package/src/cli/commands/config.ts +113 -0
  185. package/src/cli/commands/service.ts +129 -0
  186. package/src/cli/commands/start.ts +27 -0
  187. package/src/cli/index.ts +25 -0
  188. package/src/config/index.ts +113 -0
  189. package/src/config/loadConfig.test.ts +155 -0
  190. package/src/config/resolve.test.ts +192 -0
  191. package/src/config/resolve.ts +173 -0
  192. package/src/config/schema.ts +179 -0
  193. package/src/config/substituteEnvVars.test.ts +64 -0
  194. package/src/config/substituteEnvVars.ts +52 -0
  195. package/src/config/types.ts +129 -0
  196. package/src/routes/api/auth-status.ts +85 -0
  197. package/src/routes/api/diagrams.ts +53 -0
  198. package/src/routes/api/directory.ts +123 -0
  199. package/src/routes/api/drives.ts +23 -0
  200. package/src/routes/api/export.ts +314 -0
  201. package/src/routes/api/fileContent.ts +414 -0
  202. package/src/routes/api/index.ts +37 -0
  203. package/src/routes/api/linkInfo.test.ts +132 -0
  204. package/src/routes/api/linkInfo.ts +83 -0
  205. package/src/routes/api/middleware.ts +156 -0
  206. package/src/routes/api/raw.ts +54 -0
  207. package/src/routes/api/runner.ts +107 -0
  208. package/src/routes/api/search.ts +321 -0
  209. package/src/routes/api/sharing.ts +259 -0
  210. package/src/routes/api/status.test.ts +72 -0
  211. package/src/routes/api/status.ts +82 -0
  212. package/src/routes/auth.ts +143 -0
  213. package/src/routes/event.test.ts +248 -0
  214. package/src/routes/event.ts +109 -0
  215. package/src/routes/health.ts +13 -0
  216. package/src/routes/keys.ts +192 -0
  217. package/src/routes/path/index.ts +24 -0
  218. package/src/routes/static.ts +54 -0
  219. package/src/server.ts +104 -0
  220. package/src/services/deepShareLinks.ts +203 -0
  221. package/src/services/diagramCache.ts +128 -0
  222. package/src/services/embeddedDiagrams.ts +168 -0
  223. package/src/services/eventLog.test.ts +144 -0
  224. package/src/services/eventLog.ts +68 -0
  225. package/src/services/eventQueue.test.ts +127 -0
  226. package/src/services/eventQueue.ts +196 -0
  227. package/src/services/export.ts +267 -0
  228. package/src/services/exportCache.ts +216 -0
  229. package/src/services/markdown.ts +189 -0
  230. package/src/services/mermaid.ts +113 -0
  231. package/src/services/plantuml.ts +172 -0
  232. package/src/services/puppeteer.ts +188 -0
  233. package/src/types/fastify.d.ts +13 -0
  234. package/src/types/jsonmap.d.ts +10 -0
  235. package/src/types/plantuml-encoder.d.ts +4 -0
  236. package/src/util/breadcrumbs.ts +33 -0
  237. package/src/util/crypto.test.ts +132 -0
  238. package/src/util/crypto.ts +79 -0
  239. package/src/util/fileDetection.test.ts +115 -0
  240. package/src/util/fileDetection.ts +70 -0
  241. package/src/util/formatters.test.ts +105 -0
  242. package/src/util/formatters.ts +44 -0
  243. package/src/util/packageVersion.ts +30 -0
  244. package/src/util/platform.ts +178 -0
  245. package/src/util/state.ts +55 -0
  246. package/test-docs/diagram-retry-test.md +18 -0
  247. package/test-docs/embedded-diagrams.md +52 -0
  248. package/test-docs/lazy-diagrams-test.md +333 -0
  249. package/test-docs/page-a.md +7 -0
  250. package/test-docs/page-b.md +7 -0
  251. package/test-docs/page-c.md +7 -0
  252. package/test-docs/sub/page-d.md +7 -0
  253. package/test-docs/test-diagram.puml +13 -0
  254. package/test-docs/validate-deep-share.js +318 -0
  255. package/tsconfig.json +37 -0
  256. package/tsdoc.json +13 -0
  257. package/vendor/.plantuml-version +1 -0
  258. package/vendor/plantuml.jar +0 -0
  259. package/vitest.config.js +12 -0
  260. package/vitest.config.ts +13 -0
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Diagram rendering API route.
3
+ *
4
+ * Handles: /api/diagram/:type/:hash
5
+ */
6
+
7
+ import type { FastifyPluginAsync } from 'fastify';
8
+
9
+ import {
10
+ getDiagramSource,
11
+ renderDiagramToSvg,
12
+ } from '../../services/embeddedDiagrams.js';
13
+
14
+ // eslint-disable-next-line @typescript-eslint/require-await
15
+ export const diagramsRoutes: FastifyPluginAsync = async (fastify) => {
16
+ fastify.get<{ Params: { type: string; hash: string } }>(
17
+ '/api/diagram/:type/:hash',
18
+ async (request, reply) => {
19
+ const { type, hash: hashWithExt } = request.params;
20
+ const hash = hashWithExt.replace(/\.svg$/, '');
21
+
22
+ if (!['mermaid', 'plantuml'].includes(type)) {
23
+ return reply.code(400).send({ error: 'Invalid diagram type' });
24
+ }
25
+ if (!/^[a-f0-9]{64}$/.test(hash)) {
26
+ return reply.code(400).send({ error: 'Invalid hash' });
27
+ }
28
+
29
+ const entry = getDiagramSource(hash);
30
+ if (!entry) {
31
+ return reply
32
+ .code(404)
33
+ .send({ error: 'Diagram source not found (may have expired)' });
34
+ }
35
+
36
+ const svg = await renderDiagramToSvg(
37
+ type,
38
+ entry.source,
39
+ entry.contextDir,
40
+ );
41
+ if (!svg) {
42
+ return reply
43
+ .code(500)
44
+ .send({ error: 'Renderer returned empty result' });
45
+ }
46
+
47
+ return reply
48
+ .header('Content-Type', 'image/svg+xml')
49
+ .header('Cache-Control', 'public, max-age=86400, immutable')
50
+ .send(svg);
51
+ },
52
+ );
53
+ };
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Directory listing API route.
3
+ *
4
+ * Handles: GET /api/path/*
5
+ */
6
+
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+
10
+ import type { FastifyPluginAsync } from 'fastify';
11
+
12
+ import {
13
+ _directoryVisibleUnderScopes,
14
+ _pathMatchesPatterns,
15
+ _pathMatchesScopes,
16
+ } from '../../auth/keys.js';
17
+ import { getConfig } from '../../config/index.js';
18
+ import { filterBreadcrumbsForOutsider } from '../../util/breadcrumbs.js';
19
+ import {
20
+ breadcrumbParts,
21
+ fsPathToUrl,
22
+ getRoots,
23
+ urlPathToFs,
24
+ } from '../../util/platform.js';
25
+
26
+ // eslint-disable-next-line @typescript-eslint/require-await
27
+ export const directoryRoutes: FastifyPluginAsync = async (fastify) => {
28
+ const roots = getRoots(getConfig().roots);
29
+
30
+ fastify.get<{ Params: { '*': string } }>(
31
+ '/api/path/*',
32
+ async (request, reply) => {
33
+ const reqPath = request.params['*'];
34
+ if (!reqPath) return reply.redirect('/api/drives');
35
+
36
+ const fsPath = urlPathToFs(reqPath, roots);
37
+ if (!fsPath) return reply.code(404).send({ error: 'Invalid path' });
38
+ const resolved = path.resolve(fsPath);
39
+
40
+ if (!fs.existsSync(resolved)) {
41
+ return reply.code(404).send({ error: 'Not found', path: resolved });
42
+ }
43
+
44
+ const stats = fs.statSync(resolved);
45
+ if (!stats.isDirectory()) {
46
+ const ext = path.extname(resolved).toLowerCase();
47
+ return reply.send({
48
+ type: 'file',
49
+ path: reqPath,
50
+ ext,
51
+ size: stats.size,
52
+ mtime: stats.mtime.toISOString().split('T')[0],
53
+ });
54
+ }
55
+
56
+ const isInsider = request.accessMode === 'insider';
57
+ const insiderScopes = request.insiderScopes ?? null;
58
+
59
+ const allEntries = fs.readdirSync(resolved, { withFileTypes: true });
60
+
61
+ const entries = insiderScopes
62
+ ? allEntries.filter((entry) => {
63
+ const entryPath = path.join(resolved, entry.name);
64
+ const entryUrlPath = fsPathToUrl(entryPath, roots);
65
+ if (insiderScopes.deny.length > 0) {
66
+ if (_pathMatchesPatterns(entryUrlPath, insiderScopes.deny))
67
+ return false;
68
+ }
69
+ if (entry.isDirectory()) {
70
+ return _directoryVisibleUnderScopes(
71
+ entryUrlPath,
72
+ insiderScopes.allow,
73
+ );
74
+ }
75
+ return _pathMatchesScopes(entryUrlPath, insiderScopes);
76
+ })
77
+ : allEntries;
78
+
79
+ const sorted = entries.sort((a, b) => {
80
+ if (a.isDirectory() && !b.isDirectory()) return -1;
81
+ if (!a.isDirectory() && b.isDirectory()) return 1;
82
+ return a.name.localeCompare(b.name);
83
+ });
84
+
85
+ const result = sorted.map((entry) => {
86
+ const entryPath = path.join(resolved, entry.name);
87
+ let size: number | null = null;
88
+ let mtime: string | null = null;
89
+ const ext = path.extname(entry.name).toLowerCase();
90
+ try {
91
+ const entryStats = fs.statSync(entryPath);
92
+ mtime = entryStats.mtime.toISOString().split('T')[0];
93
+ if (!entry.isDirectory()) size = entryStats.size;
94
+ } catch {
95
+ /* ignore */
96
+ }
97
+ return {
98
+ name: entry.name,
99
+ type: entry.isDirectory() ? 'directory' : 'file',
100
+ ext,
101
+ size,
102
+ mtime,
103
+ };
104
+ });
105
+
106
+ const breadcrumbs = breadcrumbParts(resolved, roots);
107
+ const matchedPath = request.authMatchedPath ?? null;
108
+ const filteredBreadcrumbs = filterBreadcrumbsForOutsider(
109
+ breadcrumbs,
110
+ isInsider,
111
+ matchedPath,
112
+ true,
113
+ );
114
+
115
+ return reply.send({
116
+ path: reqPath,
117
+ entries: result,
118
+ breadcrumbs: filteredBreadcrumbs,
119
+ isInsider,
120
+ });
121
+ },
122
+ );
123
+ };
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Drive listing API route.
3
+ *
4
+ * Handles: GET /api/drives
5
+ */
6
+
7
+ import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify';
8
+
9
+ import { getConfig } from '../../config/index.js';
10
+ import { getRoots } from '../../util/platform.js';
11
+
12
+ // eslint-disable-next-line @typescript-eslint/require-await
13
+ export const drivesRoutes: FastifyPluginAsync = async (fastify) => {
14
+ const roots = getRoots(getConfig().roots);
15
+
16
+ fastify.get(
17
+ '/api/drives',
18
+ async (_request: FastifyRequest, reply: FastifyReply) => {
19
+ const drives = roots.map((r) => ({ letter: r.id, label: r.label }));
20
+ return reply.send(drives);
21
+ },
22
+ );
23
+ };
@@ -0,0 +1,314 @@
1
+ /**
2
+ * Export API routes.
3
+ *
4
+ * Handles: /api/export/*, /api/mermaid-export/*, /api/plantuml-export/*
5
+ */
6
+
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+
10
+ import archiver from 'archiver';
11
+ import type { FastifyPluginAsync } from 'fastify';
12
+
13
+ import { getConfig } from '../../config/index.js';
14
+ import {
15
+ cacheDiagramBuffer,
16
+ getCachedDiagramBuffer,
17
+ } from '../../services/diagramCache.js';
18
+ import { appendEvent } from '../../services/eventQueue.js';
19
+ import { type ExportFormat, exportPage } from '../../services/export.js';
20
+ import {
21
+ cacheExport,
22
+ clearDiagramCacheForFile,
23
+ clearExportCache,
24
+ clearStandaloneDiagramCache,
25
+ getCachedExport,
26
+ } from '../../services/exportCache.js';
27
+ import { renderMermaidToFile } from '../../services/mermaid.js';
28
+ import {
29
+ getPlantUmlFormats,
30
+ renderPlantUmlToBuffer,
31
+ } from '../../services/plantuml.js';
32
+ import { DIAGRAM_CONTENT_TYPES } from '../../util/fileDetection.js';
33
+ import { getDirSize, getRoots, urlPathToFs } from '../../util/platform.js';
34
+
35
+ // eslint-disable-next-line @typescript-eslint/require-await
36
+ export const exportRoutes: FastifyPluginAsync = async (fastify) => {
37
+ const roots = getRoots(getConfig().roots);
38
+
39
+ // GET /api/export/*
40
+ fastify.get<{ Params: { '*': string }; Querystring: { format?: string } }>(
41
+ '/api/export/*',
42
+ async (request, reply) => {
43
+ const reqPath = request.params['*'];
44
+ if (!reqPath) return reply.code(400).send({ error: 'Path required' });
45
+
46
+ const exportFsPath = urlPathToFs(reqPath, roots);
47
+ if (!exportFsPath) return reply.code(404).send({ error: 'Invalid path' });
48
+ const resolved = path.resolve(exportFsPath);
49
+
50
+ if (!fs.existsSync(resolved))
51
+ return reply.code(404).send({ error: 'Not found', path: resolved });
52
+
53
+ const format = request.query.format ?? 'pdf';
54
+ const stats = fs.statSync(resolved);
55
+
56
+ // ZIP export for directories
57
+ if (stats.isDirectory()) {
58
+ if (format !== 'zip')
59
+ return reply
60
+ .code(400)
61
+ .send({ error: 'Directories only support ZIP export' });
62
+ const isInsider = request.accessMode === 'insider';
63
+ if (!isInsider)
64
+ return reply
65
+ .code(403)
66
+ .send({ error: 'ZIP export requires insider access' });
67
+
68
+ const config = getConfig();
69
+ const totalSize = getDirSize(resolved);
70
+ const maxSizeBytes = config.maxZipSizeMb * 1024 * 1024;
71
+ if (totalSize > maxSizeBytes) {
72
+ return reply.code(413).send({
73
+ error: `Directory too large for ZIP export (${String(Math.round(totalSize / 1024 / 1024))}MB, max ${String(config.maxZipSizeMb)}MB)`,
74
+ });
75
+ }
76
+
77
+ const dirName = path.basename(resolved);
78
+ const archive = archiver('zip', { zlib: { level: 6 } });
79
+ reply.header('Content-Type', 'application/zip');
80
+ reply.header(
81
+ 'Content-Disposition',
82
+ `attachment; filename="${dirName}.zip"`,
83
+ );
84
+ reply.send(archive);
85
+ archive.directory(resolved, dirName);
86
+ void archive.finalize();
87
+ return;
88
+ }
89
+
90
+ // PDF/DOCX export for files
91
+ if (format !== 'pdf' && format !== 'docx') {
92
+ return reply
93
+ .code(400)
94
+ .send({ error: 'Files support pdf or docx export' });
95
+ }
96
+
97
+ const ext = path.extname(resolved).toLowerCase();
98
+ if (ext !== '.md')
99
+ return reply
100
+ .code(400)
101
+ .send({ error: 'Only markdown files support PDF/DOCX export' });
102
+
103
+ const config = getConfig();
104
+ const internalKey = config.internalInsiderKey;
105
+ const { port } = config;
106
+ const exportKey = (request.query as { key?: string }).key || internalKey;
107
+ if (!exportKey)
108
+ return reply
109
+ .code(500)
110
+ .send({ error: 'Export unavailable — no internal key configured' });
111
+
112
+ const exportUrl = `http://localhost:${String(port)}/browse/${reqPath}?key=${exportKey}&render_diagrams=1&plain_code=1`;
113
+ const fileName = path.basename(resolved);
114
+ const baseName = fileName.replace(/\.md$/i, '');
115
+
116
+ try {
117
+ const contentType =
118
+ format === 'pdf'
119
+ ? 'application/pdf'
120
+ : 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
121
+ const fileExt = format === 'pdf' ? 'pdf' : 'docx';
122
+
123
+ // Check export cache
124
+ const cached = getCachedExport(resolved, format);
125
+ if (cached) {
126
+ return await reply
127
+ .header('Content-Type', contentType)
128
+ .header(
129
+ 'Content-Disposition',
130
+ `attachment; filename="${baseName}.${fileExt}"`,
131
+ )
132
+ .header('Content-Length', cached.length)
133
+ .send(cached);
134
+ }
135
+
136
+ const buffer = await exportPage({
137
+ url: exportUrl,
138
+ fileName,
139
+ format: format as ExportFormat,
140
+ });
141
+
142
+ // Cache the result
143
+ cacheExport(resolved, format, buffer);
144
+
145
+ return await reply
146
+ .header('Content-Type', contentType)
147
+ .header(
148
+ 'Content-Disposition',
149
+ `attachment; filename="${baseName}.${fileExt}"`,
150
+ )
151
+ .header('Content-Length', buffer.length)
152
+ .send(buffer);
153
+ } catch (err) {
154
+ appendEvent({ kind: `${format}_export_error`, error: String(err) });
155
+ return reply.code(500).send({
156
+ error: `${format.toUpperCase()} export failed`,
157
+ details: String(err),
158
+ });
159
+ }
160
+ },
161
+ );
162
+
163
+ // DELETE /api/export-cache/* — clear all caches for a file (insider-only)
164
+ fastify.delete<{ Params: { '*': string } }>(
165
+ '/api/export-cache/*',
166
+ async (request, reply) => {
167
+ if (request.accessMode !== 'insider') {
168
+ return reply.code(403).send({ error: 'Insider access required' });
169
+ }
170
+
171
+ const reqPath = request.params['*'];
172
+ if (!reqPath) return reply.code(400).send({ error: 'Path required' });
173
+
174
+ const fsPath = urlPathToFs(reqPath, roots);
175
+ if (!fsPath) return reply.code(404).send({ error: 'Invalid path' });
176
+ const resolved = path.resolve(fsPath);
177
+
178
+ const { getDiagramCacheDir } =
179
+ await import('../../services/diagramCache.js');
180
+ const diagCacheDir = getDiagramCacheDir();
181
+ const exportCount = clearExportCache(resolved);
182
+ const embeddedCount = clearDiagramCacheForFile(resolved, diagCacheDir);
183
+ const standaloneCount = clearStandaloneDiagramCache(
184
+ resolved,
185
+ diagCacheDir,
186
+ );
187
+
188
+ return reply.send({
189
+ cleared: {
190
+ exports: exportCount,
191
+ diagrams: embeddedCount + standaloneCount,
192
+ },
193
+ });
194
+ },
195
+ );
196
+
197
+ // GET /api/mermaid-export/*
198
+ fastify.get<{ Params: { '*': string }; Querystring: { format?: string } }>(
199
+ '/api/mermaid-export/*',
200
+ async (request, reply) => {
201
+ const reqPath = request.params['*'];
202
+ if (!reqPath) return reply.code(400).send({ error: 'Path required' });
203
+
204
+ const mmdFsPath = urlPathToFs(reqPath, roots);
205
+ if (!mmdFsPath) return reply.code(404).send({ error: 'Invalid path' });
206
+ const resolved = path.resolve(mmdFsPath);
207
+
208
+ if (
209
+ !fs.existsSync(resolved) ||
210
+ !resolved.toLowerCase().endsWith('.mmd')
211
+ ) {
212
+ return reply.code(404).send({ error: 'Mermaid file not found' });
213
+ }
214
+
215
+ const mermaidFormats = ['svg', 'png', 'pdf'];
216
+ const format = mermaidFormats.includes(request.query.format ?? '')
217
+ ? request.query.format!
218
+ : 'svg';
219
+ const source = fs.readFileSync(resolved, 'utf8');
220
+
221
+ // Check cache
222
+ const cachedBuffer = getCachedDiagramBuffer('mermaid', source, format);
223
+ if (cachedBuffer) {
224
+ const downloadName = `${path.basename(resolved, '.mmd')}.${format}`;
225
+ return reply
226
+ .header(
227
+ 'Content-Type',
228
+ DIAGRAM_CONTENT_TYPES[format] ?? 'application/octet-stream',
229
+ )
230
+ .header(
231
+ 'Content-Disposition',
232
+ `attachment; filename="${downloadName}"`,
233
+ )
234
+ .send(cachedBuffer);
235
+ }
236
+
237
+ const outFile = await renderMermaidToFile(resolved, format);
238
+ if (!outFile)
239
+ return reply.code(500).send({ error: 'Mermaid render failed' });
240
+
241
+ const content = fs.readFileSync(outFile);
242
+ cacheDiagramBuffer('mermaid', source, content, format);
243
+ const downloadName = path.basename(outFile);
244
+
245
+ return reply
246
+ .header(
247
+ 'Content-Type',
248
+ DIAGRAM_CONTENT_TYPES[format] ?? 'application/octet-stream',
249
+ )
250
+ .header('Content-Disposition', `attachment; filename="${downloadName}"`)
251
+ .send(content);
252
+ },
253
+ );
254
+
255
+ // GET /api/plantuml-export/*
256
+ fastify.get<{ Params: { '*': string }; Querystring: { format?: string } }>(
257
+ '/api/plantuml-export/*',
258
+ async (request, reply) => {
259
+ const reqPath = request.params['*'];
260
+ if (!reqPath) return reply.code(400).send({ error: 'Path required' });
261
+
262
+ const pumlFsPath = urlPathToFs(reqPath, roots);
263
+ if (!pumlFsPath) return reply.code(404).send({ error: 'Invalid path' });
264
+ const resolved = path.resolve(pumlFsPath);
265
+
266
+ const ext = path.extname(resolved).toLowerCase();
267
+ if (
268
+ !fs.existsSync(resolved) ||
269
+ !['.puml', '.plantuml', '.pu'].includes(ext)
270
+ ) {
271
+ return reply.code(404).send({ error: 'PlantUML file not found' });
272
+ }
273
+
274
+ const supported = getPlantUmlFormats();
275
+ const format = supported.includes(request.query.format ?? '')
276
+ ? request.query.format!
277
+ : 'svg';
278
+ const source = fs.readFileSync(resolved, 'utf8');
279
+
280
+ const cachedBuffer = getCachedDiagramBuffer('plantuml', source, format);
281
+ if (cachedBuffer) {
282
+ const baseName = path.basename(resolved, ext);
283
+ return reply
284
+ .header(
285
+ 'Content-Type',
286
+ DIAGRAM_CONTENT_TYPES[format] ?? 'application/octet-stream',
287
+ )
288
+ .header(
289
+ 'Content-Disposition',
290
+ `attachment; filename="${baseName}.${format}"`,
291
+ )
292
+ .send(cachedBuffer);
293
+ }
294
+
295
+ const buffer = await renderPlantUmlToBuffer(resolved, format);
296
+ if (!buffer)
297
+ return reply.code(500).send({ error: 'PlantUML render failed' });
298
+
299
+ cacheDiagramBuffer('plantuml', source, buffer, format);
300
+ const baseName = path.basename(resolved, ext);
301
+
302
+ return reply
303
+ .header(
304
+ 'Content-Type',
305
+ DIAGRAM_CONTENT_TYPES[format] ?? 'application/octet-stream',
306
+ )
307
+ .header(
308
+ 'Content-Disposition',
309
+ `attachment; filename="${baseName}.${format}"`,
310
+ )
311
+ .send(buffer);
312
+ },
313
+ );
314
+ };