@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,220 @@
1
+ /**
2
+ * Export service for PDF and DOCX generation.
3
+ *
4
+ * Orchestrates Puppeteer page loading, print styling, and format-specific
5
+ * export logic. Heavy lifting delegated to puppeteer.ts utilities.
6
+ */
7
+ import HtmlToDocx from '@turbodocx/html-to-docx';
8
+ import { addPrintStyles, captureSvgsAsPng, launchBrowser, SVG_CONTAINER_SELECTORS, waitForSpaContent, } from './puppeteer.js';
9
+ // Max bounds for images in DOCX (6 inches × 8 inches at 96dpi)
10
+ const MAX_WIDTH_PX = 576;
11
+ const MAX_HEIGHT_PX = 768;
12
+ /**
13
+ * Export page as PDF.
14
+ */
15
+ export async function exportPDF(options) {
16
+ const browser = await launchBrowser();
17
+ try {
18
+ const page = await browser.newPage();
19
+ await page.goto(options.url, { waitUntil: 'networkidle0' });
20
+ await waitForSpaContent(page);
21
+ await addPrintStyles(page);
22
+ const pdfBuffer = await page.pdf({
23
+ format: 'A4',
24
+ margin: { top: '1cm', right: '1cm', bottom: '1cm', left: '1cm' },
25
+ printBackground: true,
26
+ });
27
+ return Buffer.from(pdfBuffer);
28
+ }
29
+ finally {
30
+ await browser.close();
31
+ }
32
+ }
33
+ /**
34
+ * Export page as DOCX.
35
+ */
36
+ export async function exportDOCX(options) {
37
+ const browser = await launchBrowser();
38
+ try {
39
+ const page = await browser.newPage();
40
+ await page.setViewport({ width: 1200, height: 800 });
41
+ await page.goto(options.url, { waitUntil: 'networkidle0' });
42
+ await waitForSpaContent(page);
43
+ await addPrintStyles(page);
44
+ // Capture SVGs as PNGs for DOCX embedding
45
+ const svgPngDataUrls = await captureSvgsAsPng(browser, page);
46
+ // Get processed HTML with SVGs replaced by PNGs
47
+ const processedHtml = await page.evaluate((pngUrls, maxW, maxH, selectors) => {
48
+ function calcScaled(origW, origH) {
49
+ let w = origW, h = origH;
50
+ if (w > maxW) {
51
+ const s = maxW / w;
52
+ w = maxW;
53
+ h = Math.round(h * s);
54
+ }
55
+ if (h > maxH) {
56
+ const s = maxH / h;
57
+ h = maxH;
58
+ w = Math.round(w * s);
59
+ }
60
+ return { width: w, height: h };
61
+ }
62
+ const content = document.querySelector('article.prose') ??
63
+ document.querySelector('.content');
64
+ if (!content)
65
+ return '<p>No content</p>';
66
+ const contentClone = content.cloneNode(true);
67
+ contentClone.querySelectorAll('a.anchor').forEach((el) => {
68
+ el.remove();
69
+ });
70
+ // Replace SVG containers with PNG images
71
+ const svgContainers = contentClone.querySelectorAll(selectors);
72
+ svgContainers.forEach((container, i) => {
73
+ const pngInfo = pngUrls.find((p) => p.index === i);
74
+ if (pngInfo) {
75
+ const img = document.createElement('img');
76
+ img.src = pngInfo.dataUrl;
77
+ img.alt = 'Diagram';
78
+ const dims = calcScaled(pngInfo.width, pngInfo.height);
79
+ img.setAttribute('width', String(dims.width));
80
+ img.setAttribute('height', String(dims.height));
81
+ container.replaceWith(img);
82
+ }
83
+ else {
84
+ const p = document.createElement('p');
85
+ p.textContent = '[Diagram]';
86
+ p.style.fontStyle = 'italic';
87
+ container.replaceWith(p);
88
+ }
89
+ });
90
+ // Fix image URLs and set explicit dimensions
91
+ contentClone.querySelectorAll('img').forEach((img) => {
92
+ if (img.src && !img.src.startsWith('data:')) {
93
+ img.src = new URL(img.src, window.location.origin).href;
94
+ }
95
+ const origW = img.naturalWidth || img.width || 400;
96
+ const origH = img.naturalHeight || img.height || 300;
97
+ const dims = calcScaled(origW, origH);
98
+ img.setAttribute('width', String(dims.width));
99
+ img.setAttribute('height', String(dims.height));
100
+ img.style.maxWidth = '';
101
+ img.style.maxHeight = '';
102
+ });
103
+ // Inline styles for tables
104
+ contentClone.querySelectorAll('table').forEach((t) => {
105
+ t.setAttribute('border', '1');
106
+ t.style.borderCollapse = 'collapse';
107
+ t.style.width = '100%';
108
+ });
109
+ contentClone.querySelectorAll('th').forEach((th) => {
110
+ th.style.backgroundColor = '#f0f0f0';
111
+ th.style.fontWeight = 'bold';
112
+ th.style.padding = '8px';
113
+ th.style.border = '1px solid #999';
114
+ });
115
+ contentClone.querySelectorAll('td').forEach((td) => {
116
+ td.style.padding = '8px';
117
+ td.style.border = '1px solid #999';
118
+ });
119
+ // Inline styles for code blocks — wrap in single-cell table for
120
+ // consistent block background in DOCX (html-to-docx doesn't support
121
+ // background-color on pre as a block fill)
122
+ contentClone.querySelectorAll('pre').forEach((pre) => {
123
+ const text = pre.textContent || '';
124
+ const table = document.createElement('table');
125
+ table.setAttribute('border', '1');
126
+ table.style.borderCollapse = 'collapse';
127
+ table.style.width = '100%';
128
+ table.style.marginTop = '8px';
129
+ table.style.marginBottom = '8px';
130
+ const tr = document.createElement('tr');
131
+ const td = document.createElement('td');
132
+ td.style.backgroundColor = '#f5f5f5';
133
+ td.style.border = '1px solid #ddd';
134
+ td.style.padding = '12px';
135
+ // Build one <p> per line with monospace styling and &nbsp; for
136
+ // indentation. html-to-docx converts each <p> to a Word paragraph
137
+ // cleanly, without the double-spacing that <br> causes.
138
+ const lines = text.split('\n');
139
+ lines.forEach((line) => {
140
+ const p = document.createElement('p');
141
+ p.style.fontFamily = 'Consolas, monospace';
142
+ p.style.fontSize = '9pt';
143
+ p.style.margin = '0';
144
+ p.style.lineHeight = '1.3';
145
+ // Preserve leading whitespace with &nbsp;
146
+ const leadingSpaces = line.match(/^( +)/);
147
+ if (leadingSpaces) {
148
+ const nbsp = '\u00A0'.repeat(leadingSpaces[1].length);
149
+ p.textContent = nbsp + line.slice(leadingSpaces[1].length);
150
+ }
151
+ else {
152
+ p.textContent = line || '\u00A0'; // empty lines need content
153
+ }
154
+ td.appendChild(p);
155
+ });
156
+ tr.appendChild(td);
157
+ table.appendChild(tr);
158
+ pre.replaceWith(table);
159
+ });
160
+ return contentClone.innerHTML;
161
+ }, svgPngDataUrls, MAX_WIDTH_PX, MAX_HEIGHT_PX, SVG_CONTAINER_SELECTORS);
162
+ await browser.close();
163
+ const fullHtml = buildDocxHtml(processedHtml);
164
+ const baseName = options.fileName.replace(/\.md$/i, '');
165
+ const docxBuffer = await HtmlToDocx(fullHtml, null, {
166
+ title: baseName,
167
+ creator: 'Jeeves Server',
168
+ table: { row: { cantSplit: true } },
169
+ imageProcessing: {
170
+ svgHandling: 'native',
171
+ maxRetries: 2,
172
+ downloadTimeout: 15000,
173
+ },
174
+ });
175
+ return Buffer.isBuffer(docxBuffer)
176
+ ? docxBuffer
177
+ : Buffer.from(docxBuffer);
178
+ }
179
+ finally {
180
+ await browser.close();
181
+ }
182
+ }
183
+ /**
184
+ * Export page based on format.
185
+ */
186
+ export async function exportPage(options) {
187
+ return options.format === 'pdf' ? exportPDF(options) : exportDOCX(options);
188
+ }
189
+ /** Build a clean HTML document for DOCX conversion. */
190
+ function buildDocxHtml(bodyContent) {
191
+ return `<!DOCTYPE html>
192
+ <html>
193
+ <head>
194
+ <meta charset="UTF-8">
195
+ <style>
196
+ body { font-family: Calibri, Arial, sans-serif; font-size: 10pt; line-height: 1.6; }
197
+ h1 { font-size: 18pt; font-weight: bold; color: #1a1a1a; margin-top: 20pt; margin-bottom: 10pt; }
198
+ h2 { font-size: 14pt; font-weight: bold; color: #2a2a2a; margin-top: 16pt; margin-bottom: 8pt; }
199
+ h3 { font-size: 12pt; font-weight: bold; color: #3a3a3a; margin-top: 12pt; margin-bottom: 6pt; }
200
+ h4 { font-size: 10pt; font-weight: bold; color: #4a4a4a; margin-top: 10pt; margin-bottom: 5pt; }
201
+ p { margin: 5pt 0; }
202
+ code { font-family: Consolas, 'Courier New', monospace; font-size: 9pt; background-color: #f4f4f4; padding: 2pt 4pt; }
203
+ pre { font-family: Consolas, 'Courier New', monospace; font-size: 8pt; background-color: #f8f8f8; border: 1pt solid #ddd; padding: 10pt; margin: 10pt 0; white-space: pre-wrap; word-wrap: break-word; }
204
+ pre code { background-color: transparent; padding: 0; }
205
+ table { border-collapse: collapse; width: 100%; margin: 12pt 0; }
206
+ th { background-color: #f0f0f0; font-weight: bold; border: 1pt solid #999; padding: 8pt; text-align: left; }
207
+ td { border: 1pt solid #999; padding: 8pt; text-align: left; }
208
+ tr:nth-child(even) td { background-color: #fafafa; }
209
+ blockquote { border-left: 4pt solid #ddd; margin: 12pt 0; padding: 6pt 12pt; color: #666; }
210
+ ul, ol { margin: 6pt 0; padding-left: 24pt; }
211
+ li { margin: 4pt 0; }
212
+ a { color: #0066cc; }
213
+ img, svg { margin: 12pt 0; display: block; }
214
+ </style>
215
+ </head>
216
+ <body>
217
+ ${bodyContent}
218
+ </body>
219
+ </html>`;
220
+ }
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Export cache for PDF/DOCX renders.
3
+ *
4
+ * Cache key = sha256(normalizedFsPath + NUL + format).
5
+ * Invalidation: source file mtime greater than cache file mtime = miss.
6
+ * Also maintains a reverse index (fsPath to diagram cache hashes) so
7
+ * "Clear Cache" can purge both export and diagram caches for a file.
8
+ */
9
+ import crypto from 'node:crypto';
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+ let cacheDir = null;
13
+ /** Diagram type for a given file extension. */
14
+ const DIAGRAM_EXT_MAP = {
15
+ '.mmd': 'mermaid',
16
+ '.puml': 'plantuml',
17
+ '.plantuml': 'plantuml',
18
+ '.pu': 'plantuml',
19
+ };
20
+ /** Initialize the export cache directory. Call once at startup. */
21
+ export function initExportCache(dir) {
22
+ cacheDir = dir ?? path.resolve('.export-cache');
23
+ fs.mkdirSync(cacheDir, { recursive: true });
24
+ }
25
+ function cacheKey(fsPath, format) {
26
+ const normalized = fsPath.replace(/\\/g, '/').toLowerCase();
27
+ return crypto
28
+ .createHash('sha256')
29
+ .update(`${normalized}\0${format}`)
30
+ .digest('hex');
31
+ }
32
+ function cacheFilePath(fsPath, format) {
33
+ if (!cacheDir)
34
+ return null;
35
+ return path.join(cacheDir, `${cacheKey(fsPath, format)}.${format}`);
36
+ }
37
+ /**
38
+ * Get a cached export buffer if it exists and is fresh.
39
+ * Returns null on miss or stale cache.
40
+ */
41
+ export function getCachedExport(fsPath, format) {
42
+ const file = cacheFilePath(fsPath, format);
43
+ if (!file)
44
+ return null;
45
+ try {
46
+ const sourceStat = fs.statSync(fsPath);
47
+ const cacheStat = fs.statSync(file);
48
+ if (cacheStat.mtimeMs >= sourceStat.mtimeMs) {
49
+ return fs.readFileSync(file);
50
+ }
51
+ // Stale
52
+ return null;
53
+ }
54
+ catch {
55
+ return null;
56
+ }
57
+ }
58
+ /** Store an export buffer in the cache. */
59
+ export function cacheExport(fsPath, format, buffer) {
60
+ const file = cacheFilePath(fsPath, format);
61
+ if (!file)
62
+ return;
63
+ try {
64
+ fs.writeFileSync(file, buffer);
65
+ }
66
+ catch (err) {
67
+ console.error('[exportCache] write failed:', err.message);
68
+ }
69
+ }
70
+ /**
71
+ * Clear all cached exports for a given file path.
72
+ * Returns the number of files deleted.
73
+ */
74
+ export function clearExportCache(fsPath) {
75
+ if (!cacheDir)
76
+ return 0;
77
+ let count = 0;
78
+ for (const format of ['pdf', 'docx']) {
79
+ const file = cacheFilePath(fsPath, format);
80
+ if (file) {
81
+ try {
82
+ fs.unlinkSync(file);
83
+ count++;
84
+ }
85
+ catch {
86
+ // Not cached, that's fine
87
+ }
88
+ }
89
+ }
90
+ return count;
91
+ }
92
+ /**
93
+ * Clear standalone diagram cache entries for a diagram source file.
94
+ * Reads the file, computes the content-addressed hash, and removes
95
+ * all format variants (svg, png, pdf, eps) from the diagram cache.
96
+ */
97
+ export function clearStandaloneDiagramCache(fsPath, diagramCacheDir) {
98
+ if (!diagramCacheDir)
99
+ return 0;
100
+ const ext = path.extname(fsPath).toLowerCase();
101
+ const diagramType = DIAGRAM_EXT_MAP[ext];
102
+ if (!diagramType)
103
+ return 0;
104
+ let source;
105
+ try {
106
+ source = fs.readFileSync(fsPath, 'utf8');
107
+ }
108
+ catch {
109
+ return 0;
110
+ }
111
+ const hash = crypto
112
+ .createHash('sha256')
113
+ .update(`${diagramType}\0${source}`)
114
+ .digest('hex');
115
+ let count = 0;
116
+ for (const fmt of ['svg', 'png', 'pdf', 'eps']) {
117
+ try {
118
+ fs.unlinkSync(path.join(diagramCacheDir, `${hash}.${fmt}`));
119
+ count++;
120
+ }
121
+ catch {
122
+ // Not present
123
+ }
124
+ }
125
+ return count;
126
+ }
127
+ // --- Reverse index: fsPath → diagram cache hashes ---
128
+ const reverseIndexFile = () => cacheDir ? path.join(cacheDir, '_diagram-index.json') : null;
129
+ function loadDiagramIndex() {
130
+ const file = reverseIndexFile();
131
+ if (!file)
132
+ return {};
133
+ try {
134
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
135
+ }
136
+ catch {
137
+ return {};
138
+ }
139
+ }
140
+ function saveDiagramIndex(index) {
141
+ const file = reverseIndexFile();
142
+ if (!file)
143
+ return;
144
+ try {
145
+ fs.writeFileSync(file, JSON.stringify(index, null, 2), 'utf8');
146
+ }
147
+ catch (err) {
148
+ console.error('[exportCache] index write failed:', err.message);
149
+ }
150
+ }
151
+ /**
152
+ * Register diagram cache hashes associated with a file.
153
+ * Called during diagram rendering to build the reverse index.
154
+ */
155
+ export function registerDiagramHashes(fsPath, hashes) {
156
+ if (!cacheDir || hashes.length === 0)
157
+ return;
158
+ const key = fsPath.replace(/\\/g, '/').toLowerCase();
159
+ const index = loadDiagramIndex();
160
+ const existing = new Set(index[key] ?? []);
161
+ for (const h of hashes)
162
+ existing.add(h);
163
+ index[key] = [...existing];
164
+ saveDiagramIndex(index);
165
+ }
166
+ /**
167
+ * Clear diagram cache entries associated with a file.
168
+ * Returns the number of diagram cache files deleted.
169
+ */
170
+ export function clearDiagramCacheForFile(fsPath, diagramCacheDir) {
171
+ if (!cacheDir || !diagramCacheDir)
172
+ return 0;
173
+ const key = fsPath.replace(/\\/g, '/').toLowerCase();
174
+ const index = loadDiagramIndex();
175
+ const hashes = index[key];
176
+ if (!hashes || hashes.length === 0)
177
+ return 0;
178
+ let count = 0;
179
+ for (const hash of hashes) {
180
+ // Diagram cache files can be .svg, .png, or .pdf
181
+ for (const ext of ['svg', 'png', 'pdf']) {
182
+ try {
183
+ fs.unlinkSync(path.join(diagramCacheDir, `${hash}.${ext}`));
184
+ count++;
185
+ }
186
+ catch {
187
+ // Not present
188
+ }
189
+ }
190
+ }
191
+ // Remove from index
192
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
193
+ const { [key]: _removed, ...rest } = index;
194
+ saveDiagramIndex(rest);
195
+ return count;
196
+ }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Markdown rendering with TOC generation, Windows path linking, and syntax highlighting
3
+ */
4
+ import fs from 'node:fs';
5
+ import { marked } from 'marked';
6
+ import { registerDiagram } from './embeddedDiagrams.js';
7
+ /**
8
+ * Resolve a filesystem path for linking (skip non-existent and templated paths)
9
+ */
10
+ function resolvePathForLink(fsPath) {
11
+ // Skip templated paths
12
+ if (fsPath.includes('{') || fsPath.includes('}'))
13
+ return null;
14
+ if (!fs.existsSync(fsPath))
15
+ return null;
16
+ return fsPath;
17
+ }
18
+ const IS_WINDOWS = process.platform === 'win32';
19
+ /**
20
+ * Convert platform-native filesystem paths in markdown text to clickable browse links.
21
+ * Windows: C:\\foo\\bar → [C:\\foo\\bar](/browse/c/foo/bar)
22
+ * Linux: /home/user/docs → [/home/user/docs](/browse/home/user/docs)
23
+ */
24
+ function linkifyFilesystemPaths(markdown) {
25
+ // Platform-specific path regex
26
+ const pathRegex = IS_WINDOWS
27
+ ? /([A-Z]):\\(?:[^\s"'`<>\\]+\\)*[^\s"'`<>\\]+/g
28
+ : /(?<=\s|^)(\/(?:home|opt|var|tmp|etc|usr|srv|mnt|media)\/[^\s"'`<>]+)/gm;
29
+ const linkifyPath = (fsPath) => {
30
+ const resolved = resolvePathForLink(fsPath);
31
+ if (!resolved)
32
+ return fsPath;
33
+ let urlPath;
34
+ if (IS_WINDOWS) {
35
+ urlPath =
36
+ '/' +
37
+ resolved
38
+ .replace(/\\/g, '/')
39
+ .replace(/^([A-Z]):/, (_m, d) => d.toLowerCase());
40
+ }
41
+ else {
42
+ urlPath = resolved;
43
+ }
44
+ return `[${fsPath}](/browse${urlPath})`;
45
+ };
46
+ // Split by code blocks and inline code
47
+ const codeBlockRegex = /(```[\s\S]*?```|`[^`\n]+`)/g;
48
+ const parts = markdown.split(codeBlockRegex);
49
+ return parts
50
+ .map((part) => {
51
+ if (part.startsWith('```') || part.startsWith('`')) {
52
+ return part; // Don't modify code
53
+ }
54
+ return part.replace(pathRegex, linkifyPath);
55
+ })
56
+ .join('');
57
+ }
58
+ /**
59
+ * Extract YAML frontmatter from markdown if present.
60
+ * Returns the frontmatter content (without delimiters) and the remaining markdown.
61
+ */
62
+ function extractFrontmatter(markdown) {
63
+ const match = markdown.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n/);
64
+ if (!match)
65
+ return { frontmatter: null, body: markdown };
66
+ return { frontmatter: match[1], body: markdown.slice(match[0].length) };
67
+ }
68
+ /**
69
+ * Parse markdown to HTML with heading extraction
70
+ */
71
+ export function parseMarkdown(markdown, options = {}) {
72
+ // Extract frontmatter before processing
73
+ const { frontmatter, body } = extractFrontmatter(markdown);
74
+ let processedMarkdown = body;
75
+ // Optionally linkify Windows paths
76
+ if (options.linkWindowsPaths) {
77
+ processedMarkdown = linkifyFilesystemPaths(processedMarkdown);
78
+ }
79
+ const headings = [];
80
+ // Custom renderer to extract headings and add anchors
81
+ const renderer = new marked.Renderer();
82
+ renderer.heading = function (args) {
83
+ const text = typeof args === 'object' ? args.text : args;
84
+ const raw = typeof args === 'object' && args.raw ? args.raw : text;
85
+ const level = typeof args === 'object' ? args.depth : 1;
86
+ const slug = raw
87
+ .toLowerCase()
88
+ .replace(/<[^>]+>/g, '')
89
+ .replace(/[^\w\s-]/g, '')
90
+ .replace(/\s+/g, '-')
91
+ .replace(/-+/g, '-')
92
+ .replace(/^-|-$/g, '');
93
+ headings.push({ level, text: text.replace(/<[^>]+>/g, ''), slug });
94
+ return `<h${String(level)} id="${slug}">${text} <a href="#${slug}" class="anchor">#</a></h${String(level)}>\n`;
95
+ };
96
+ // Rewrite relative image src to /path/ URLs
97
+ if (options.basePath) {
98
+ const base = options.basePath;
99
+ renderer.image = function (args) {
100
+ const href = typeof args === 'object' ? args.href : args;
101
+ const title = typeof args === 'object' ? args.title : '';
102
+ const text = typeof args === 'object' ? args.text : '';
103
+ let src = href;
104
+ if (src && !src.startsWith('http') && !src.startsWith('data:')) {
105
+ // Rewrite relative paths to /api/raw/ for file serving
106
+ if (!src.startsWith('/')) {
107
+ src = `/api/raw/${base}/${src}`;
108
+ }
109
+ else if (src.startsWith('/path/')) {
110
+ // Legacy /path/ references → /api/raw/
111
+ src = src.replace('/path/', '/api/raw/');
112
+ }
113
+ }
114
+ const titleAttr = title ? ` title="${title}"` : '';
115
+ return `<img src="${src}" alt="${text}"${titleAttr} />`;
116
+ };
117
+ }
118
+ // Syntax-highlight fenced code blocks; render mermaid/plantuml as diagrams
119
+ renderer.code = function (args) {
120
+ const text = typeof args === 'object' ? args.text : args;
121
+ const lang = (typeof args === 'object' ? args.lang : undefined)?.toLowerCase();
122
+ // Diagram code blocks → register for async rendering (GitHub convention)
123
+ if (lang === 'mermaid') {
124
+ return registerDiagram('mermaid', text);
125
+ }
126
+ if (lang === 'plantuml' || lang === 'puml') {
127
+ return registerDiagram('plantuml', text);
128
+ }
129
+ const escaped = text
130
+ .replace(/&/g, '&amp;')
131
+ .replace(/</g, '&lt;')
132
+ .replace(/>/g, '&gt;');
133
+ const langClass = lang ? ` class="language-${lang}"` : '';
134
+ return `<pre><code${langClass}>${escaped}</code></pre>\n`;
135
+ };
136
+ marked.setOptions({ renderer });
137
+ let html = marked(processedMarkdown);
138
+ // Prepend frontmatter as a rendered YAML code block
139
+ if (frontmatter) {
140
+ const escaped = frontmatter
141
+ .replace(/&/g, '&amp;')
142
+ .replace(/</g, '&lt;')
143
+ .replace(/>/g, '&gt;');
144
+ html = `<div class="frontmatter-block"><pre><code class="language-yaml">${escaped}</code></pre></div>\n${html}`;
145
+ }
146
+ return { html, headings };
147
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Mermaid diagram rendering service.
3
+ *
4
+ * Uses \@mermaid-js/mermaid-cli programmatic API with the server's configured Chrome.
5
+ * Falls back to execSync shell-out if the programmatic API fails to load.
6
+ */
7
+ import fs from 'node:fs';
8
+ import os from 'node:os';
9
+ import path from 'node:path';
10
+ import { getConfig } from '../config/index.js';
11
+ /**
12
+ * Render Mermaid source string to SVG via the mermaid-cli programmatic API.
13
+ *
14
+ * Uses the server's configured chromePath via puppeteerConfig.executablePath,
15
+ * avoiding a separate Chromium download.
16
+ *
17
+ * Returns null on failure.
18
+ */
19
+ export async function renderMermaidFromSource(source) {
20
+ const config = getConfig();
21
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mmd-'));
22
+ const inFile = path.join(tmpDir, 'diagram.mmd');
23
+ const outFile = path.join(tmpDir, 'diagram.svg');
24
+ try {
25
+ fs.writeFileSync(inFile, source, 'utf8');
26
+ // Dynamic import to avoid loading puppeteer at module parse time
27
+ const { run } = await import('@mermaid-js/mermaid-cli');
28
+ await run(inFile, outFile, {
29
+ quiet: true,
30
+ outputFormat: 'svg',
31
+ puppeteerConfig: {
32
+ executablePath: config.chromePath,
33
+ headless: 'shell',
34
+ args: ['--no-sandbox', '--disable-setuid-sandbox'],
35
+ },
36
+ parseMMDOptions: {
37
+ backgroundColor: 'white',
38
+ viewport: { width: 1600, height: 1200, deviceScaleFactor: 2 },
39
+ },
40
+ });
41
+ if (!fs.existsSync(outFile))
42
+ return null;
43
+ return fs.readFileSync(outFile, 'utf8');
44
+ }
45
+ catch (err) {
46
+ console.error('[mermaid] render failed:', err.message);
47
+ return null;
48
+ }
49
+ finally {
50
+ try {
51
+ fs.rmSync(tmpDir, { recursive: true, force: true });
52
+ }
53
+ catch {
54
+ /* ignore */
55
+ }
56
+ }
57
+ }
58
+ /**
59
+ * Render a .mmd file to SVG string.
60
+ * Returns null on failure.
61
+ */
62
+ export async function renderMermaidSvg(inputPath) {
63
+ const source = fs.readFileSync(inputPath, 'utf8');
64
+ return renderMermaidFromSource(source);
65
+ }
66
+ /**
67
+ * Render a .mmd file to the specified format and return the output file path.
68
+ * The caller is responsible for reading and cleaning up the output file.
69
+ * Returns null on failure.
70
+ */
71
+ export async function renderMermaidToFile(inputPath, format) {
72
+ const config = getConfig();
73
+ const resolved = path.resolve(inputPath);
74
+ const outFile = path.join(path.dirname(resolved), `${path.basename(resolved, '.mmd')}.${format}`);
75
+ try {
76
+ const { run } = await import('@mermaid-js/mermaid-cli');
77
+ await run(resolved, outFile, {
78
+ quiet: true,
79
+ outputFormat: format,
80
+ puppeteerConfig: {
81
+ executablePath: config.chromePath,
82
+ headless: 'shell',
83
+ args: ['--no-sandbox', '--disable-setuid-sandbox'],
84
+ },
85
+ parseMMDOptions: {
86
+ backgroundColor: 'white',
87
+ viewport: { width: 1600, height: 1200, deviceScaleFactor: 2 },
88
+ },
89
+ });
90
+ if (fs.existsSync(outFile))
91
+ return outFile;
92
+ }
93
+ catch (err) {
94
+ console.error('[mermaid] render to file failed:', err.message);
95
+ }
96
+ return null;
97
+ }