@karmaniverous/jeeves-server 3.5.2 → 3.6.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 (76) hide show
  1. package/.tsbuildinfo +1 -1
  2. package/CHANGELOG.md +21 -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/dist/client/assets/{CodeViewer-Cegj3cEn.js → CodeViewer-D5fJ1Z6_.js} +1 -1
  12. package/dist/client/assets/{index-DrBXupPz.js → index-BXy6kgl7.js} +2 -2
  13. package/dist/client/assets/index-Ch0vkF39.css +2 -0
  14. package/dist/client/index.html +2 -2
  15. package/dist/src/cli/index.js +2 -1
  16. package/dist/src/cli/start-server.js +12 -2
  17. package/dist/src/config/index.js +16 -2
  18. package/dist/src/config/loadConfig.test.js +66 -1
  19. package/dist/src/config/resolve.js +0 -4
  20. package/dist/src/config/resolve.test.js +0 -2
  21. package/dist/src/config/schema.js +7 -21
  22. package/dist/src/config/substituteEnvVars.js +2 -0
  23. package/dist/src/descriptor.js +9 -2
  24. package/dist/src/routes/api/auth-status.js +1 -1
  25. package/dist/src/routes/api/directory.js +46 -24
  26. package/dist/src/routes/api/directory.test.js +65 -0
  27. package/dist/src/routes/api/export.js +5 -1
  28. package/dist/src/routes/api/export.test.js +46 -0
  29. package/dist/src/routes/api/fileContent.js +26 -4
  30. package/dist/src/routes/api/runner.js +2 -3
  31. package/dist/src/routes/api/runner.test.js +29 -0
  32. package/dist/src/routes/api/search.js +4 -9
  33. package/dist/src/routes/api/search.test.js +28 -0
  34. package/dist/src/routes/config.test.js +0 -1
  35. package/dist/src/routes/status.js +4 -4
  36. package/dist/src/routes/status.test.js +10 -4
  37. package/dist/src/server.js +4 -2
  38. package/dist/src/services/csv.js +114 -0
  39. package/dist/src/services/csv.test.js +107 -0
  40. package/dist/src/services/markdown.js +21 -1
  41. package/dist/src/services/markdown.test.js +43 -0
  42. package/dist/src/util/packageVersion.js +3 -13
  43. package/guides/deployment.md +1 -1
  44. package/guides/setup.md +14 -10
  45. package/knip.json +2 -1
  46. package/package.json +5 -3
  47. package/src/cli/index.ts +3 -1
  48. package/src/cli/start-server.ts +17 -3
  49. package/src/config/index.ts +22 -2
  50. package/src/config/loadConfig.test.ts +77 -1
  51. package/src/config/resolve.test.ts +0 -2
  52. package/src/config/resolve.ts +0 -4
  53. package/src/config/schema.ts +8 -21
  54. package/src/config/substituteEnvVars.ts +2 -0
  55. package/src/config/types.ts +0 -4
  56. package/src/descriptor.ts +9 -1
  57. package/src/routes/api/auth-status.ts +1 -1
  58. package/src/routes/api/directory.test.ts +77 -0
  59. package/src/routes/api/directory.ts +59 -22
  60. package/src/routes/api/export.test.ts +56 -0
  61. package/src/routes/api/export.ts +5 -1
  62. package/src/routes/api/fileContent.ts +27 -3
  63. package/src/routes/api/runner.test.ts +39 -0
  64. package/src/routes/api/runner.ts +2 -5
  65. package/src/routes/api/search.test.ts +36 -0
  66. package/src/routes/api/search.ts +4 -9
  67. package/src/routes/config.test.ts +0 -1
  68. package/src/routes/status.test.ts +13 -4
  69. package/src/routes/status.ts +4 -4
  70. package/src/server.ts +4 -2
  71. package/src/services/csv.test.ts +127 -0
  72. package/src/services/csv.ts +115 -0
  73. package/src/services/markdown.test.ts +54 -0
  74. package/src/services/markdown.ts +21 -1
  75. package/src/util/packageVersion.ts +3 -18
  76. package/dist/client/assets/index-Dk_myGs4.css +0 -2
