@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
package/src/server.ts ADDED
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Jeeves Server - Main entry point
3
+ * Fastify server for webhooks, file serving, and markdown rendering
4
+ */
5
+
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { fileURLToPath } from 'node:url';
9
+
10
+ import cookie from '@fastify/cookie';
11
+ import fastifyStatic from '@fastify/static';
12
+ import Fastify from 'fastify';
13
+
14
+ import { getConfig, initConfig, isConfigInitialized } from './config/index.js';
15
+ import { apiRoute } from './routes/api/index.js';
16
+ import { authRoute } from './routes/auth.js';
17
+ import { eventRoute } from './routes/event.js';
18
+ import { healthRoute } from './routes/health.js';
19
+ import { keysRoute } from './routes/keys.js';
20
+ import { pathRoute } from './routes/path/index.js';
21
+ import { staticRoutes } from './routes/static.js';
22
+ import { initDiagramCache } from './services/diagramCache.js';
23
+ import { startQueueProcessor } from './services/eventQueue.js';
24
+ import { initExportCache } from './services/exportCache.js';
25
+
26
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
27
+
28
+ async function start() {
29
+ try {
30
+ const config = isConfigInitialized() ? getConfig() : await initConfig();
31
+
32
+ const fastify = Fastify({
33
+ logger: true,
34
+ });
35
+
36
+ // Register plugins
37
+ await fastify.register(cookie);
38
+
39
+ // X-Robots-Tag on all responses — invisible to search engines
40
+ fastify.addHook('onSend', async (_request, reply) => {
41
+ void reply.header('X-Robots-Tag', 'noindex, nofollow');
42
+ });
43
+
44
+ // Register routes
45
+ await fastify.register(staticRoutes);
46
+ await fastify.register(healthRoute);
47
+ await fastify.register(authRoute);
48
+ await fastify.register(keysRoute);
49
+ await fastify.register(eventRoute);
50
+ await fastify.register(apiRoute);
51
+ await fastify.register(pathRoute);
52
+
53
+ // Serve React SPA (if built)
54
+ const clientDir = path.join(__dirname, '..', 'client');
55
+ if (fs.existsSync(clientDir)) {
56
+ await fastify.register(fastifyStatic, {
57
+ root: clientDir,
58
+ prefix: '/app/',
59
+ });
60
+
61
+ // SPA fallback for React routes
62
+ fastify.get('/', async (_request, reply) => {
63
+ return reply.sendFile('index.html', clientDir);
64
+ });
65
+ fastify.get('/browse', async (_request, reply) => {
66
+ return reply.sendFile('index.html', clientDir);
67
+ });
68
+ fastify.get('/browse/*', async (_request, reply) => {
69
+ return reply.sendFile('index.html', clientDir);
70
+ });
71
+ fastify.get('/runner', async (_request, reply) => {
72
+ return reply.sendFile('index.html', clientDir);
73
+ });
74
+ fastify.get('/runner/*', async (_request, reply) => {
75
+ return reply.sendFile('index.html', clientDir);
76
+ });
77
+ }
78
+
79
+ // Initialize caches
80
+ initDiagramCache(config.diagramCachePath);
81
+ initExportCache();
82
+
83
+ // Start queue processor
84
+ startQueueProcessor();
85
+
86
+ await fastify.listen({ port: config.port, host: '0.0.0.0' });
87
+ console.log(`Jeeves server listening on port ${String(config.port)}`);
88
+ console.log(`Endpoints:`);
89
+ console.log(` GET /browse/* - File browser SPA`);
90
+ console.log(` GET /api/raw/* - Raw file serving`);
91
+ console.log(` GET /api/export/* - PDF/DOCX/ZIP export`);
92
+ console.log(` POST /event - Event Gateway (key auth)`);
93
+ console.log(` GET /key - Compute path key (X-API-Key auth)`);
94
+ console.log(` GET /health - Health check (no auth)`);
95
+ } catch (err) {
96
+ console.error('Fatal startup error:', err);
97
+ process.exit(1);
98
+ }
99
+ }
100
+
101
+ start().catch((err: unknown) => {
102
+ console.error('Unhandled startup error:', err);
103
+ process.exit(1);
104
+ });
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Deep share link rewriting for outsider access with depth traversal.
3
+ *
4
+ * Rewrites outgoing links in rendered HTML with computed sub-keys,
5
+ * enabling outsiders to follow links up to N levels deep.
6
+ */
7
+
8
+ import * as cheerio from 'cheerio';
9
+ import LZString from 'lz-string';
10
+
11
+ import { computeDeepShareKey, type DeepShareParams } from '../util/crypto.js';
12
+
13
+ /**
14
+ * Parse a compressed stack string into an array of paths.
15
+ */
16
+ export function decodeStack(compressed: string): string[] {
17
+ if (!compressed) return [];
18
+ const json = LZString.decompressFromEncodedURIComponent(compressed);
19
+ if (!json) return [];
20
+ try {
21
+ const parsed: unknown = JSON.parse(json);
22
+ return Array.isArray(parsed) ? (parsed as string[]) : [];
23
+ } catch {
24
+ return [];
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Encode a path stack array into a compressed string.
30
+ */
31
+ export function encodeStack(stack: string[]): string {
32
+ return LZString.compressToEncodedURIComponent(JSON.stringify(stack));
33
+ }
34
+
35
+ /**
36
+ * Compute the remaining depth for a given stack.
37
+ */
38
+ export function remainingDepth(maxDepth: number, stack: string[]): number {
39
+ return maxDepth - (stack.length - 1);
40
+ }
41
+
42
+ /**
43
+ * Compute a sub-link URL for an outgoing link target.
44
+ * Returns null if the link should be stripped (depth exhausted or type not allowed).
45
+ */
46
+ export function computeSubLink(
47
+ seed: string,
48
+ targetUrlPath: string,
49
+ currentStack: string[],
50
+ maxDepth: number,
51
+ dirs: boolean,
52
+ exp: string | undefined,
53
+ isDirectory: boolean,
54
+ ): string | null {
55
+ // Check if directories are allowed
56
+ if (isDirectory && !dirs) return null;
57
+
58
+ // Compute new stack
59
+ const existingIndex = currentStack.indexOf(targetUrlPath);
60
+ let newStack: string[];
61
+ if (existingIndex >= 0) {
62
+ // Revisiting — truncate stack to that point
63
+ newStack = currentStack.slice(0, existingIndex + 1);
64
+ } else {
65
+ // New page — append
66
+ newStack = [...currentStack, targetUrlPath];
67
+ }
68
+
69
+ // Check remaining depth
70
+ const remaining = remainingDepth(maxDepth, newStack);
71
+ if (remaining < 0) return null;
72
+
73
+ const compressedStack = encodeStack(newStack);
74
+
75
+ // Compute key for the target
76
+ const params: DeepShareParams = {
77
+ depth: maxDepth,
78
+ dirs,
79
+ stack: compressedStack,
80
+ exp,
81
+ };
82
+ const key = computeDeepShareKey(seed, targetUrlPath, params);
83
+
84
+ // Build URL
85
+ let url = `/browse${targetUrlPath}?key=${key}&d=${String(maxDepth)}&dirs=${dirs ? '1' : '0'}&s=${compressedStack}`;
86
+ if (exp) url += `&exp=${exp}`;
87
+ return url;
88
+ }
89
+
90
+ /**
91
+ * Rewrite outgoing links in rendered HTML for deep share access.
92
+ *
93
+ * For outsiders with depth \> 0:
94
+ * - Internal links get rewritten with sub-keys
95
+ * - Links beyond depth get stripped (text preserved, link removed)
96
+ * - External links (http/https) are left unchanged
97
+ * - Anchor links (#) are left unchanged
98
+ */
99
+ export function rewriteLinksForDeepShare(
100
+ html: string,
101
+ seed: string,
102
+ currentPath: string,
103
+ maxDepth: number,
104
+ dirs: boolean,
105
+ stackCompressed: string,
106
+ exp: string | undefined,
107
+ ): string {
108
+ const currentStack = decodeStack(stackCompressed);
109
+ // Ensure current path is in the stack
110
+ if (
111
+ currentStack.length === 0 ||
112
+ currentStack[currentStack.length - 1] !== currentPath
113
+ ) {
114
+ currentStack.push(currentPath);
115
+ }
116
+
117
+ const remaining = remainingDepth(maxDepth, currentStack);
118
+
119
+ const $ = cheerio.load(html, null, false);
120
+
121
+ // Rewrite <a> tags
122
+ $('a').each((_i, el) => {
123
+ const $el = $(el);
124
+ const href = $el.attr('href');
125
+ if (!href) return;
126
+
127
+ // Skip external links, anchors, and data URLs
128
+ if (
129
+ href.startsWith('http://') ||
130
+ href.startsWith('https://') ||
131
+ href.startsWith('#') ||
132
+ href.startsWith('//') ||
133
+ href.startsWith('data:') ||
134
+ href.startsWith('mailto:')
135
+ ) {
136
+ return;
137
+ }
138
+
139
+ // If no remaining depth, strip the link (keep text)
140
+ if (remaining <= 0) {
141
+ $el.replaceWith($el.html() ?? '');
142
+ return;
143
+ }
144
+
145
+ // Raw file links — leave as-is (these are for images/downloads)
146
+ if (href.startsWith('/api/raw/')) return;
147
+
148
+ // Determine target path
149
+ let targetPath: string;
150
+ if (href.startsWith('/browse/')) {
151
+ targetPath = '/' + href.replace('/browse/', '').split('?')[0];
152
+ } else if (href.startsWith('/')) {
153
+ targetPath = href.split('?')[0];
154
+ } else {
155
+ // Relative link — resolve against current path directory
156
+ const dir = currentPath.substring(0, currentPath.lastIndexOf('/'));
157
+ targetPath = dir
158
+ ? `${dir}/${href.split('?')[0]}`
159
+ : `/${href.split('?')[0]}`;
160
+ }
161
+
162
+ // Normalize
163
+ targetPath = targetPath.replace(/\/+/g, '/');
164
+
165
+ const isDirectory = targetPath.endsWith('/');
166
+ const subLink = computeSubLink(
167
+ seed,
168
+ targetPath,
169
+ currentStack,
170
+ maxDepth,
171
+ dirs,
172
+ exp,
173
+ isDirectory,
174
+ );
175
+
176
+ if (subLink === null) {
177
+ // Strip link, keep text
178
+ $el.replaceWith($el.html() ?? '');
179
+ } else {
180
+ $el.attr('href', subLink);
181
+ }
182
+ });
183
+
184
+ // Rewrite <img> src for images that use /api/raw/ — add key auth
185
+ $('img').each((_i, el) => {
186
+ const $el = $(el);
187
+ const src = $el.attr('src');
188
+ if (!src || !src.startsWith('/api/raw/')) return;
189
+
190
+ const params: DeepShareParams = {
191
+ depth: maxDepth,
192
+ dirs,
193
+ stack: stackCompressed,
194
+ exp,
195
+ };
196
+ const rawPath = '/' + src.replace('/api/raw/', '').split('?')[0];
197
+ const key = computeDeepShareKey(seed, rawPath, params);
198
+ const authSrc = `${src}${src.includes('?') ? '&' : '?'}key=${key}&d=${String(maxDepth)}&dirs=${dirs ? '1' : '0'}&s=${stackCompressed}${exp ? `&exp=${exp}` : ''}`;
199
+ $el.attr('src', authSrc);
200
+ });
201
+
202
+ return $.html();
203
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Content-addressed diagram render cache.
3
+ *
4
+ * Cache key = sha256(type + '\\0' + source). Cached artifacts are SVG strings
5
+ * stored as files in a single directory. No timestamp comparison needed:
6
+ * if the source changes, the hash changes → automatic cache miss.
7
+ */
8
+
9
+ import crypto from 'node:crypto';
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+
13
+ let cacheDir: string | null = null;
14
+
15
+ /**
16
+ * Initialize the cache directory. Call once at startup.
17
+ * Defaults to `.diagram-cache` in the current working directory.
18
+ */
19
+ export function initDiagramCache(dir?: string): void {
20
+ cacheDir = dir ?? path.resolve('.diagram-cache');
21
+ fs.mkdirSync(cacheDir, { recursive: true });
22
+ }
23
+
24
+ /**
25
+ * Get the cache directory path (for diagnostics / health endpoint).
26
+ */
27
+ export function getDiagramCacheDir(): string | null {
28
+ return cacheDir;
29
+ }
30
+
31
+ /**
32
+ * Compute the cache key for a diagram.
33
+ */
34
+ function cacheKey(type: string, source: string): string {
35
+ return crypto.createHash('sha256').update(`${type}\0${source}`).digest('hex');
36
+ }
37
+
38
+ /**
39
+ * Look up a cached diagram. Returns the content as a string or null on miss.
40
+ * @param format - Output format extension (e.g. 'svg', 'png', 'pdf'). Defaults to 'svg'.
41
+ */
42
+ export function getCachedDiagram(
43
+ type: string,
44
+ source: string,
45
+ format: string = 'svg',
46
+ ): string | null {
47
+ if (!cacheDir) return null;
48
+ const file = path.join(cacheDir, `${cacheKey(type, source)}.${format}`);
49
+ try {
50
+ return fs.readFileSync(file, 'utf8');
51
+ } catch {
52
+ return null;
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Look up a cached diagram as a Buffer (for binary formats like PNG/PDF).
58
+ * @param format - Output format extension (e.g. 'png', 'pdf').
59
+ */
60
+ export function getCachedDiagramBuffer(
61
+ type: string,
62
+ source: string,
63
+ format: string,
64
+ ): Buffer | null {
65
+ if (!cacheDir) return null;
66
+ const file = path.join(cacheDir, `${cacheKey(type, source)}.${format}`);
67
+ try {
68
+ return fs.readFileSync(file);
69
+ } catch {
70
+ return null;
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Store a rendered diagram in the cache (string content).
76
+ * @param format - Output format extension. Defaults to 'svg'.
77
+ */
78
+ export function cacheDiagram(
79
+ type: string,
80
+ source: string,
81
+ content: string,
82
+ format: string = 'svg',
83
+ ): void {
84
+ if (!cacheDir) return;
85
+ const file = path.join(cacheDir, `${cacheKey(type, source)}.${format}`);
86
+ try {
87
+ fs.writeFileSync(file, content, 'utf8');
88
+ } catch (err) {
89
+ console.error('[diagramCache] write failed:', (err as Error).message);
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Store a rendered diagram in the cache (binary content).
95
+ * @param format - Output format extension (e.g. 'png', 'pdf').
96
+ */
97
+ export function cacheDiagramBuffer(
98
+ type: string,
99
+ source: string,
100
+ buffer: Buffer,
101
+ format: string,
102
+ ): void {
103
+ if (!cacheDir) return;
104
+ const file = path.join(cacheDir, `${cacheKey(type, source)}.${format}`);
105
+ try {
106
+ fs.writeFileSync(file, buffer);
107
+ } catch (err) {
108
+ console.error('[diagramCache] write failed:', (err as Error).message);
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Get a cached diagram or render it and cache the result.
114
+ * Eliminates the repeated getCachedDiagram → render → cacheDiagram pattern.
115
+ */
116
+ export async function getOrRenderDiagram(
117
+ type: string,
118
+ source: string,
119
+ renderFn: () => string | null | Promise<string | null>,
120
+ format: string = 'svg',
121
+ ): Promise<string | null> {
122
+ const cached = getCachedDiagram(type, source, format);
123
+ if (cached) return cached;
124
+
125
+ const result = await renderFn();
126
+ if (result) cacheDiagram(type, source, result, format);
127
+ return result;
128
+ }
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Embedded diagram support for Mermaid and PlantUML code blocks in markdown.
3
+ *
4
+ * Strategy: parseMarkdown() calls registerDiagram() which stores the source
5
+ * and returns a placeholder div. The client lazily fetches rendered SVGs
6
+ * via GET /api/diagram/:type/:hash.svg, which renders on-demand and caches.
7
+ *
8
+ * For PDF/DOCX export (server-side rendering), renderEmbeddedDiagrams()
9
+ * replaces placeholders with inline SVGs synchronously.
10
+ */
11
+
12
+ import crypto from 'node:crypto';
13
+
14
+ import { getOrRenderDiagram } from './diagramCache.js';
15
+ import { renderMermaidFromSource } from './mermaid.js';
16
+ import { renderPlantUmlFromSource } from './plantuml.js';
17
+
18
+ /** Placeholder format used by the markdown renderer */
19
+ const PLACEHOLDER_RE = /<!--DIAGRAM:(mermaid|plantuml):([a-f0-9]{64})-->/g;
20
+
21
+ /**
22
+ * In-flight diagram sources, keyed by content hash.
23
+ * Entries are cleaned up after a TTL to prevent unbounded growth.
24
+ */
25
+ const diagramSources = new Map<
26
+ string,
27
+ { source: string; contextDir?: string; createdAt: number }
28
+ >();
29
+
30
+ /** TTL for source map entries (10 minutes) */
31
+ const SOURCE_TTL_MS = 10 * 60 * 1000;
32
+
33
+ /** Periodic cleanup interval */
34
+ let cleanupInterval: ReturnType<typeof setInterval> | null = null;
35
+
36
+ function startCleanup(): void {
37
+ if (cleanupInterval) return;
38
+ cleanupInterval = setInterval(() => {
39
+ const now = Date.now();
40
+ for (const [hash, entry] of diagramSources) {
41
+ if (now - entry.createdAt > SOURCE_TTL_MS) {
42
+ diagramSources.delete(hash);
43
+ }
44
+ }
45
+ }, 60_000);
46
+ // Don't keep process alive just for cleanup
47
+ cleanupInterval.unref();
48
+ }
49
+
50
+ /**
51
+ * Compute content hash matching the cache key format.
52
+ */
53
+ export function diagramHash(type: string, source: string): string {
54
+ return crypto.createHash('sha256').update(`${type}\0${source}`).digest('hex');
55
+ }
56
+
57
+ /** Module-level context directory for the current markdown parse. */
58
+ let currentContextDir: string | undefined;
59
+
60
+ /**
61
+ * Set the context directory for diagram registration.
62
+ * Call before parseMarkdown() so registered diagrams know their !include context.
63
+ */
64
+ export function setDiagramContext(contextDir?: string): void {
65
+ currentContextDir = contextDir;
66
+ }
67
+
68
+ /**
69
+ * Register a diagram source and return a placeholder HTML div.
70
+ * Called synchronously from the marked renderer.
71
+ */
72
+ export function registerDiagram(
73
+ type: 'mermaid' | 'plantuml',
74
+ source: string,
75
+ ): string {
76
+ const hash = diagramHash(type, source);
77
+ diagramSources.set(hash, {
78
+ source,
79
+ contextDir: currentContextDir,
80
+ createdAt: Date.now(),
81
+ });
82
+ startCleanup();
83
+
84
+ // Emit a client-side placeholder that the LazyDiagram component will hydrate
85
+ return `<div class="embedded-diagram-lazy" data-diagram-type="${type}" data-diagram-hash="${hash}"><!--DIAGRAM:${type}:${hash}--></div>\n`;
86
+ }
87
+
88
+ /**
89
+ * Look up a registered diagram source by hash.
90
+ * Used by the /api/diagram endpoint for on-demand rendering.
91
+ */
92
+ export function getDiagramSource(
93
+ hash: string,
94
+ ): { type: string; source: string; contextDir?: string } | null {
95
+ const entry = diagramSources.get(hash);
96
+ if (!entry) return null;
97
+ return { type: '', source: entry.source, contextDir: entry.contextDir };
98
+ }
99
+
100
+ /**
101
+ * Render a diagram to SVG (with cache). Used by the /api/diagram endpoint.
102
+ */
103
+ export async function renderDiagramToSvg(
104
+ type: string,
105
+ source: string,
106
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
107
+ _contextDir?: string,
108
+ ): Promise<string | null> {
109
+ try {
110
+ return await getOrRenderDiagram(type, source, () => {
111
+ if (type === 'mermaid') return renderMermaidFromSource(source);
112
+ if (type === 'plantuml') return renderPlantUmlFromSource(source);
113
+ return null;
114
+ });
115
+ } catch (err) {
116
+ console.error(
117
+ `[embeddedDiagrams] ${type} render failed:`,
118
+ (err as Error).message,
119
+ );
120
+ return null;
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Replace all diagram placeholders in rendered HTML with inline SVGs.
126
+ * Used for PDF/DOCX export where client-side lazy loading isn't available.
127
+ */
128
+ export async function renderEmbeddedDiagrams(
129
+ html: string,
130
+ contextDir?: string,
131
+ ): Promise<string> {
132
+ const matches = [...html.matchAll(PLACEHOLDER_RE)];
133
+ if (matches.length === 0) return html;
134
+
135
+ let result = html;
136
+ for (const match of matches) {
137
+ const [placeholder, type, hash] = match;
138
+ const entry = diagramSources.get(hash);
139
+ if (!entry) continue;
140
+ const source = entry.source;
141
+ if (!source) continue;
142
+
143
+ const svg = await renderDiagramToSvg(
144
+ type,
145
+ source,
146
+ contextDir ?? entry.contextDir,
147
+ );
148
+
149
+ if (svg) {
150
+ const wrapped = `<div class="embedded-diagram-rendered" data-type="${type}">${svg}</div>`;
151
+ result = result.replace(placeholder, wrapped);
152
+ } else {
153
+ const escaped = escapeHtml(source);
154
+ const errorBlock = `<div class="embedded-diagram-error" data-type="${type}"><div class="diagram-error-label">${type} render failed</div><pre class="hljs"><code>${escaped}</code></pre></div>`;
155
+ result = result.replace(placeholder, errorBlock);
156
+ }
157
+ }
158
+
159
+ return result;
160
+ }
161
+
162
+ function escapeHtml(str: string): string {
163
+ return str
164
+ .replace(/&/g, '&amp;')
165
+ .replace(/</g, '&lt;')
166
+ .replace(/>/g, '&gt;')
167
+ .replace(/"/g, '&quot;');
168
+ }