@karmaniverous/jeeves-server 3.5.2 → 3.6.1

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 (82) hide show
  1. package/.tsbuildinfo +1 -1
  2. package/CHANGELOG.md +33 -1
  3. package/client/src/components/DirectoryRow.tsx +5 -1
  4. package/client/src/components/FileContentView.tsx +7 -0
  5. package/client/src/components/MarkdownView.tsx +63 -22
  6. package/client/src/components/TocSection.tsx +74 -0
  7. package/client/src/components/renderableUtils.ts +2 -2
  8. package/client/src/components/tocUtils.ts +65 -0
  9. package/client/src/index.css +36 -0
  10. package/client/src/lib/api.ts +2 -1
  11. package/client/src/lucide.d.ts +15 -0
  12. package/dist/client/assets/{CodeViewer-Cegj3cEn.js → CodeViewer-D5fJ1Z6_.js} +1 -1
  13. package/dist/client/assets/{index-DrBXupPz.js → index-BXy6kgl7.js} +2 -2
  14. package/dist/client/assets/index-Ch0vkF39.css +2 -0
  15. package/dist/client/index.html +2 -2
  16. package/dist/src/cli/index.js +2 -1
  17. package/dist/src/cli/start-server.js +12 -2
  18. package/dist/src/config/index.js +16 -2
  19. package/dist/src/config/loadConfig.test.js +66 -1
  20. package/dist/src/config/resolve.js +0 -4
  21. package/dist/src/config/resolve.test.js +0 -2
  22. package/dist/src/config/schema.js +7 -21
  23. package/dist/src/config/substituteEnvVars.js +2 -0
  24. package/dist/src/descriptor.js +9 -2
  25. package/dist/src/routes/api/auth-status.js +1 -1
  26. package/dist/src/routes/api/directory.js +46 -24
  27. package/dist/src/routes/api/directory.test.js +65 -0
  28. package/dist/src/routes/api/export.js +5 -1
  29. package/dist/src/routes/api/export.test.js +46 -0
  30. package/dist/src/routes/api/fileContent.js +26 -4
  31. package/dist/src/routes/api/runner.js +2 -3
  32. package/dist/src/routes/api/runner.test.js +29 -0
  33. package/dist/src/routes/api/search.js +4 -9
  34. package/dist/src/routes/api/search.test.js +28 -0
  35. package/dist/src/routes/config.test.js +0 -1
  36. package/dist/src/routes/event.js +1 -1
  37. package/dist/src/routes/status.js +4 -4
  38. package/dist/src/routes/status.test.js +10 -4
  39. package/dist/src/server.js +4 -2
  40. package/dist/src/services/csv.js +114 -0
  41. package/dist/src/services/csv.test.js +107 -0
  42. package/dist/src/services/markdown.js +21 -1
  43. package/dist/src/services/markdown.test.js +43 -0
  44. package/dist/src/util/packageVersion.js +3 -13
  45. package/guides/deployment.md +1 -1
  46. package/guides/setup.md +14 -10
  47. package/knip.json +2 -1
  48. package/package.json +18 -16
  49. package/src/cli/index.ts +3 -1
  50. package/src/cli/start-server.ts +17 -3
  51. package/src/config/index.ts +22 -2
  52. package/src/config/loadConfig.test.ts +77 -1
  53. package/src/config/resolve.test.ts +0 -2
  54. package/src/config/resolve.ts +0 -4
  55. package/src/config/schema.ts +8 -21
  56. package/src/config/substituteEnvVars.ts +2 -0
  57. package/src/config/types.ts +0 -4
  58. package/src/descriptor.ts +9 -1
  59. package/src/routes/api/auth-status.ts +1 -1
  60. package/src/routes/api/directory.test.ts +77 -0
  61. package/src/routes/api/directory.ts +59 -22
  62. package/src/routes/api/export.test.ts +56 -0
  63. package/src/routes/api/export.ts +5 -1
  64. package/src/routes/api/fileContent.ts +27 -3
  65. package/src/routes/api/runner.test.ts +39 -0
  66. package/src/routes/api/runner.ts +2 -5
  67. package/src/routes/api/search.test.ts +36 -0
  68. package/src/routes/api/search.ts +4 -9
  69. package/src/routes/config.test.ts +0 -1
  70. package/src/routes/event.test.ts +4 -4
  71. package/src/routes/event.ts +11 -6
  72. package/src/routes/status.test.ts +13 -4
  73. package/src/routes/status.ts +4 -4
  74. package/src/server.ts +4 -2
  75. package/src/services/csv.test.ts +127 -0
  76. package/src/services/csv.ts +115 -0
  77. package/src/services/markdown.test.ts +54 -0
  78. package/src/services/markdown.ts +21 -1
  79. package/src/types/puppeteer-core.d.ts +16 -0
  80. package/src/util/packageVersion.ts +3 -18
  81. package/dist/client/assets/index-Dk_myGs4.css +0 -2
  82. package/src/types/jsonmap.d.ts +0 -10