@@ -10,6 +10,7 @@
10
10
  import fs from 'node:fs';
11
11
  import path from 'node:path';
12
12
 
13
+ import { getBindAddress } from '@karmaniverous/jeeves';
13
14
  import archiver from 'archiver';
14
15
  import type { FastifyPluginAsync } from 'fastify';
15
16
 
@@ -102,7 +103,10 @@ export const exportRoutes: FastifyPluginAsync = async (fastify) => {
102
103
  .code(500)
103
104
  .send({ error: 'Export unavailable — no internal key configured' });
104
105
 
105
- const exportUrl = `http://127.0.0.1:${String(port)}/browse/${reqPath}?key=${exportKey}&render_diagrams=1&plain_code=1`;
106
+ const bindAddr = getBindAddress('server');
107
+ // Use loopback if bound to all interfaces (0.0.0.0 is not a valid request target)
108
+ const renderHost = bindAddr === '0.0.0.0' ? '127.0.0.1' : bindAddr;
109
+ const exportUrl = `http://${renderHost}:${String(port)}/browse/${reqPath}?key=${exportKey}&render_diagrams=1&plain_code=1`;
106
110
  const fileName = path.basename(resolved);
107
111
  const baseName = fileName.replace(/\.md$/i, '');
108
112
 
@@ -7,9 +7,11 @@
7
7
  import fs from 'node:fs';
8
8
  import path from 'node:path';
9
9
 
10
+ import { getServiceUrl } from '@karmaniverous/jeeves';
10
11
  import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify';
11
12
 
12
13
  import { getConfig } from '../../config/index.js';
14
+ import { csvToHtmlTable } from '../../services/csv.js';
13
15
  import { rewriteLinksForDeepShare } from '../../services/deepShareLinks.js';
14
16
  import { getOrRenderDiagram } from '../../services/diagramCache.js';
15
17
  import {
@@ -142,6 +144,29 @@ export const fileContentRoutes: FastifyPluginAsync = async (fastify) => {
142
144
  });
143
145
  }
144
146
 
147
+ // CSV
148
+ if (ext === '.csv') {
149
+ const content = fs.readFileSync(resolved, 'utf8');
150
+ if (rawOnly) {
151
+ return reply.send({
152
+ type: 'text',
153
+ content,
154
+ fileName,
155
+ breadcrumbs,
156
+ isInsider,
157
+ });
158
+ }
159
+ const html = csvToHtmlTable(content);
160
+ return reply.send({
161
+ type: 'csv',
162
+ content,
163
+ html,
164
+ fileName,
165
+ breadcrumbs,
166
+ isInsider,
167
+ });
168
+ }
169
+
145
170
  // Text files
146
171
  const buffer = fs.readFileSync(resolved);
