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