@@ -0,0 +1,46 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ vi.mock('@karmaniverous/jeeves', () => ({
3
+ getBindAddress: vi.fn(),
4
+ }));
5
+ const { getBindAddress } = await import('@karmaniverous/jeeves');
6
+ const mockedGetBindAddress = vi.mocked(getBindAddress);
7
+ /**
8
+ * Replicate the bind-address resolution logic from export.ts:
9
+ * 0.0.0.0 (all interfaces) is not a valid request target, so fall back to loopback.
10
+ */
11
+ function resolveRenderHost(bindAddr) {
12
+ return bindAddr === '0.0.0.0' ? '127.0.0.1' : bindAddr;
13
+ }
14
+ describe('export render host resolution', () => {
15
+ beforeEach(() => {
16
+ vi.clearAllMocks();
17
+ });
18
+ it('resolves 0.0.0.0 to 127.0.0.1 for Chrome render URL', () => {
19
+ mockedGetBindAddress.mockReturnValue('0.0.0.0');
20
+ const bindAddr = getBindAddress('server');
21
+ const renderHost = resolveRenderHost(bindAddr);
22
+ expect(renderHost).toBe('127.0.0.1');
23
+ });
24
+ it('preserves a specific bind address for Chrome render URL', () => {
25
+ mockedGetBindAddress.mockReturnValue('192.168.1.5');
26
+ const bindAddr = getBindAddress('server');
27
+ const renderHost = resolveRenderHost(bindAddr);
28
+ expect(renderHost).toBe('192.168.1.5');
29
+ });
30
+ it('preserves loopback address as-is', () => {
31
+ mockedGetBindAddress.mockReturnValue('127.0.0.1');
32
+ const bindAddr = getBindAddress('server');
33
+ const renderHost = resolveRenderHost(bindAddr);
34
+ expect(renderHost).toBe('127.0.0.1');
35
+ });
36
+ it('constructs a valid export URL with resolved host and port', () => {
37
+ mockedGetBindAddress.mockReturnValue('0.0.0.0');
38
+ const bindAddr = getBindAddress('server');
39
+ const renderHost = resolveRenderHost(bindAddr);
40
+ const port = 1934;
41
+ const reqPath = 'c/docs/readme.md';
42
+ const key = 'test-key';
43
+ const exportUrl = `http://${renderHost}:${String(port)}/browse/${reqPath}?key=${key}&render_diagrams=1&plain_code=1`;
44
+ expect(exportUrl).toBe('http://127.0.0.1:1934/browse/c/docs/readme.md?key=test-key&render_diagrams=1&plain_code=1');
45
+ });
46
+ });
@@ -5,7 +5,9 @@
5
5
  */
6
6
  import fs from 'node:fs';
7
7
  import path from 'node:path';
8
+ import { getServiceUrl } from '@karmaniverous/jeeves';
8
9
  import { getConfig } from '../../config/index.js';
10
+ import { csvToHtmlTable } from '../../services/csv.js';
9
11
  import { rewriteLinksForDeepShare } from '../../services/deepShareLinks.js';
10
12
  import { getOrRenderDiagram } from '../../services/diagramCache.js';
11
13
  import { renderEmbeddedDiagrams, setDiagramContext, } from '../../services/embeddedDiagrams.js';
@@ -102,6 +104,28 @@ export const fileContentRoutes = async (fastify) => {
102
104
  isInsider,
103
105
  });
104
106
  }
107
+ // CSV
108
+ if (ext === '.csv') {
109
+ const content = fs.readFileSync(resolved, 'utf8');
110
+ if (rawOnly) {
111
+ return reply.send({
112
+ type: 'text',
113
+ content,
114
+ fileName,
115
+ breadcrumbs,
116
+ isInsider,
117
+ });
118
+ }
119
+ const html = csvToHtmlTable(content);
120
+ return reply.send({
121
+ type: 'csv',
122
+ content,
123
+ html,
124
+ fileName,
125
+ breadcrumbs,
126
+ isInsider,
127
+ });
128
+ }
105
129
  // Text files
106
130
  const buffer = fs.readFileSync(resolved);