147
172
  if (looksLikeText(buffer)) {
@@ -259,11 +284,10 @@ interface WatcherRenderResponse {
259
284
  async function tryWatcherRender(
260
285
  fsPath: string,
261
286
  ): Promise<WatcherRenderResponse | null> {
262
- const config = getConfig();
263
- if (!config.watcherUrl) return null;
287
+ const watcherUrl = getServiceUrl('watcher');
264
288
 
265
289
  try {
266
- const res = await fetch(`${config.watcherUrl}/render`, {
290
+ const res = await fetch(`${watcherUrl}/render`, {
267
291
  method: 'POST',
268
292
  headers: { 'Content-Type': 'application/json' },
269
293
  body: JSON.stringify({
@@ -0,0 +1,39 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ vi.mock('@karmaniverous/jeeves', () => ({
4
+ getServiceUrl: vi.fn(),
5
+ }));
6
+
7
+ const { getServiceUrl } = await import('@karmaniverous/jeeves');
8
+ const mockedGetServiceUrl = vi.mocked(getServiceUrl);
9
+
10
+ describe('runner URL resolution', () => {
11
+ beforeEach(() => {
12
+ vi.clearAllMocks();
13
+ });
14
+
15
+ it('resolves runner URL via core getServiceUrl', () => {
16
+ mockedGetServiceUrl.mockReturnValue('http://127.0.0.1:1937');
17
+ const url = getServiceUrl('runner');
18
+ expect(url).toBe('http://127.0.0.1:1937');
19
+ expect(mockedGetServiceUrl).toHaveBeenCalledWith('runner');
20
+ });
21
+
22
+ it('uses the URL from core, not a hardcoded default', () => {
23
+ mockedGetServiceUrl.mockReturnValue('http://10.0.0.5:9999');
24
+ const url = getServiceUrl('runner');
25
+ expect(url).toBe('http://10.0.0.5:9999');
26
+ });
27
+
28
+ it('constructs proxy paths correctly from the resolved URL', () => {
29
+ mockedGetServiceUrl.mockReturnValue('http://127.0.0.1:1937');
30
+ const base = getServiceUrl('runner');
31
+ const jobId = 'my-job';
32
+ expect(`${base}/jobs/${encodeURIComponent(jobId)}`).toBe(
33
+ 'http://127.0.0.1:1937/jobs/my-job',
34
+ );
35
+ expect(`${base}/jobs/${encodeURIComponent(jobId)}/runs?limit=20`).toBe(
36
+ 'http://127.0.0.1:1937/jobs/my-job/runs?limit=20',
37
+ );
38
+ });
39
+ });
@@ -5,14 +5,11 @@
5
5
  * so jeeves-server acts as an authenticated gateway.
6
6
  */
7
7
 
8
+ import { getServiceUrl } from '@karmaniverous/jeeves';
8
9
  import type { FastifyPluginAsync, FastifyReply } from 'fastify';
9
10
 
10
- import { getConfig } from '../../config/index.js';
11
-
12
- const DEFAULT_RUNNER_URL = 'http://127.0.0.1:3100';
13
-
14
11
  function getRunnerUrl(): string {
15
- return getConfig().runnerUrl ?? DEFAULT_RUNNER_URL;
12
+ return getServiceUrl('runner');
16
13
  }
17
14
 
18
15
  /** Proxy a request to the runner and send the response via reply. */
@@ -0,0 +1,36 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ vi.mock('@karmaniverous/jeeves', () => ({
4
+ getServiceUrl: vi.fn(),
5
+ }));
6
+
7
+ const { getServiceUrl } = await import('@karmaniverous/jeeves');
8
+ const mockedGetServiceUrl = vi.mocked(getServiceUrl);
9
+
10
+ describe('search watcher URL resolution', () => {
11
+ beforeEach(() => {
12
+ vi.clearAllMocks();
13
+ });
14
+
15
+ it('resolves watcher URL via core getServiceUrl', () => {
16
+ mockedGetServiceUrl.mockReturnValue('http://127.0.0.1:1936');
17
+ const url = getServiceUrl('watcher');
18
+ expect(url).toBe('http://127.0.0.1:1936');
19
+ expect(mockedGetServiceUrl).toHaveBeenCalledWith('watcher');
20
+ });
21
+
22
+ it('uses core-resolved URL, not a hardcoded default', () => {
23
+ mockedGetServiceUrl.mockReturnValue('http://10.0.0.5:8888');
24
+ const url = getServiceUrl('watcher');
25
+ expect(url).toBe('http://10.0.0.5:8888');
26
+ });
27
+
28
+ it('constructs search endpoint from resolved watcher URL', () => {
29
+ mockedGetServiceUrl.mockReturnValue('http://127.0.0.1:1936');
30
+ const watcherUrl = getServiceUrl('watcher');
31
+ expect(`${watcherUrl}/search`).toBe('http://127.0.0.1:1936/search');
32
+ expect(`${watcherUrl}/search/facets`).toBe(
33
+ 'http://127.0.0.1:1936/search/facets',
34
+ );
35
+ });
36
+ });
@@ -9,6 +9,7 @@
9
9
 
10
10
  import { stat } from 'node:fs/promises';
11
11
 
12
+ import { getServiceUrl } from '@karmaniverous/jeeves';
12
13
  import type { FastifyPluginAsync } from 'fastify';
13
14
  import picomatch from 'picomatch';
14
15
 
@@ -86,9 +87,7 @@ export const searchRoutes: FastifyPluginAsync = async (fastify) => {
86
87
  }
87
88
 
88
89
  const config = getConfig();
89
- if (!config.watcherUrl) {
90
- return reply.code(501).send({ error: 'Search not configured' });
91
- }
90
+ const watcherUrl = getServiceUrl('watcher');
92
91
 
93
92
  const { query, limit = 20, filter } = request.body;
94
93
  if (!query || typeof query !== 'string') {
@@ -102,7 +101,7 @@ export const searchRoutes: FastifyPluginAsync = async (fastify) => {
102
101
  const fetchLimit = Math.min(limit * 5, 200);
103
102
 
104
103
  try {
105
- const watcherRes = await fetch(`${config.watcherUrl}/search`, {
104
+ const watcherRes = await fetch(`${watcherUrl}/search`, {
106
105
  method: 'POST',
107
106
  headers: { 'Content-Type': 'application/json' },
108
107
  body: JSON.stringify({ query, limit: fetchLimit, filter }),
@@ -244,10 +243,7 @@ export const searchRoutes: FastifyPluginAsync = async (fastify) => {
244
243
  return reply.code(403).send({ error: 'Insider access required' });
245
244
  }
246
245
 
247
- const config = getConfig();
248
- if (!config.watcherUrl) {
249
- return reply.code(501).send({ error: 'Search not configured' });
250
- }
246
+ const watcherUrl = getServiceUrl('watcher');
251
247
 
252
248
  // Return cached if fresh
253
249
  if (
@@ -260,7 +256,6 @@ export const searchRoutes: FastifyPluginAsync = async (fastify) => {
260
256
  try {
261
257
  // Guard against cache stampede: reuse in-flight fetch
262
258
  if (!facetsFetchPromise) {
263
- const watcherUrl = config.watcherUrl;
264
259
  facetsFetchPromise = (async () => {
265
260
  const watcherRes = await fetch(`${watcherUrl}/search/facets`, {
266
261
  signal: AbortSignal.timeout(15000),
@@ -11,7 +11,6 @@ import { sanitizeConfig } from './config.js';
11
11
  function makeConfig(overrides: Partial<RuntimeConfig> = {}): RuntimeConfig {
12
12
  return {
13
13
  port: 1934,
14
- host: '0.0.0.0',
15
14
  eventTimeoutMs: 30_000,
16
15
  eventLogPurgeMs: 604_800_000,
17
16
  maxZipSizeMb: 100,
@@ -1,9 +1,21 @@
1
1
  import { describe, expect, it, vi } from 'vitest';
2
2
 
3
+ vi.mock('@karmaniverous/jeeves', async (importOriginal) => {
4
+ const mod = await importOriginal<Record<string, unknown>>();
5
+ return {
6
+ ...mod,
7
+ getServiceUrl: (name: string) =>
8
+ `http://127.0.0.1:${name === 'watcher' ? '1936' : name === 'runner' ? '1937' : '1938'}`,
9
+ };
10
+ });
11
+
12
+ // Mock fetch so service health checks don't make real HTTP calls
13
+ const mockFetch = vi.fn().mockRejectedValue(new Error('not reachable'));
14
+ vi.stubGlobal('fetch', mockFetch);
15
+
3
16
  // Mock config
4
17
  const mockConfig = {
5
18
  port: 1934,
6
- host: '0.0.0.0',
7
19
  chromePath: '/usr/bin/chromium',
8
20
  authModes: ['keys'],
9
21
  resolvedInsiders: [{ email: 'a@b.com' }, { email: 'c@d.com' }],
@@ -17,9 +29,6 @@ const mockConfig = {
17
29
  jarPath: '/tools/plantuml.jar',
18
30
  servers: ['https://plantuml.com/plantuml'],
19
31
  },
20
- watcherUrl: null,
21
- runnerUrl: null,
22
- metaUrl: null,
23
32
  exportFormats: ['pdf', 'docx', 'zip'],
24
33
  };
25
34
 
@@ -5,7 +5,7 @@
5
5
  * with server-specific details nested under `health`.
6
6
  */
7
7
 
8
- import { createStatusHandler } from '@karmaniverous/jeeves';
8
+ import { createStatusHandler, getServiceUrl } from '@karmaniverous/jeeves';
9
9
  import type { FastifyPluginAsync } from 'fastify';
10
10
 
11
11
  import { getConfig } from '../config/index.js';
@@ -41,9 +41,9 @@ const handleStatus = createStatusHandler({
41
41
  const config = getConfig();
42
42
 
43
43
  const [watcher, runner, meta] = await Promise.all([
44
- config.watcherUrl ? checkService(config.watcherUrl) : null,
45
- config.runnerUrl ? checkService(config.runnerUrl) : null,
46
- config.metaUrl ? checkService(config.metaUrl) : null,
44
+ checkService(getServiceUrl('watcher')),
45
+ checkService(getServiceUrl('runner')),
46
+ checkService(getServiceUrl('meta')),
47
47
  ]);
48
48
 
49
49
  return {
package/src/server.ts CHANGED
@@ -9,6 +9,7 @@ import { fileURLToPath } from 'node:url';
9
9
 
10
10
  import cookie from '@fastify/cookie';
11
11
  import fastifyStatic from '@fastify/static';
12
+ import { getBindAddress } from '@karmaniverous/jeeves';
12
13
  import Fastify, { type FastifyReply, type FastifyRequest } from 'fastify';
13
14
 
14
15
  import { getConfig, initConfig, isConfigInitialized } from './config/index.js';
@@ -97,9 +98,10 @@ async function start() {
97
98
  // Start queue processor
98
99
  startQueueProcessor();
99
100
 
100
- await fastify.listen({ port: config.port, host: config.host });
101
+ const bindAddress = getBindAddress('server');
102
+ await fastify.listen({ port: config.port, host: bindAddress });
101
103
  console.log(
102
- `Jeeves server listening on ${config.host}:${String(config.port)}`,
104
+ `Jeeves server listening on ${bindAddress}:${String(config.port)}`,
103
105
  );
104
106
  console.log(`Endpoints:`);
105
107
  console.log(` GET /browse/* - File browser SPA`);
@@ -0,0 +1,127 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { csvToHtmlTable, parseCsvRows } from './csv.js';
4
+
5
+ describe('parseCsvRows', () => {
6
+ it('parses simple CSV', () => {
7
+ const rows = parseCsvRows('a,b,c\n1,2,3\n');
8
+ expect(rows).toEqual([
9
+ ['a', 'b', 'c'],
10
+ ['1', '2', '3'],
11
+ ]);
12
+ });
13
+
14
+ it('handles quoted fields with embedded commas', () => {
15
+ const rows = parseCsvRows('name,desc\n"Smith, John","has, commas"\n');
16
+ expect(rows).toEqual([
17
+ ['name', 'desc'],
18
+ ['Smith, John', 'has, commas'],
19
+ ]);
20
+ });
21
+
22
+ it('handles escaped quotes ("")', () => {
23
+ const rows = parseCsvRows('a\n"he said ""hello"""\n');
24
+ expect(rows).toEqual([['a'], ['he said "hello"']]);
25
+ });
26
+
27
+ it('handles empty fields', () => {
28
+ const rows = parseCsvRows('a,,c\n,,\n');
29
+ expect(rows).toEqual([
30
+ ['a', '', 'c'],
31
+ ['', '', ''],
32
+ ]);
33
+ });
34
+
35
+ it('handles trailing newlines', () => {
36
+ const rows = parseCsvRows('a,b\n1,2\n\n');
37
+ expect(rows).toEqual([
38
+ ['a', 'b'],
39
+ ['1', '2'],
40
+ ]);
41
+ });
42
+
43
+ it('handles single row (headers only)', () => {
44
+ const rows = parseCsvRows('name,age,city\n');
45
+ expect(rows).toEqual([['name', 'age', 'city']]);
46
+ });
47
+
48
+ it('returns empty array for empty input', () => {
49
+ const rows = parseCsvRows('');
50
+ expect(rows).toEqual([]);
51
+ });
52
+
53
+ it('handles CRLF line endings', () => {
54
+ const rows = parseCsvRows('a,b\r\n1,2\r\n');
55
+ expect(rows).toEqual([
56
+ ['a', 'b'],
57
+ ['1', '2'],
58
+ ]);
59
+ });
60
+
61
+ it('handles quoted fields with embedded newlines', () => {
62
+ const rows = parseCsvRows('a,b\n"line1\nline2",val\n');
63
+ expect(rows).toEqual([
64
+ ['a', 'b'],
65
+ ['line1\nline2', 'val'],
66
+ ]);
67
+ });
68
+ });
69
+
70
+ describe('csvToHtmlTable', () => {
71
+ it('renders a table with headers and data', () => {
72
+ const html = csvToHtmlTable('Name,Age\nAlice,30\nBob,25\n');
73
+ expect(html).toContain('<thead>');
74
+ expect(html).toContain('<th>Name</th>');
75
+ expect(html).toContain('<th>Age</th>');
76
+ expect(html).toContain('<td>Alice</td>');
77
+ expect(html).toContain('<td>30</td>');
78
+ expect(html).toContain('<td>Bob</td>');
79
+ });
80
+
81
+ it('returns empty paragraph for empty input', () => {
82
+ const html = csvToHtmlTable('');
83
+ expect(html).toBe('<p>Empty CSV</p>');
84
+ });
85
+
86
+ it('escapes HTML in field values', () => {
87
+ const html = csvToHtmlTable('a\n<script>alert("xss")</script>\n');
88
+ expect(html).not.toContain('<script>');
89
+ expect(html).toContain('&lt;script&gt;');
90
+ });
91
+
92
+ it('produces a table with csv-table class for client styling', () => {
93
+ const html = csvToHtmlTable('Name,Age\nAlice,30\n');
94
+ expect(html).toMatch(/<table class="csv-table">/);
95
+ });
96
+
97
+ it('normalizes rows with fewer or more columns than header', () => {
98
+ // Row with fewer columns gets padded, row with more gets truncated
99
+ const csv = 'a,b,c\n1\n4,5,6,7\n';
100
+ const html = csvToHtmlTable(csv);
101
+ // First data row: 1 field → padded to 3 columns
102
+ expect(html).toContain('<tr><td>1</td><td></td><td></td></tr>');
103
+ // Second data row: 4 fields → truncated to 3 columns (no "7")
104
+ expect(html).toContain('<tr><td>4</td><td>5</td><td>6</td></tr>');
105
+ expect(html).not.toContain('<td>7</td>');
106
+ });
107
+
108
+ it('handles a large CSV (>100 rows) without issues', () => {
109
+ const header = 'id,name,value\n';
110
+ const rows = Array.from(
111
+ { length: 150 },
112
+ (_, i) => `${String(i)},item${String(i)},${String(i * 10)}\n`,
113
+ ).join('');
114
+ const csv = header + rows;
115
+
116
+ const html = csvToHtmlTable(csv);
117
+ expect(html).toContain('csv-table');
118
+ expect(html).toContain('<thead>');
119
+ // Verify first and last data rows are present
120
+ expect(html).toContain('<td>0</td>');
121
+ expect(html).toContain('<td>149</td>');
122
+ // Count tbody rows
123
+ const trMatches = html.match(/<tr>/g);
124
+ // 1 header row + 150 data rows = 151
125
+ expect(trMatches).toHaveLength(151);
126
+ });
127
+ });
@@ -0,0 +1,115 @@
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
+ /**
10
+ * Parse a CSV string into rows of fields per RFC 4180.
11
+ *
12
+ * Handles: quoted fields with embedded commas/newlines,
13
+ * escaped quotes (""), empty fields, trailing newlines.
14
+ */
15
+ export function parseCsvRows(csv: string): string[][] {
16
+ const rows: string[][] = [];
17
+ let row: string[] = [];
18
+ let field = '';
19
+ let inQuotes = false;
20
+ let i = 0;
21
+
22
+ while (i < csv.length) {
23
+ const ch = csv[i];
24
+
25
+ if (inQuotes) {
26
+ if (ch === '"') {
27
+ // Peek next character
28
+ if (i + 1 < csv.length && csv[i + 1] === '"') {
29
+ // Escaped quote
30
+ field += '"';
31
+ i += 2;
32
+ } else {
33
+ // End of quoted field
34
+ inQuotes = false;
35
+ i++;
36
+ }
37
+ } else {
38
+ field += ch;
39
+ i++;
40
+ }
41
+ } else {
42
+ if (ch === '"') {
43
+ inQuotes = true;
44
+ i++;
45
+ } else if (ch === ',') {
46
+ row.push(field);
47
+ field = '';
48
+ i++;
49
+ } else if (ch === '\r') {
50
+ // Handle \r\n or bare \r
51
+ row.push(field);
52
+ field = '';
53
+ rows.push(row);
54
+ row = [];
55
+ i++;
56
+ if (i < csv.length && csv[i] === '\n') i++;
57
+ } else if (ch === '\n') {
58
+ row.push(field);
59
+ field = '';
60
+ rows.push(row);
61
+ row = [];
62
+ i++;
63
+ } else {
64
+ field += ch;
65
+ i++;
66
+ }
67
+ }
68
+ }
69
+
70
+ // Push final field/row if there's content
71
+ if (field.length > 0 || row.length > 0) {
72
+ row.push(field);
73
+ rows.push(row);
74
+ }
75
+
76
+ // Remove trailing empty row (from trailing newline)
77
+ const lastRow = rows.length > 0 ? rows[rows.length - 1] : undefined;
78
+ if (lastRow && lastRow.length === 1 && lastRow[0] === '') {
79
+ rows.pop();
80
+ }
81
+
82
+ return rows;
83
+ }
84
+
85
+ /** Escape HTML special characters. */
86
+ function escapeHtml(text: string): string {
87
+ return text
88
+ .replace(/&/g, '&amp;')
89
+ .replace(/</g, '&lt;')
90
+ .replace(/>/g, '&gt;')
91
+ .replace(/"/g, '&quot;');
92
+ }
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: string): string {
101
+ const rows = parseCsvRows(csv);
102
+ if (rows.length === 0) return '<p>Empty CSV</p>';
103
+
104
+ const [headers, ...data] = rows as [string[], ...string[][]];
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
+
114
+ return `<table class="csv-table">${thead}${tbody}</table>`;
115
+ }
@@ -24,3 +24,57 @@ describe('parseMarkdown', () => {
24
24
  expect(headings[0].text).toBe('It\'s a "test"');
25
25
  });
26
26
  });
27
+
28
+ describe('collapsible frontmatter', () => {
29
+ function makeFrontmatter(lineCount: number): string {
30
+ const lines = Array.from(
31
+ { length: lineCount },
32
+ (_, i) => `key${String(i)}: value${String(i)}`,
33
+ );
34
+ return `---\n${lines.join('\n')}\n---\n# Body`;
35
+ }
36
+
37
+ it('collapses frontmatter with >10 lines', () => {
38
+ const md = makeFrontmatter(15);
39
+ const { html } = parseMarkdown(md);
40
+ expect(html).toContain('frontmatter-collapsible');
41
+ expect(html).toContain('frontmatter-toggle');
42
+ expect(html).toContain('Show all (15 lines)');
43
+ });
44
+
45
+ it('does not collapse frontmatter with ≤10 lines', () => {
46
+ const md = makeFrontmatter(8);
47
+ const { html } = parseMarkdown(md);
48
+ expect(html).toContain('frontmatter-block');
49
+ expect(html).not.toContain('frontmatter-collapsible');
50
+ expect(html).not.toContain('frontmatter-toggle');
51
+ });
52
+
53
+ it('preview section contains only the first 10 lines', () => {
54
+ const md = makeFrontmatter(20);
55
+ const { html } = parseMarkdown(md);
56
+ const previewMatch = html.match(
57
+ /frontmatter-preview.*?<code[^>]*>([\s\S]*?)<\/code>/,
58
+ );
59
+ expect(previewMatch).not.toBeNull();
60
+ const previewContent = previewMatch![1];
61
+ const previewLines = previewContent.split('\n');
62
+ expect(previewLines).toHaveLength(10);
63
+ expect(previewLines[0]).toContain('key0');
64
+ expect(previewLines[9]).toContain('key9');
65
+ });
66
+
67
+ it('does NOT collapse frontmatter with exactly 10 lines', () => {
68
+ const md = makeFrontmatter(10);
69
+ const { html } = parseMarkdown(md);
70
+ expect(html).toContain('frontmatter-block');
71
+ expect(html).not.toContain('frontmatter-collapsible');
72
+ });
73
+
74
+ it('collapses frontmatter with exactly 11 lines', () => {
75
+ const md = makeFrontmatter(11);
76
+ const { html } = parseMarkdown(md);
77
+ expect(html).toContain('frontmatter-collapsible');
78
+ expect(html).toContain('Show all (11 lines)');
79
+ });
80
+ });
@@ -197,11 +197,31 @@ export function parseMarkdown(
197
197
 
198
198
  // Prepend frontmatter as a rendered YAML code block
199
199
  if (frontmatter) {
200
+ const FRONTMATTER_COLLAPSE_THRESHOLD = 10;
200
201
  const escaped = frontmatter
201
202
  .replace(/&/g, '&amp;')
202
203
  .replace(/</g, '&lt;')
203
204
  .replace(/>/g, '&gt;');
204
- html = `<div class="frontmatter-block"><pre><code class="language-yaml">${escaped}</code></pre></div>\n${html}`;
205
+ const lines = frontmatter.split('\n');
206
+
207
+ if (lines.length > FRONTMATTER_COLLAPSE_THRESHOLD) {
208
+ const previewEscaped = lines
209
+ .slice(0, FRONTMATTER_COLLAPSE_THRESHOLD)
210
+ .join('\n')
211
+ .replace(/&/g, '&amp;')
212
+ .replace(/</g, '&lt;')
213
+ .replace(/>/g, '&gt;');
214
+ html =
215
+ `<div class="frontmatter-block frontmatter-collapsible">` +
216
+ `<div class="frontmatter-preview"><pre><code class="language-yaml">${previewEscaped}</code></pre></div>` +
217
+ `<div class="frontmatter-full"><pre><code class="language-yaml">${escaped}</code></pre></div>` +
218
+ `<button class="frontmatter-toggle" onclick="this.parentElement.classList.toggle('frontmatter-expanded'); ` +
219
+ `this.textContent = this.parentElement.classList.contains('frontmatter-expanded') ` +
220
+ `? 'Show less' : 'Show all (${String(lines.length)} lines)'">Show all (${String(lines.length)} lines)</button>` +
221
+ `</div>\n${html}`;
222
+ } else {
223
+ html = `<div class="frontmatter-block"><pre><code class="language-yaml">${escaped}</code></pre></div>\n${html}`;
224
+ }
205
225
  }
206
226
 
207
227
  return { html, headings };
@@ -1,23 +1,8 @@
1
1
  /**
2
- * Resolve the service package version using package-directory.
2
+ * Resolve the service package version using core's getPackageVersion().
3
3
  */
4
4
 
5
- import fs from 'node:fs';
6
- import path from 'node:path';
7
- import { fileURLToPath } from 'node:url';
8
-
9
- import { packageDirectorySync } from 'package-directory';
10
-
11
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
12
- const pkgDir = packageDirectorySync({ cwd: __dirname });
13
- if (!pkgDir) {
14
- throw new Error('Could not find package directory for jeeves-server');
15
- }
16
-
17
- const pkgPath = path.join(pkgDir, 'package.json');
18
- const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')) as {
19
- version: string;
20
- };
5
+ import { getPackageVersion } from '@karmaniverous/jeeves';
21
6
 
22
7
  /** The package version of the jeeves-server service package. */
23
- export const packageVersion: string = pkg.version;
8
+ export const packageVersion: string = getPackageVersion(import.meta.url);