mrmd-server 0.1.0 → 0.1.2
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.
- package/LICENSE +21 -0
- package/bin/cli.js +4 -20
- package/package.json +20 -3
- package/src/api/bash.js +72 -189
- package/src/api/file.js +26 -20
- package/src/api/index.js +5 -0
- package/src/api/notebook.js +290 -0
- package/src/api/project.js +178 -12
- package/src/api/pty.js +73 -293
- package/src/api/r.js +337 -0
- package/src/api/session.js +96 -251
- package/src/api/settings.js +782 -0
- package/src/api/system.js +199 -1
- package/src/server.js +133 -8
- package/src/services.js +42 -0
- package/src/sync-manager.js +223 -0
- package/static/favicon.png +0 -0
- package/static/http-shim.js +172 -3
- package/static/index.html +1 -0
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Notebook (Jupyter) API routes
|
|
3
|
+
*
|
|
4
|
+
* Mirrors electronAPI.notebook.*
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { Router } from 'express';
|
|
8
|
+
import { spawn } from 'child_process';
|
|
9
|
+
import path from 'path';
|
|
10
|
+
import fs from 'fs/promises';
|
|
11
|
+
|
|
12
|
+
// Active sync processes: ipynbPath -> { process, shadowPath, syncPort }
|
|
13
|
+
const syncProcesses = new Map();
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Create notebook routes
|
|
17
|
+
* @param {import('../server.js').ServerContext} ctx
|
|
18
|
+
*/
|
|
19
|
+
export function createNotebookRoutes(ctx) {
|
|
20
|
+
const router = Router();
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* POST /api/notebook/convert
|
|
24
|
+
* Convert a Jupyter notebook to markdown (deletes the .ipynb file)
|
|
25
|
+
* Mirrors: electronAPI.notebook.convert(ipynbPath)
|
|
26
|
+
*/
|
|
27
|
+
router.post('/convert', async (req, res) => {
|
|
28
|
+
try {
|
|
29
|
+
const { ipynbPath } = req.body;
|
|
30
|
+
if (!ipynbPath) {
|
|
31
|
+
return res.status(400).json({ error: 'ipynbPath required' });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const fullPath = path.resolve(ctx.projectDir, ipynbPath);
|
|
35
|
+
|
|
36
|
+
// Verify file exists and is .ipynb
|
|
37
|
+
try {
|
|
38
|
+
await fs.access(fullPath);
|
|
39
|
+
} catch {
|
|
40
|
+
return res.status(404).json({ success: false, error: 'File not found' });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (!fullPath.endsWith('.ipynb')) {
|
|
44
|
+
return res.status(400).json({ success: false, error: 'File must be a .ipynb file' });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Read notebook
|
|
48
|
+
const content = await fs.readFile(fullPath, 'utf-8');
|
|
49
|
+
const notebook = JSON.parse(content);
|
|
50
|
+
|
|
51
|
+
// Convert to markdown
|
|
52
|
+
const markdown = convertNotebookToMarkdown(notebook);
|
|
53
|
+
|
|
54
|
+
// Write markdown file
|
|
55
|
+
const mdPath = fullPath.replace(/\.ipynb$/, '.md');
|
|
56
|
+
await fs.writeFile(mdPath, markdown, 'utf-8');
|
|
57
|
+
|
|
58
|
+
// Delete original .ipynb
|
|
59
|
+
await fs.unlink(fullPath);
|
|
60
|
+
|
|
61
|
+
res.json({
|
|
62
|
+
success: true,
|
|
63
|
+
mdPath: path.relative(ctx.projectDir, mdPath),
|
|
64
|
+
});
|
|
65
|
+
} catch (err) {
|
|
66
|
+
console.error('[notebook:convert]', err);
|
|
67
|
+
res.status(500).json({ success: false, error: err.message });
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* POST /api/notebook/start-sync
|
|
73
|
+
* Start syncing a notebook (creates shadow .md in .mrmd folder)
|
|
74
|
+
* Mirrors: electronAPI.notebook.startSync(ipynbPath)
|
|
75
|
+
*/
|
|
76
|
+
router.post('/start-sync', async (req, res) => {
|
|
77
|
+
try {
|
|
78
|
+
const { ipynbPath } = req.body;
|
|
79
|
+
if (!ipynbPath) {
|
|
80
|
+
return res.status(400).json({ error: 'ipynbPath required' });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const fullPath = path.resolve(ctx.projectDir, ipynbPath);
|
|
84
|
+
|
|
85
|
+
// Check if already syncing
|
|
86
|
+
if (syncProcesses.has(fullPath)) {
|
|
87
|
+
const existing = syncProcesses.get(fullPath);
|
|
88
|
+
return res.json({
|
|
89
|
+
success: true,
|
|
90
|
+
shadowPath: existing.shadowPath,
|
|
91
|
+
syncPort: existing.syncPort,
|
|
92
|
+
reused: true,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Verify file exists
|
|
97
|
+
try {
|
|
98
|
+
await fs.access(fullPath);
|
|
99
|
+
} catch {
|
|
100
|
+
return res.status(404).json({ success: false, error: 'File not found' });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Create .mrmd directory if needed
|
|
104
|
+
const mrmdDir = path.join(path.dirname(fullPath), '.mrmd');
|
|
105
|
+
await fs.mkdir(mrmdDir, { recursive: true });
|
|
106
|
+
|
|
107
|
+
// Create shadow markdown file
|
|
108
|
+
const baseName = path.basename(fullPath, '.ipynb');
|
|
109
|
+
const shadowPath = path.join(mrmdDir, `${baseName}.shadow.md`);
|
|
110
|
+
|
|
111
|
+
// Read and convert notebook
|
|
112
|
+
const content = await fs.readFile(fullPath, 'utf-8');
|
|
113
|
+
const notebook = JSON.parse(content);
|
|
114
|
+
const markdown = convertNotebookToMarkdown(notebook);
|
|
115
|
+
await fs.writeFile(shadowPath, markdown, 'utf-8');
|
|
116
|
+
|
|
117
|
+
// Find mrmd-jupyter-bridge
|
|
118
|
+
const bridgePaths = [
|
|
119
|
+
path.join(ctx.projectDir, '../mrmd-jupyter-bridge'),
|
|
120
|
+
path.join(process.cwd(), '../mrmd-jupyter-bridge'),
|
|
121
|
+
path.join(process.cwd(), 'mrmd-jupyter-bridge'),
|
|
122
|
+
];
|
|
123
|
+
|
|
124
|
+
let bridgePath = null;
|
|
125
|
+
for (const p of bridgePaths) {
|
|
126
|
+
try {
|
|
127
|
+
await fs.access(path.join(p, 'package.json'));
|
|
128
|
+
bridgePath = p;
|
|
129
|
+
break;
|
|
130
|
+
} catch {}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
let syncPort = null;
|
|
134
|
+
let proc = null;
|
|
135
|
+
|
|
136
|
+
if (bridgePath) {
|
|
137
|
+
// Start sync process
|
|
138
|
+
syncPort = 4450 + syncProcesses.size;
|
|
139
|
+
|
|
140
|
+
proc = spawn('node', [
|
|
141
|
+
path.join(bridgePath, 'src', 'sync.js'),
|
|
142
|
+
'--notebook', fullPath,
|
|
143
|
+
'--shadow', shadowPath,
|
|
144
|
+
'--port', syncPort.toString(),
|
|
145
|
+
], {
|
|
146
|
+
cwd: path.dirname(fullPath),
|
|
147
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
proc.on('exit', () => {
|
|
151
|
+
syncProcesses.delete(fullPath);
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
syncProcesses.set(fullPath, {
|
|
156
|
+
process: proc,
|
|
157
|
+
shadowPath: path.relative(ctx.projectDir, shadowPath),
|
|
158
|
+
syncPort,
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
res.json({
|
|
162
|
+
success: true,
|
|
163
|
+
shadowPath: path.relative(ctx.projectDir, shadowPath),
|
|
164
|
+
syncPort,
|
|
165
|
+
});
|
|
166
|
+
} catch (err) {
|
|
167
|
+
console.error('[notebook:startSync]', err);
|
|
168
|
+
res.status(500).json({ success: false, error: err.message });
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* POST /api/notebook/stop-sync
|
|
174
|
+
* Stop syncing a notebook
|
|
175
|
+
* Mirrors: electronAPI.notebook.stopSync(ipynbPath)
|
|
176
|
+
*/
|
|
177
|
+
router.post('/stop-sync', async (req, res) => {
|
|
178
|
+
try {
|
|
179
|
+
const { ipynbPath } = req.body;
|
|
180
|
+
if (!ipynbPath) {
|
|
181
|
+
return res.status(400).json({ error: 'ipynbPath required' });
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const fullPath = path.resolve(ctx.projectDir, ipynbPath);
|
|
185
|
+
const sync = syncProcesses.get(fullPath);
|
|
186
|
+
|
|
187
|
+
if (sync) {
|
|
188
|
+
if (sync.process && !sync.process.killed) {
|
|
189
|
+
sync.process.kill();
|
|
190
|
+
}
|
|
191
|
+
syncProcesses.delete(fullPath);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
res.json({ success: true });
|
|
195
|
+
} catch (err) {
|
|
196
|
+
console.error('[notebook:stopSync]', err);
|
|
197
|
+
res.status(500).json({ success: false, error: err.message });
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
return router;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Convert Jupyter notebook to markdown
|
|
206
|
+
*/
|
|
207
|
+
function convertNotebookToMarkdown(notebook) {
|
|
208
|
+
const cells = notebook.cells || [];
|
|
209
|
+
const lines = [];
|
|
210
|
+
|
|
211
|
+
// Add frontmatter if notebook has metadata
|
|
212
|
+
if (notebook.metadata?.kernelspec?.language) {
|
|
213
|
+
lines.push('---');
|
|
214
|
+
lines.push(`language: ${notebook.metadata.kernelspec.language}`);
|
|
215
|
+
if (notebook.metadata.kernelspec.display_name) {
|
|
216
|
+
lines.push(`kernel: ${notebook.metadata.kernelspec.display_name}`);
|
|
217
|
+
}
|
|
218
|
+
lines.push('---');
|
|
219
|
+
lines.push('');
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
for (const cell of cells) {
|
|
223
|
+
const source = Array.isArray(cell.source)
|
|
224
|
+
? cell.source.join('')
|
|
225
|
+
: cell.source || '';
|
|
226
|
+
|
|
227
|
+
if (cell.cell_type === 'markdown') {
|
|
228
|
+
lines.push(source.trim());
|
|
229
|
+
lines.push('');
|
|
230
|
+
} else if (cell.cell_type === 'code') {
|
|
231
|
+
// Determine language
|
|
232
|
+
const lang = notebook.metadata?.kernelspec?.language || 'python';
|
|
233
|
+
|
|
234
|
+
lines.push('```' + lang);
|
|
235
|
+
lines.push(source.trim());
|
|
236
|
+
lines.push('```');
|
|
237
|
+
lines.push('');
|
|
238
|
+
|
|
239
|
+
// Add outputs if present
|
|
240
|
+
if (cell.outputs && cell.outputs.length > 0) {
|
|
241
|
+
for (const output of cell.outputs) {
|
|
242
|
+
if (output.output_type === 'stream') {
|
|
243
|
+
const text = Array.isArray(output.text)
|
|
244
|
+
? output.text.join('')
|
|
245
|
+
: output.text || '';
|
|
246
|
+
if (text.trim()) {
|
|
247
|
+
lines.push('```output');
|
|
248
|
+
lines.push(text.trim());
|
|
249
|
+
lines.push('```');
|
|
250
|
+
lines.push('');
|
|
251
|
+
}
|
|
252
|
+
} else if (output.output_type === 'execute_result' || output.output_type === 'display_data') {
|
|
253
|
+
const data = output.data || {};
|
|
254
|
+
if (data['text/plain']) {
|
|
255
|
+
const text = Array.isArray(data['text/plain'])
|
|
256
|
+
? data['text/plain'].join('')
|
|
257
|
+
: data['text/plain'];
|
|
258
|
+
lines.push('```output');
|
|
259
|
+
lines.push(text.trim());
|
|
260
|
+
lines.push('```');
|
|
261
|
+
lines.push('');
|
|
262
|
+
}
|
|
263
|
+
// Handle images
|
|
264
|
+
if (data['image/png']) {
|
|
265
|
+
lines.push(``);
|
|
266
|
+
lines.push('');
|
|
267
|
+
}
|
|
268
|
+
} else if (output.output_type === 'error') {
|
|
269
|
+
const traceback = output.traceback || [];
|
|
270
|
+
// Strip ANSI codes
|
|
271
|
+
const cleanTraceback = traceback
|
|
272
|
+
.map(line => line.replace(/\x1b\[[0-9;]*m/g, ''))
|
|
273
|
+
.join('\n');
|
|
274
|
+
lines.push('```error');
|
|
275
|
+
lines.push(cleanTraceback.trim());
|
|
276
|
+
lines.push('```');
|
|
277
|
+
lines.push('');
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
} else if (cell.cell_type === 'raw') {
|
|
282
|
+
lines.push('```');
|
|
283
|
+
lines.push(source.trim());
|
|
284
|
+
lines.push('```');
|
|
285
|
+
lines.push('');
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return lines.join('\n');
|
|
290
|
+
}
|
package/src/api/project.js
CHANGED
|
@@ -1,13 +1,38 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Project API routes
|
|
3
3
|
*
|
|
4
|
-
* Mirrors electronAPI.project.*
|
|
4
|
+
* Mirrors electronAPI.project.* using ProjectService from mrmd-electron
|
|
5
|
+
* and dynamic sync server management.
|
|
5
6
|
*/
|
|
6
7
|
|
|
7
8
|
import { Router } from 'express';
|
|
8
9
|
import path from 'path';
|
|
9
10
|
import fs from 'fs/promises';
|
|
11
|
+
import { existsSync, readFileSync } from 'fs';
|
|
10
12
|
import { watch } from 'chokidar';
|
|
13
|
+
import { Project } from 'mrmd-project';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Detect project from a file path
|
|
17
|
+
* Returns { root, config } or null if not in a project
|
|
18
|
+
*/
|
|
19
|
+
function detectProject(filePath) {
|
|
20
|
+
// Use mrmd-project's findRoot to locate project root
|
|
21
|
+
const root = Project.findRoot(filePath, (dir) => existsSync(path.join(dir, 'mrmd.md')));
|
|
22
|
+
|
|
23
|
+
if (!root) return null;
|
|
24
|
+
|
|
25
|
+
// Read and parse mrmd.md config
|
|
26
|
+
try {
|
|
27
|
+
const mrmdPath = path.join(root, 'mrmd.md');
|
|
28
|
+
const content = readFileSync(mrmdPath, 'utf8');
|
|
29
|
+
const config = Project.parseConfig(content);
|
|
30
|
+
return { root, config };
|
|
31
|
+
} catch (e) {
|
|
32
|
+
// mrmd.md exists but couldn't be read/parsed
|
|
33
|
+
return { root, config: {} };
|
|
34
|
+
}
|
|
35
|
+
}
|
|
11
36
|
|
|
12
37
|
/**
|
|
13
38
|
* Create project routes
|
|
@@ -15,11 +40,14 @@ import { watch } from 'chokidar';
|
|
|
15
40
|
*/
|
|
16
41
|
export function createProjectRoutes(ctx) {
|
|
17
42
|
const router = Router();
|
|
43
|
+
const { projectService, acquireSyncServer, releaseSyncServer, getSyncServer, listSyncServers } = ctx;
|
|
18
44
|
|
|
19
45
|
/**
|
|
20
46
|
* GET /api/project?path=...
|
|
21
47
|
* Get project info for a file path
|
|
22
48
|
* Mirrors: electronAPI.project.get(filePath)
|
|
49
|
+
*
|
|
50
|
+
* Enhanced: Now uses mrmd-project for detection and starts sync server dynamically
|
|
23
51
|
*/
|
|
24
52
|
router.get('/', async (req, res) => {
|
|
25
53
|
try {
|
|
@@ -28,14 +56,91 @@ export function createProjectRoutes(ctx) {
|
|
|
28
56
|
return res.status(400).json({ error: 'path query parameter required' });
|
|
29
57
|
}
|
|
30
58
|
|
|
31
|
-
|
|
32
|
-
|
|
59
|
+
// Detect project from file path
|
|
60
|
+
const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(ctx.projectDir || process.cwd(), filePath);
|
|
61
|
+
const detected = detectProject(resolvedPath);
|
|
62
|
+
|
|
63
|
+
if (detected) {
|
|
64
|
+
// Get or start sync server for this project
|
|
65
|
+
let syncInfo = null;
|
|
66
|
+
try {
|
|
67
|
+
syncInfo = await acquireSyncServer(detected.root);
|
|
68
|
+
} catch (e) {
|
|
69
|
+
console.warn('[project] Could not start sync server:', e.message);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Build nav tree
|
|
73
|
+
const navTree = await buildNavTree(detected.root);
|
|
74
|
+
|
|
75
|
+
res.json({
|
|
76
|
+
root: detected.root,
|
|
77
|
+
config: detected.config,
|
|
78
|
+
navTree,
|
|
79
|
+
files: flattenNavTree(navTree, detected.root),
|
|
80
|
+
currentFile: resolvedPath,
|
|
81
|
+
syncPort: syncInfo?.port || null,
|
|
82
|
+
});
|
|
83
|
+
} else {
|
|
84
|
+
// No project detected, use fallback
|
|
85
|
+
const projectInfo = await getProjectInfo(filePath, ctx.projectDir || process.cwd());
|
|
86
|
+
res.json(projectInfo);
|
|
87
|
+
}
|
|
33
88
|
} catch (err) {
|
|
34
89
|
console.error('[project:get]', err);
|
|
35
90
|
res.status(500).json({ error: err.message });
|
|
36
91
|
}
|
|
37
92
|
});
|
|
38
93
|
|
|
94
|
+
/**
|
|
95
|
+
* GET /api/project/sync
|
|
96
|
+
* List all active sync servers
|
|
97
|
+
*/
|
|
98
|
+
router.get('/sync', async (req, res) => {
|
|
99
|
+
try {
|
|
100
|
+
const servers = listSyncServers();
|
|
101
|
+
res.json(servers);
|
|
102
|
+
} catch (err) {
|
|
103
|
+
console.error('[project:sync]', err);
|
|
104
|
+
res.status(500).json({ error: err.message });
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* POST /api/project/sync/acquire
|
|
110
|
+
* Acquire sync server for a project directory
|
|
111
|
+
*/
|
|
112
|
+
router.post('/sync/acquire', async (req, res) => {
|
|
113
|
+
try {
|
|
114
|
+
const { projectDir } = req.body;
|
|
115
|
+
if (!projectDir) {
|
|
116
|
+
return res.status(400).json({ error: 'projectDir required' });
|
|
117
|
+
}
|
|
118
|
+
const server = await acquireSyncServer(projectDir);
|
|
119
|
+
res.json({ port: server.port, dir: server.dir, refCount: server.refCount });
|
|
120
|
+
} catch (err) {
|
|
121
|
+
console.error('[project:sync/acquire]', err);
|
|
122
|
+
res.status(500).json({ error: err.message });
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* POST /api/project/sync/release
|
|
128
|
+
* Release sync server for a project directory
|
|
129
|
+
*/
|
|
130
|
+
router.post('/sync/release', async (req, res) => {
|
|
131
|
+
try {
|
|
132
|
+
const { projectDir } = req.body;
|
|
133
|
+
if (!projectDir) {
|
|
134
|
+
return res.status(400).json({ error: 'projectDir required' });
|
|
135
|
+
}
|
|
136
|
+
releaseSyncServer(projectDir);
|
|
137
|
+
res.json({ success: true });
|
|
138
|
+
} catch (err) {
|
|
139
|
+
console.error('[project:sync/release]', err);
|
|
140
|
+
res.status(500).json({ error: err.message });
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
|
|
39
144
|
/**
|
|
40
145
|
* POST /api/project
|
|
41
146
|
* Create a new mrmd project
|
|
@@ -198,28 +303,89 @@ async function getProjectInfo(filePath, defaultRoot) {
|
|
|
198
303
|
// Build nav tree
|
|
199
304
|
const navTree = await buildNavTree(projectRoot);
|
|
200
305
|
|
|
306
|
+
// Collect all files from nav tree (flattened)
|
|
307
|
+
const files = flattenNavTree(navTree, projectRoot);
|
|
308
|
+
|
|
201
309
|
return {
|
|
202
310
|
root: projectRoot,
|
|
203
311
|
config: mrmdConfig,
|
|
204
312
|
navTree,
|
|
313
|
+
files,
|
|
205
314
|
currentFile: resolvedPath,
|
|
206
315
|
};
|
|
207
316
|
}
|
|
208
317
|
|
|
209
318
|
/**
|
|
210
|
-
*
|
|
319
|
+
* Flatten nav tree to list of file paths
|
|
320
|
+
*/
|
|
321
|
+
function flattenNavTree(nodes, projectRoot) {
|
|
322
|
+
const files = [];
|
|
323
|
+
for (const node of nodes) {
|
|
324
|
+
if (!node.isFolder) {
|
|
325
|
+
files.push(path.join(projectRoot, node.path));
|
|
326
|
+
} else if (node.children) {
|
|
327
|
+
files.push(...flattenNavTree(node.children, projectRoot));
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return files;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Parse mrmd.md config (from yaml config code blocks)
|
|
211
335
|
*/
|
|
212
336
|
function parseMrmdConfig(content) {
|
|
213
337
|
const config = { venv: '.venv' };
|
|
214
338
|
|
|
215
|
-
//
|
|
216
|
-
const
|
|
217
|
-
|
|
339
|
+
// Find all ```yaml config blocks and merge them
|
|
340
|
+
const configBlockRegex = /```yaml\s+config\n([\s\S]*?)```/g;
|
|
341
|
+
let match;
|
|
342
|
+
|
|
343
|
+
while ((match = configBlockRegex.exec(content)) !== null) {
|
|
218
344
|
const yaml = match[1];
|
|
219
|
-
|
|
345
|
+
|
|
346
|
+
// Parse name
|
|
347
|
+
const nameMatch = yaml.match(/^name:\s*["']?([^"'\n]+)["']?/m);
|
|
348
|
+
if (nameMatch) {
|
|
349
|
+
config.name = nameMatch[1].trim();
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Parse venv (direct or under session.python)
|
|
353
|
+
const venvMatch = yaml.match(/^\s*venv:\s*["']?([^"'\n]+)["']?/m);
|
|
220
354
|
if (venvMatch) {
|
|
221
355
|
config.venv = venvMatch[1].trim();
|
|
222
356
|
}
|
|
357
|
+
|
|
358
|
+
// Parse session config
|
|
359
|
+
const sessionMatch = yaml.match(/^session:\n([\s\S]*?)(?=^[^\s]|\Z)/m);
|
|
360
|
+
if (sessionMatch) {
|
|
361
|
+
config.session = {};
|
|
362
|
+
const sessionYaml = sessionMatch[1];
|
|
363
|
+
|
|
364
|
+
// Parse python session
|
|
365
|
+
const pythonMatch = sessionYaml.match(/^\s+python:\n([\s\S]*?)(?=^\s+\w+:|\Z)/m);
|
|
366
|
+
if (pythonMatch) {
|
|
367
|
+
config.session.python = {};
|
|
368
|
+
const pyYaml = pythonMatch[1];
|
|
369
|
+
const pyVenv = pyYaml.match(/venv:\s*["']?([^"'\n]+)["']?/);
|
|
370
|
+
if (pyVenv) config.session.python.venv = pyVenv[1].trim();
|
|
371
|
+
const pyCwd = pyYaml.match(/cwd:\s*["']?([^"'\n]+)["']?/);
|
|
372
|
+
if (pyCwd) config.session.python.cwd = pyCwd[1].trim();
|
|
373
|
+
const pyName = pyYaml.match(/name:\s*["']?([^"'\n]+)["']?/);
|
|
374
|
+
if (pyName) config.session.python.name = pyName[1].trim();
|
|
375
|
+
const pyAutoStart = pyYaml.match(/auto_start:\s*(true|false)/);
|
|
376
|
+
if (pyAutoStart) config.session.python.auto_start = pyAutoStart[1] === 'true';
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Also check YAML frontmatter for backwards compatibility
|
|
382
|
+
const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
|
|
383
|
+
if (fmMatch) {
|
|
384
|
+
const yaml = fmMatch[1];
|
|
385
|
+
const venvMatch = yaml.match(/venv:\s*(.+)/);
|
|
386
|
+
if (venvMatch && !config.venv) {
|
|
387
|
+
config.venv = venvMatch[1].trim();
|
|
388
|
+
}
|
|
223
389
|
}
|
|
224
390
|
|
|
225
391
|
return config;
|
|
@@ -258,16 +424,16 @@ async function buildNavTree(projectRoot, relativePath = '') {
|
|
|
258
424
|
// Only include directories that have .md files (directly or nested)
|
|
259
425
|
if (children.length > 0 || await hasIndexFile(path.join(projectRoot, entryRelPath))) {
|
|
260
426
|
nodes.push({
|
|
261
|
-
|
|
262
|
-
|
|
427
|
+
isFolder: true,
|
|
428
|
+
title: cleanName(entry.name),
|
|
263
429
|
path: entryRelPath,
|
|
264
430
|
children,
|
|
265
431
|
});
|
|
266
432
|
}
|
|
267
433
|
} else if (entry.name.endsWith('.md') && entry.name !== 'mrmd.md') {
|
|
268
434
|
nodes.push({
|
|
269
|
-
|
|
270
|
-
|
|
435
|
+
isFolder: false,
|
|
436
|
+
title: cleanName(entry.name.replace(/\.md$/, '')),
|
|
271
437
|
path: entryRelPath,
|
|
272
438
|
});
|
|
273
439
|
}
|