107
131
  if (looksLikeText(buffer)) {
@@ -184,11 +208,9 @@ export const fileContentRoutes = async (fastify) => {
184
208
  * Returns null if watcher is not configured, unreachable, or no rules match.
185
209
  */
186
210
  async function tryWatcherRender(fsPath) {
187
- const config = getConfig();
188
- if (!config.watcherUrl)
189
- return null;
211
+ const watcherUrl = getServiceUrl('watcher');
190
212
  try {
191
- const res = await fetch(`${config.watcherUrl}/render`, {
213
+ const res = await fetch(`${watcherUrl}/render`, {
192
214
  method: 'POST',
193
215
  headers: { 'Content-Type': 'application/json' },
194
216
  body: JSON.stringify({
@@ -4,10 +4,9 @@
4
4
  * All routes require insider auth. The runner only listens on localhost,
5
5
  * so jeeves-server acts as an authenticated gateway.
6
6
  */
7
- import { getConfig } from '../../config/index.js';
8
- const DEFAULT_RUNNER_URL = 'http://127.0.0.1:3100';
7
+ import { getServiceUrl } from '@karmaniverous/jeeves';
9
8
  function getRunnerUrl() {
10
- return getConfig().runnerUrl ?? DEFAULT_RUNNER_URL;
9
+ return getServiceUrl('runner');
11
10
  }
12
11
  /** Proxy a request to the runner and send the response via reply. */
13
12
  async function proxyToRunner(reply, path, method = 'GET') {
@@ -0,0 +1,29 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ vi.mock('@karmaniverous/jeeves', () => ({
3
+ getServiceUrl: vi.fn(),
4
+ }));
5
+ const { getServiceUrl } = await import('@karmaniverous/jeeves');
6
+ const mockedGetServiceUrl = vi.mocked(getServiceUrl);
7
+ describe('runner URL resolution', () => {
8
+ beforeEach(() => {
9
+ vi.clearAllMocks();
10
+ });
11
+ it('resolves runner URL via core getServiceUrl', () => {
12
+ mockedGetServiceUrl.mockReturnValue('http://127.0.0.1:1937');
13
+ const url = getServiceUrl('runner');
14
+ expect(url).toBe('http://127.0.0.1:1937');
15
+ expect(mockedGetServiceUrl).toHaveBeenCalledWith('runner');
16
+ });
17
+ it('uses the URL from core, not a hardcoded default', () => {
18
+ mockedGetServiceUrl.mockReturnValue('http://10.0.0.5:9999');
19
+ const url = getServiceUrl('runner');
20
+ expect(url).toBe('http://10.0.0.5:9999');
21
+ });
22
+ it('constructs proxy paths correctly from the resolved URL', () => {
23
+ mockedGetServiceUrl.mockReturnValue('http://127.0.0.1:1937');
24
+ const base = getServiceUrl('runner');
25
+ const jobId = 'my-job';
26
+ expect(`${base}/jobs/${encodeURIComponent(jobId)}`).toBe('http://127.0.0.1:1937/jobs/my-job');
27
+ expect(`${base}/jobs/${encodeURIComponent(jobId)}/runs?limit=20`).toBe('http://127.0.0.1:1937/jobs/my-job/runs?limit=20');
28
+ });
29
+ });
@@ -7,6 +7,7 @@
7
7
  * @packageDocumentation
8
8
  */
9
9
  import { stat } from 'node:fs/promises';
10
+ import { getServiceUrl } from '@karmaniverous/jeeves';
10
11
  import picomatch from 'picomatch';
11
12
  import { getConfig } from '../../config/index.js';
12
13
  import { fsPathToUrl, getRoots, urlPathToFs } from '../../util/platform.js';
@@ -33,9 +34,7 @@ export const searchRoutes = async (fastify) => {
33
34
  return reply.code(403).send({ error: 'Insider access required' });
34
35
  }
35
36
  const config = getConfig();
36
- if (!config.watcherUrl) {
37
- return reply.code(501).send({ error: 'Search not configured' });
38
- }
37
+ const watcherUrl = getServiceUrl('watcher');
39
38
  const { query, limit = 20, filter } = request.body;
40
39
  if (!query || typeof query !== 'string') {
41
40
  return reply.code(400).send({ error: 'query is required' });
@@ -45,7 +44,7 @@ export const searchRoutes = async (fastify) => {
45
44
  // Over-fetch to account for scope filtering
46
45
  const fetchLimit = Math.min(limit * 5, 200);
47
46
  try {
48
- const watcherRes = await fetch(`${config.watcherUrl}/search`, {
47
+ const watcherRes = await fetch(`${watcherUrl}/search`, {
49
48
  method: 'POST',
50
49
  headers: { 'Content-Type': 'application/json' },
51
50
  body: JSON.stringify({ query, limit: fetchLimit, filter }),
@@ -177,10 +176,7 @@ export const searchRoutes = async (fastify) => {
177
176
  if (request.accessMode !== 'insider') {
178
177
  return reply.code(403).send({ error: 'Insider access required' });
179
178
  }
180
- const config = getConfig();
181
- if (!config.watcherUrl) {
182
- return reply.code(501).send({ error: 'Search not configured' });
183
- }
179
+ const watcherUrl = getServiceUrl('watcher');
184
180
  // Return cached if fresh
185
181
  if (facetsCache &&
186
182
  Date.now() - facetsCache.fetchedAt < FACETS_CACHE_TTL_MS) {
@@ -189,7 +185,6 @@ export const searchRoutes = async (fastify) => {
189
185
  try {
190
186
  // Guard against cache stampede: reuse in-flight fetch
191
187
  if (!facetsFetchPromise) {
192
- const watcherUrl = config.watcherUrl;
193
188
  facetsFetchPromise = (async () => {
194
189
  const watcherRes = await fetch(`${watcherUrl}/search/facets`, {
195
190
  signal: AbortSignal.timeout(15000),
@@ -0,0 +1,28 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ vi.mock('@karmaniverous/jeeves', () => ({
3
+ getServiceUrl: vi.fn(),
4
+ }));
5
+ const { getServiceUrl } = await import('@karmaniverous/jeeves');
6
+ const mockedGetServiceUrl = vi.mocked(getServiceUrl);
7
+ describe('search watcher URL resolution', () => {
8
+ beforeEach(() => {
9
+ vi.clearAllMocks();
10
+ });
11
+ it('resolves watcher URL via core getServiceUrl', () => {
12
+ mockedGetServiceUrl.mockReturnValue('http://127.0.0.1:1936');
13
+ const url = getServiceUrl('watcher');
14
+ expect(url).toBe('http://127.0.0.1:1936');
15
+ expect(mockedGetServiceUrl).toHaveBeenCalledWith('watcher');
16
+ });
17
+ it('uses core-resolved URL, not a hardcoded default', () => {
18
+ mockedGetServiceUrl.mockReturnValue('http://10.0.0.5:8888');
19
+ const url = getServiceUrl('watcher');
20
+ expect(url).toBe('http://10.0.0.5:8888');
21
+ });
22
+ it('constructs search endpoint from resolved watcher URL', () => {
23
+ mockedGetServiceUrl.mockReturnValue('http://127.0.0.1:1936');
24
+ const watcherUrl = getServiceUrl('watcher');
25
+ expect(`${watcherUrl}/search`).toBe('http://127.0.0.1:1936/search');
26
+ expect(`${watcherUrl}/search/facets`).toBe('http://127.0.0.1:1936/search/facets');
27
+ });
28
+ });
@@ -7,7 +7,6 @@ import { sanitizeConfig } from './config.js';
7
7
  function makeConfig(overrides = {}) {
8
8
  return {
9
9
  port: 1934,
10
- host: '0.0.0.0',
11
10
  eventTimeoutMs: 30_000,
12
11
  eventLogPurgeMs: 604_800_000,
13
12
  maxZipSizeMb: 100,
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Event Gateway webhook endpoint
3
3
  */
4
- import { JsonMap } from '@karmaniverous/jsonmap';
4
+ import { JsonMap, } from '@karmaniverous/jsonmap';
5
5
  import Ajv from 'ajv';
6
6
  import * as _ from 'radash';
7
7
  import { verifyKey } from '../auth/keys.js';
@@ -4,7 +4,7 @@
4
4
  * Returns standard `{ name, version, uptime, status, health }` shape
5
5
  * with server-specific details nested under `health`.
6
6
  */
7
- import { createStatusHandler } from '@karmaniverous/jeeves';
7
+ import { createStatusHandler, getServiceUrl } from '@karmaniverous/jeeves';
8
8
  import { getConfig } from '../config/index.js';
9
9
  import { packageVersion } from '../util/packageVersion.js';
10
10
  async function checkService(url) {
@@ -30,9 +30,9 @@ const handleStatus = createStatusHandler({
30
30
  getHealth: async () => {
31
31
  const config = getConfig();
32
32
  const [watcher, runner, meta] = await Promise.all([
33
- config.watcherUrl ? checkService(config.watcherUrl) : null,
34
- config.runnerUrl ? checkService(config.runnerUrl) : null,
35
- config.metaUrl ? checkService(config.metaUrl) : null,
33
+ checkService(getServiceUrl('watcher')),
34
+ checkService(getServiceUrl('runner')),
35
+ checkService(getServiceUrl('meta')),
36
36
  ]);
37
37
  return {
38
38
  port: config.port,
@@ -1,8 +1,17 @@
1
1
  import { describe, expect, it, vi } from 'vitest';
2
+ vi.mock('@karmaniverous/jeeves', async (importOriginal) => {
3
+ const mod = await importOriginal();
4
+ return {
5
+ ...mod,
6
+ getServiceUrl: (name) => `http://127.0.0.1:${name === 'watcher' ? '1936' : name === 'runner' ? '1937' : '1938'}`,
7
+ };
8
+ });
9
+ // Mock fetch so service health checks don't make real HTTP calls
10
+ const mockFetch = vi.fn().mockRejectedValue(new Error('not reachable'));
11
+ vi.stubGlobal('fetch', mockFetch);
2
12
  // Mock config
3
13
  const mockConfig = {
4
14
  port: 1934,
5
- host: '0.0.0.0',
6
15
  chromePath: '/usr/bin/chromium',
7
16
  authModes: ['keys'],
8
17
  resolvedInsiders: [{ email: 'a@b.com' }, { email: 'c@d.com' }],
@@ -16,9 +25,6 @@ const mockConfig = {
16
25
  jarPath: '/tools/plantuml.jar',
17
26
  servers: ['https://plantuml.com/plantuml'],
18
27
  },
19
- watcherUrl: null,
20
- runnerUrl: null,
21
- metaUrl: null,
22
28
  exportFormats: ['pdf', 'docx', 'zip'],
23
29
  };
24
30
  vi.mock('../config/index.js', () => ({
@@ -7,6 +7,7 @@ import path from 'node:path';
7
7
  import { fileURLToPath } from 'node:url';
8
8
  import cookie from '@fastify/cookie';
9
9
  import fastifyStatic from '@fastify/static';
10
+ import { getBindAddress } from '@karmaniverous/jeeves';
10
11
  import Fastify from 'fastify';
11
12
  import { getConfig, initConfig, isConfigInitialized } from './config/index.js';
12
13
  import { apiRoute } from './routes/api/index.js';
@@ -76,8 +77,9 @@ async function start() {
76
77
  initExportCache();
77
78
  // Start queue processor
78
79
  startQueueProcessor();
79
- await fastify.listen({ port: config.port, host: config.host });
80
- console.log(`Jeeves server listening on ${config.host}:${String(config.port)}`);
80
+ const bindAddress = getBindAddress('server');
81
+ await fastify.listen({ port: config.port, host: bindAddress });
82
+ console.log(`Jeeves server listening on ${bindAddress}:${String(config.port)}`);
81
83
  console.log(`Endpoints:`);
82
84
  console.log(` GET /browse/* - File browser SPA`);
83
85
  console.log(` GET /api/raw/* - Raw file serving`);
@@ -0,0 +1,114 @@
1
+ /**
2
+ * CSV to HTML table conversion.
3
+ *
4
+ * Parses RFC 4180 CSV and renders an HTML table with headers.
5
+ *
6
+ * @packageDocumentation
7
+ */
8
+ /**
9
+ * Parse a CSV string into rows of fields per RFC 4180.
10
+ *
11
+ * Handles: quoted fields with embedded commas/newlines,
12
+ * escaped quotes (""), empty fields, trailing newlines.
13
+ */
14
+ export function parseCsvRows(csv) {
15
+ const rows = [];
16
+ let row = [];
17
+ let field = '';
18
+ let inQuotes = false;
19
+ let i = 0;
20
+ while (i < csv.length) {
21
+ const ch = csv[i];
22
+ if (inQuotes) {
23
+ if (ch === '"') {
24
+ // Peek next character
25
+ if (i + 1 < csv.length && csv[i + 1] === '"') {
26
+ // Escaped quote
27
+ field += '"';
28
+ i += 2;
29
+ }
30
+ else {
31
+ // End of quoted field
32
+ inQuotes = false;
33
+ i++;
34
+ }
35
+ }
36
+ else {
37
+ field += ch;
38
+ i++;
39
+ }
40
+ }
41
+ else {
42
+ if (ch === '"') {
43
+ inQuotes = true;
44
+ i++;
45
+ }
46
+ else if (ch === ',') {
47
+ row.push(field);
48
+ field = '';
49
+ i++;
50
+ }
51
+ else if (ch === '\r') {
52
+ // Handle \r\n or bare \r
53
+ row.push(field);
54
+ field = '';
55
+ rows.push(row);
56
+ row = [];
57
+ i++;
58
+ if (i < csv.length && csv[i] === '\n')
59
+ i++;
60
+ }
61
+ else if (ch === '\n') {
62
+ row.push(field);
63
+ field = '';
64
+ rows.push(row);
65
+ row = [];
66
+ i++;
67
+ }
68
+ else {
69
+ field += ch;
70
+ i++;
71
+ }
72
+ }
73
+ }
74
+ // Push final field/row if there's content
75
+ if (field.length > 0 || row.length > 0) {
76
+ row.push(field);
77
+ rows.push(row);
78
+ }
79
+ // Remove trailing empty row (from trailing newline)
80
+ const lastRow = rows.length > 0 ? rows[rows.length - 1] : undefined;
81
+ if (lastRow && lastRow.length === 1 && lastRow[0] === '') {
82
+ rows.pop();
83
+ }
84
+ return rows;
85
+ }
86
+ /** Escape HTML special characters. */
87
+ function escapeHtml(text) {
88
+ return text
89
+ .replace(/&/g, '&amp;')
90
+ .replace(/</g, '&lt;')
91
+ .replace(/>/g, '&gt;')
92
+ .replace(/"/g, '&quot;');
93
+ }
94
+ /**
95
+ * Convert a CSV string to an HTML table.
96
+ *
97
+ * First row is treated as column headers (thead).
98
+ * Returns empty paragraph for empty input.
99
+ */
100
+ export function csvToHtmlTable(csv) {
101
+ const rows = parseCsvRows(csv);
102
+ if (rows.length === 0)
103
+ return '<p>Empty CSV</p>';
104
+ const [headers, ...data] = rows;
105
+ const colCount = headers.length;
106
+ const thead = `<thead><tr>${headers.map((h) => `<th>${escapeHtml(h)}</th>`).join('')}</tr></thead>`;
107
+ const tbody = `<tbody>${data
108
+ .map((row) => {
109
+ const cells = Array.from({ length: colCount }, (_, i) => row[i] ?? '');
110
+ return `<tr>${cells.map((cell) => `<td>${escapeHtml(cell)}</td>`).join('')}</tr>`;
111
+ })
112
+ .join('')}</tbody>`;
113
+ return `<table class="csv-table">${thead}${tbody}</table>`;
114
+ }
@@ -0,0 +1,107 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { csvToHtmlTable, parseCsvRows } from './csv.js';
3
+ describe('parseCsvRows', () => {
4
+ it('parses simple CSV', () => {
5
+ const rows = parseCsvRows('a,b,c\n1,2,3\n');
6
+ expect(rows).toEqual([
7
+ ['a', 'b', 'c'],
8
+ ['1', '2', '3'],
9
+ ]);
10
+ });
11
+ it('handles quoted fields with embedded commas', () => {
12
+ const rows = parseCsvRows('name,desc\n"Smith, John","has, commas"\n');
13
+ expect(rows).toEqual([
14
+ ['name', 'desc'],
15
+ ['Smith, John', 'has, commas'],
16
+ ]);
17
+ });
18
+ it('handles escaped quotes ("")', () => {
19
+ const rows = parseCsvRows('a\n"he said ""hello"""\n');
20
+ expect(rows).toEqual([['a'], ['he said "hello"']]);
21
+ });
22
+ it('handles empty fields', () => {
23
+ const rows = parseCsvRows('a,,c\n,,\n');
24
+ expect(rows).toEqual([
25
+ ['a', '', 'c'],
26
+ ['', '', ''],
27
+ ]);
28
+ });
29
+ it('handles trailing newlines', () => {
30
+ const rows = parseCsvRows('a,b\n1,2\n\n');
31
+ expect(rows).toEqual([
32
+ ['a', 'b'],
33
+ ['1', '2'],
34
+ ]);
35
+ });
36
+ it('handles single row (headers only)', () => {
37
+ const rows = parseCsvRows('name,age,city\n');
38
+ expect(rows).toEqual([['name', 'age', 'city']]);
39
+ });
40
+ it('returns empty array for empty input', () => {
41
+ const rows = parseCsvRows('');
42
+ expect(rows).toEqual([]);
43
+ });
44
+ it('handles CRLF line endings', () => {
45
+ const rows = parseCsvRows('a,b\r\n1,2\r\n');
46
+ expect(rows).toEqual([
47
+ ['a', 'b'],
48
+ ['1', '2'],
49
+ ]);
50
+ });
51
+ it('handles quoted fields with embedded newlines', () => {
52
+ const rows = parseCsvRows('a,b\n"line1\nline2",val\n');
53
+ expect(rows).toEqual([
54
+ ['a', 'b'],
55
+ ['line1\nline2', 'val'],
56
+ ]);
57
+ });
58
+ });
59
+ describe('csvToHtmlTable', () => {
60
+ it('renders a table with headers and data', () => {
61
+ const html = csvToHtmlTable('Name,Age\nAlice,30\nBob,25\n');
62
+ expect(html).toContain('<thead>');
63
+ expect(html).toContain('<th>Name</th>');
64
+ expect(html).toContain('<th>Age</th>');
65
+ expect(html).toContain('<td>Alice</td>');
66
+ expect(html).toContain('<td>30</td>');
67
+ expect(html).toContain('<td>Bob</td>');
68
+ });
69
+ it('returns empty paragraph for empty input', () => {
70
+ const html = csvToHtmlTable('');
71
+ expect(html).toBe('<p>Empty CSV</p>');
72
+ });
73
+ it('escapes HTML in field values', () => {
74
+ const html = csvToHtmlTable('a\n<script>alert("xss")</script>\n');
75
+ expect(html).not.toContain('<script>');
76
+ expect(html).toContain('&lt;script&gt;');
77
+ });
78
+ it('produces a table with csv-table class for client styling', () => {
79
+ const html = csvToHtmlTable('Name,Age\nAlice,30\n');
80
+ expect(html).toMatch(/<table class="csv-table">/);
81
+ });
82
+ it('normalizes rows with fewer or more columns than header', () => {
83
+ // Row with fewer columns gets padded, row with more gets truncated
84
+ const csv = 'a,b,c\n1\n4,5,6,7\n';
85
+ const html = csvToHtmlTable(csv);
86
+ // First data row: 1 field → padded to 3 columns
87
+ expect(html).toContain('<tr><td>1</td><td></td><td></td></tr>');
88
+ // Second data row: 4 fields → truncated to 3 columns (no "7")
89
+ expect(html).toContain('<tr><td>4</td><td>5</td><td>6</td></tr>');
90
+ expect(html).not.toContain('<td>7</td>');
91
+ });
92
+ it('handles a large CSV (>100 rows) without issues', () => {
93
+ const header = 'id,name,value\n';
94
+ const rows = Array.from({ length: 150 }, (_, i) => `${String(i)},item${String(i)},${String(i * 10)}\n`).join('');
95
+ const csv = header + rows;
96
+ const html = csvToHtmlTable(csv);
97
+ expect(html).toContain('csv-table');
98
+ expect(html).toContain('<thead>');
99
+ // Verify first and last data rows are present
100
+ expect(html).toContain('<td>0</td>');
101
+ expect(html).toContain('<td>149</td>');
102
+ // Count tbody rows
103
+ const trMatches = html.match(/<tr>/g);
104
+ // 1 header row + 150 data rows = 151
105
+ expect(trMatches).toHaveLength(151);
106
+ });
107
+ });
@@ -146,11 +146,31 @@ export function parseMarkdown(markdown, options = {}) {
146
146
  let html = marked(processedMarkdown);
147
147
  // Prepend frontmatter as a rendered YAML code block
148
148
  if (frontmatter) {
149
+ const FRONTMATTER_COLLAPSE_THRESHOLD = 10;
149
150
  const escaped = frontmatter
150
151
  .replace(/&/g, '&amp;')
151
152
  .replace(/</g, '&lt;')
152
153
  .replace(/>/g, '&gt;');
153
- html = `<div class="frontmatter-block"><pre><code class="language-yaml">${escaped}</code></pre></div>\n${html}`;
154
+ const lines = frontmatter.split('\n');
155
+ if (lines.length > FRONTMATTER_COLLAPSE_THRESHOLD) {
156
+ const previewEscaped = lines
157
+ .slice(0, FRONTMATTER_COLLAPSE_THRESHOLD)
158
+ .join('\n')
159
+ .replace(/&/g, '&amp;')
160
+ .replace(/</g, '&lt;')
161
+ .replace(/>/g, '&gt;');
162
+ html =
163
+ `<div class="frontmatter-block frontmatter-collapsible">` +
164
+ `<div class="frontmatter-preview"><pre><code class="language-yaml">${previewEscaped}</code></pre></div>` +
165
+ `<div class="frontmatter-full"><pre><code class="language-yaml">${escaped}</code></pre></div>` +
166
+ `<button class="frontmatter-toggle" onclick="this.parentElement.classList.toggle('frontmatter-expanded'); ` +
167
+ `this.textContent = this.parentElement.classList.contains('frontmatter-expanded') ` +
168
+ `? 'Show less' : 'Show all (${String(lines.length)} lines)'">Show all (${String(lines.length)} lines)</button>` +
169
+ `</div>\n${html}`;
170
+ }
171
+ else {
172
+ html = `<div class="frontmatter-block"><pre><code class="language-yaml">${escaped}</code></pre></div>\n${html}`;
173
+ }
154
174
  }
155
175
  return { html, headings };
156
176
  }
@@ -20,3 +20,46 @@ describe('parseMarkdown', () => {
20
20
  expect(headings[0].text).toBe('It\'s a "test"');
21
21
  });
22
22
  });
23
+ describe('collapsible frontmatter', () => {
24
+ function makeFrontmatter(lineCount) {
25
+ const lines = Array.from({ length: lineCount }, (_, i) => `key${String(i)}: value${String(i)}`);
26
+ return `---\n${lines.join('\n')}\n---\n# Body`;
27
+ }
28
+ it('collapses frontmatter with >10 lines', () => {
29
+ const md = makeFrontmatter(15);
30
+ const { html } = parseMarkdown(md);
31
+ expect(html).toContain('frontmatter-collapsible');
32
+ expect(html).toContain('frontmatter-toggle');
33
+ expect(html).toContain('Show all (15 lines)');
34
+ });
35
+ it('does not collapse frontmatter with ≤10 lines', () => {
36
+ const md = makeFrontmatter(8);
37
+ const { html } = parseMarkdown(md);
38
+ expect(html).toContain('frontmatter-block');
39
+ expect(html).not.toContain('frontmatter-collapsible');
40
+ expect(html).not.toContain('frontmatter-toggle');
41
+ });
42
+ it('preview section contains only the first 10 lines', () => {
43
+ const md = makeFrontmatter(20);
44
+ const { html } = parseMarkdown(md);
45
+ const previewMatch = html.match(/frontmatter-preview.*?<code[^>]*>([\s\S]*?)<\/code>/);
46
+ expect(previewMatch).not.toBeNull();
47
+ const previewContent = previewMatch[1];
48
+ const previewLines = previewContent.split('\n');
49
+ expect(previewLines).toHaveLength(10);
50
+ expect(previewLines[0]).toContain('key0');
51
+ expect(previewLines[9]).toContain('key9');
52
+ });
53
+ it('does NOT collapse frontmatter with exactly 10 lines', () => {
54
+ const md = makeFrontmatter(10);
55
+ const { html } = parseMarkdown(md);
56
+ expect(html).toContain('frontmatter-block');
57
+ expect(html).not.toContain('frontmatter-collapsible');
58
+ });
59
+ it('collapses frontmatter with exactly 11 lines', () => {
60
+ const md = makeFrontmatter(11);
61
+ const { html } = parseMarkdown(md);
62
+ expect(html).toContain('frontmatter-collapsible');
63
+ expect(html).toContain('Show all (11 lines)');
64
+ });
65
+ });
@@ -1,16 +1,6 @@
1
1
  /**
2
- * Resolve the service package version using package-directory.
2
+ * Resolve the service package version using core's getPackageVersion().
3
3
  */
4
- import fs from 'node:fs';
5
- import path from 'node:path';
6
- import { fileURLToPath } from 'node:url';
7
- import { packageDirectorySync } from 'package-directory';
8
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
9
- const pkgDir = packageDirectorySync({ cwd: __dirname });
10
- if (!pkgDir) {
11
- throw new Error('Could not find package directory for jeeves-server');
12
- }
13
- const pkgPath = path.join(pkgDir, 'package.json');
14
- const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
4
+ import { getPackageVersion } from '@karmaniverous/jeeves';
15
5
  /** The package version of the jeeves-server service package. */
16
- export const packageVersion = pkg.version;
6
+ export const packageVersion = getPackageVersion(import.meta.url);
@@ -8,7 +8,7 @@ How to run Jeeves Server in production.
8
8
 
9
9
  ## Prerequisites
10
10
 
11
- - **Node.js** ≥ 20
11
+ - **Node.js** ≥ 22
12
12
  - **Chrome or Chromium** — for PDF/DOCX export via Puppeteer
13
13
  - **A domain** with HTTPS — required for Google OAuth and secure sharing
14
14
  - **A reverse proxy** — Caddy, nginx, or similar (recommended)