mrmd-project 0.1.0 → 0.1.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 (3) hide show
  1. package/package.json +2 -2
  2. package/src/fsml.js +205 -2
  3. package/src/project.js +136 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mrmd-project",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Pure logic for understanding mrmd project structure and conventions",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -29,7 +29,7 @@
29
29
  "license": "MIT",
30
30
  "repository": {
31
31
  "type": "git",
32
- "url": "https://github.com/anthropics/mrmd.git",
32
+ "url": "git+https://github.com/anthropics/mrmd.git",
33
33
  "directory": "packages/mrmd-project"
34
34
  },
35
35
  "bugs": {
package/src/fsml.js CHANGED
@@ -318,12 +318,13 @@ export function titleFromFilename(filename) {
318
318
  }
319
319
 
320
320
  /**
321
- * Compute new paths when reordering files
321
+ * Compute new paths when reordering files (simple version)
322
322
  *
323
323
  * @param {string} sourcePath - Path being moved
324
324
  * @param {string} targetPath - Path to move relative to
325
325
  * @param {'before' | 'after' | 'inside'} position - Where to place
326
- * @returns {object} New path and required renames
326
+ * @returns {object} New path and required renames (source only, no sibling shifts)
327
+ * @deprecated Use computeReorder() for full sibling shifting support
327
328
  */
328
329
  export function computeNewPath(sourcePath, targetPath, position) {
329
330
  const source = parsePath(sourcePath);
@@ -369,3 +370,205 @@ export function computeNewPath(sourcePath, targetPath, position) {
369
370
  renames,
370
371
  };
371
372
  }
373
+
374
+ /**
375
+ * Compute all renames needed when reordering files with proper sibling shifting.
376
+ *
377
+ * This is the main function for drag-drop reordering. It:
378
+ * 1. Computes the new path for the source
379
+ * 2. Shifts siblings to make room (updates their order prefixes)
380
+ * 3. Returns all renames in the correct execution order
381
+ *
382
+ * @param {string} sourcePath - Path being moved
383
+ * @param {string} targetPath - Path to move relative to (drop target)
384
+ * @param {'before' | 'after' | 'inside'} position - Where to place relative to target
385
+ * @param {string[]} siblings - All paths in the target directory (for shift calculation)
386
+ * @returns {{ newPath: string, renames: Array<{ from: string, to: string }> }}
387
+ *
388
+ * @example
389
+ * // Drag 02-config.md before 01-intro.md
390
+ * computeReorder('02-config.md', '01-intro.md', 'before', ['01-intro.md', '02-config.md'])
391
+ * // Returns {
392
+ * // newPath: '01-config.md',
393
+ * // renames: [
394
+ * // { from: '01-intro.md', to: '02-intro.md' }, // shift
395
+ * // { from: '02-config.md', to: '01-config.md' } // move
396
+ * // ]
397
+ * // }
398
+ */
399
+ export function computeReorder(sourcePath, targetPath, position, siblings = []) {
400
+ const source = parsePath(sourcePath);
401
+ const target = parsePath(targetPath);
402
+
403
+ // Determine target directory
404
+ let targetDir = '';
405
+ if (position === 'inside') {
406
+ targetDir = targetPath;
407
+ } else {
408
+ targetDir = target.parent;
409
+ }
410
+
411
+ // Filter siblings to only those in the target directory
412
+ // and parse them for order information
413
+ const siblingsInDir = siblings
414
+ .filter(p => {
415
+ const parsed = parsePath(p);
416
+ return parsed.parent === targetDir;
417
+ })
418
+ .map(p => ({
419
+ path: p,
420
+ ...parsePath(p),
421
+ }))
422
+ .filter(s => s.order !== null) // Only numbered items participate in reordering
423
+ .sort((a, b) => a.order - b.order);
424
+
425
+ // Determine what order number the source should get
426
+ let insertOrder;
427
+ if (position === 'inside') {
428
+ // Moving inside a folder - find max order and add 1, or use 1 if empty
429
+ const maxOrder = siblingsInDir.reduce((max, s) => Math.max(max, s.order || 0), 0);
430
+ insertOrder = maxOrder + 1;
431
+ } else if (position === 'before') {
432
+ insertOrder = target.order || 1;
433
+ } else {
434
+ // after
435
+ insertOrder = (target.order || 0) + 1;
436
+ }
437
+
438
+ // Build new path for source
439
+ const paddedOrder = String(insertOrder).padStart(2, '0');
440
+ const sourceExt = source.isFolder ? '' : source.extension;
441
+ const newFilename = `${paddedOrder}-${source.name}${sourceExt}`;
442
+ const newPath = targetDir ? `${targetDir}/${newFilename}` : newFilename;
443
+
444
+ // Now compute all renames needed
445
+ const renames = [];
446
+
447
+ // Is source currently in the same directory?
448
+ const sourceInSameDir = source.parent === targetDir;
449
+
450
+ // Determine which siblings need to shift
451
+ // We need to shift siblings at or after insertOrder to make room
452
+ // But if source is in same dir and moving "down", logic is different
453
+
454
+ if (sourceInSameDir && source.order !== null) {
455
+ // Source is moving within same directory
456
+ const sourceOrder = source.order;
457
+
458
+ if (sourceOrder < insertOrder) {
459
+ // Moving DOWN (e.g., 01 -> position after 03)
460
+ // Items between old position and new position shift UP (decrease order)
461
+ // insertOrder needs adjustment since source is leaving
462
+ const adjustedInsertOrder = insertOrder - 1;
463
+
464
+ for (const sibling of siblingsInDir) {
465
+ if (sibling.path === sourcePath) continue;
466
+
467
+ if (sibling.order > sourceOrder && sibling.order <= adjustedInsertOrder) {
468
+ // This sibling shifts up (decreases order)
469
+ const newSiblingOrder = sibling.order - 1;
470
+ const siblingExt = sibling.isFolder ? '' : sibling.extension;
471
+ const newSiblingFilename = `${String(newSiblingOrder).padStart(2, '0')}-${sibling.name}${siblingExt}`;
472
+ const newSiblingPath = targetDir ? `${targetDir}/${newSiblingFilename}` : newSiblingFilename;
473
+
474
+ if (sibling.path !== newSiblingPath) {
475
+ renames.push({ from: sibling.path, to: newSiblingPath });
476
+ }
477
+ }
478
+ }
479
+
480
+ // Source gets the adjusted insert position
481
+ const finalSourceOrder = adjustedInsertOrder;
482
+ const finalSourceFilename = `${String(finalSourceOrder).padStart(2, '0')}-${source.name}${sourceExt}`;
483
+ const finalSourcePath = targetDir ? `${targetDir}/${finalSourceFilename}` : finalSourceFilename;
484
+
485
+ if (sourcePath !== finalSourcePath) {
486
+ renames.push({ from: sourcePath, to: finalSourcePath });
487
+ }
488
+
489
+ return { newPath: finalSourcePath, renames: sortRenamesForExecution(renames, 'down') };
490
+
491
+ } else if (sourceOrder > insertOrder) {
492
+ // Moving UP (e.g., 03 -> position before 01)
493
+ // Items at or after insertOrder (up to source's old position) shift DOWN (increase order)
494
+
495
+ for (const sibling of siblingsInDir) {
496
+ if (sibling.path === sourcePath) continue;
497
+
498
+ if (sibling.order >= insertOrder && sibling.order < sourceOrder) {
499
+ // This sibling shifts down (increases order)
500
+ const newSiblingOrder = sibling.order + 1;
501
+ const siblingExt = sibling.isFolder ? '' : sibling.extension;
502
+ const newSiblingFilename = `${String(newSiblingOrder).padStart(2, '0')}-${sibling.name}${siblingExt}`;
503
+ const newSiblingPath = targetDir ? `${targetDir}/${newSiblingFilename}` : newSiblingFilename;
504
+
505
+ if (sibling.path !== newSiblingPath) {
506
+ renames.push({ from: sibling.path, to: newSiblingPath });
507
+ }
508
+ }
509
+ }
510
+
511
+ // Source gets insertOrder
512
+ if (sourcePath !== newPath) {
513
+ renames.push({ from: sourcePath, to: newPath });
514
+ }
515
+
516
+ return { newPath, renames: sortRenamesForExecution(renames, 'up') };
517
+
518
+ } else {
519
+ // Same position - no change needed
520
+ return { newPath: sourcePath, renames: [] };
521
+ }
522
+
523
+ } else {
524
+ // Source is moving from different directory (or has no order)
525
+ // All siblings at or after insertOrder need to shift down (increase order)
526
+
527
+ for (const sibling of siblingsInDir) {
528
+ if (sibling.order >= insertOrder) {
529
+ const newSiblingOrder = sibling.order + 1;
530
+ const siblingExt = sibling.isFolder ? '' : sibling.extension;
531
+ const newSiblingFilename = `${String(newSiblingOrder).padStart(2, '0')}-${sibling.name}${siblingExt}`;
532
+ const newSiblingPath = targetDir ? `${targetDir}/${newSiblingFilename}` : newSiblingFilename;
533
+
534
+ if (sibling.path !== newSiblingPath) {
535
+ renames.push({ from: sibling.path, to: newSiblingPath });
536
+ }
537
+ }
538
+ }
539
+
540
+ // Source gets insertOrder
541
+ if (sourcePath !== newPath) {
542
+ renames.push({ from: sourcePath, to: newPath });
543
+ }
544
+
545
+ return { newPath, renames: sortRenamesForExecution(renames, 'up') };
546
+ }
547
+ }
548
+
549
+ /**
550
+ * Sort renames to avoid conflicts during execution.
551
+ *
552
+ * When shifting DOWN (increasing order numbers), rename from highest to lowest
553
+ * to avoid collisions (e.g., rename 02->03 before 01->02).
554
+ *
555
+ * When shifting UP (decreasing order numbers), rename from lowest to highest.
556
+ *
557
+ * @param {Array<{ from: string, to: string }>} renames
558
+ * @param {'up' | 'down'} direction - 'up' = orders increasing, 'down' = orders decreasing
559
+ * @returns {Array<{ from: string, to: string }>}
560
+ */
561
+ function sortRenamesForExecution(renames, direction) {
562
+ return renames.sort((a, b) => {
563
+ const aOrder = parsePath(a.from).order || 0;
564
+ const bOrder = parsePath(b.from).order || 0;
565
+
566
+ if (direction === 'up') {
567
+ // Shifting down (orders increasing) - rename highest first
568
+ return bOrder - aOrder;
569
+ } else {
570
+ // Shifting up (orders decreasing) - rename lowest first
571
+ return aOrder - bOrder;
572
+ }
573
+ });
574
+ }
package/src/project.js CHANGED
@@ -17,6 +17,27 @@ const DEFAULTS = {
17
17
  name: 'default',
18
18
  auto_start: true,
19
19
  },
20
+ bash: {
21
+ cwd: '.',
22
+ name: 'default',
23
+ auto_start: true,
24
+ },
25
+ r: {
26
+ cwd: '.',
27
+ name: 'default',
28
+ auto_start: true,
29
+ },
30
+ julia: {
31
+ cwd: '.',
32
+ name: 'default',
33
+ auto_start: true,
34
+ },
35
+ term: {
36
+ venv: null, // optional, for venv activation in terminals
37
+ cwd: '.',
38
+ name: 'default',
39
+ auto_start: true,
40
+ },
20
41
  },
21
42
  assets: {
22
43
  directory: '_assets',
@@ -145,7 +166,8 @@ export function parseFrontmatter(content) {
145
166
  try {
146
167
  const parsed = YAML.parse(yamlContent);
147
168
  if (parsed && typeof parsed === 'object') {
148
- return parsed;
169
+ // Normalize shorthand syntax to full config structure
170
+ return normalizeFrontmatter(parsed);
149
171
  }
150
172
  return null;
151
173
  } catch (e) {
@@ -154,6 +176,60 @@ export function parseFrontmatter(content) {
154
176
  }
155
177
  }
156
178
 
179
+ /**
180
+ * Normalize shorthand frontmatter syntax to full config structure.
181
+ *
182
+ * Supports minimal syntax like:
183
+ * python: .venv -> session: { python: { venv: '.venv' } }
184
+ * python: { venv: .venv } -> session: { python: { venv: '.venv' } }
185
+ * bash: { cwd: ./src } -> session: { bash: { cwd: './src' } }
186
+ *
187
+ * This enables a cleaner, more readable frontmatter format while
188
+ * maintaining backwards compatibility with the full verbose syntax.
189
+ *
190
+ * @param {object} frontmatter - Parsed frontmatter object
191
+ * @returns {object} Normalized frontmatter with full structure
192
+ */
193
+ function normalizeFrontmatter(frontmatter) {
194
+ if (!frontmatter || typeof frontmatter !== 'object') {
195
+ return frontmatter;
196
+ }
197
+
198
+ const normalized = { ...frontmatter };
199
+
200
+ // Languages that support shorthand syntax
201
+ const languages = ['python', 'bash', 'term', 'node', 'julia', 'r'];
202
+
203
+ for (const lang of languages) {
204
+ if (lang in normalized && !normalized.session?.[lang]) {
205
+ const value = normalized[lang];
206
+
207
+ // Ensure session object exists
208
+ if (!normalized.session) {
209
+ normalized.session = {};
210
+ }
211
+
212
+ if (typeof value === 'string') {
213
+ // Simple form: python: .venv
214
+ // For python, string is venv path; for others, it's cwd
215
+ if (lang === 'python') {
216
+ normalized.session[lang] = { venv: value };
217
+ } else {
218
+ normalized.session[lang] = { cwd: value };
219
+ }
220
+ } else if (typeof value === 'object' && value !== null) {
221
+ // Object form: python: { venv: .venv, cwd: . }
222
+ normalized.session[lang] = value;
223
+ }
224
+
225
+ // Remove the shorthand key
226
+ delete normalized[lang];
227
+ }
228
+ }
229
+
230
+ return normalized;
231
+ }
232
+
157
233
  /**
158
234
  * Deep merge project config with document frontmatter
159
235
  *
@@ -171,9 +247,10 @@ export function mergeConfig(projectConfig, frontmatter) {
171
247
  }
172
248
 
173
249
  /**
174
- * Resolve full session configuration for a document
250
+ * Resolve full session configuration for a document (Python)
175
251
  *
176
252
  * Computes absolute paths and full session name.
253
+ * Uses legacy naming format {project}:{name} for backwards compatibility.
177
254
  *
178
255
  * @param {string} documentPath - Absolute path to document
179
256
  * @param {string} projectRoot - Absolute path to project root
@@ -191,26 +268,82 @@ export function mergeConfig(projectConfig, frontmatter) {
191
268
  export function resolveSession(documentPath, projectRoot, mergedConfig) {
192
269
  const pythonConfig = mergedConfig?.session?.python || {};
193
270
  const defaults = DEFAULTS.session.python;
271
+ const projectName = mergedConfig?.name || 'unnamed';
194
272
 
195
273
  // Get values with defaults
196
274
  const venvRelative = pythonConfig.venv || defaults.venv;
197
275
  const cwdRelative = pythonConfig.cwd || defaults.cwd;
198
276
  const sessionName = pythonConfig.name || defaults.name;
199
277
  const autoStart = pythonConfig.auto_start !== undefined ? pythonConfig.auto_start : defaults.auto_start;
200
- const projectName = mergedConfig?.name || 'unnamed';
201
278
 
202
279
  // Resolve paths relative to project root
203
280
  const venv = resolvePath(projectRoot, venvRelative);
204
281
  const cwd = resolvePath(projectRoot, cwdRelative);
205
282
 
283
+ // Legacy naming format: {project}:{name} (no language in name)
206
284
  return {
207
285
  name: `${projectName}:${sessionName}`,
208
286
  venv,
209
287
  cwd,
210
288
  autoStart,
289
+ language: 'python',
211
290
  };
212
291
  }
213
292
 
293
+ /**
294
+ * Resolve session configuration for a specific language
295
+ *
296
+ * @param {'python' | 'bash' | 'term'} language - Language to resolve session for
297
+ * @param {string} documentPath - Absolute path to document
298
+ * @param {string} projectRoot - Absolute path to project root
299
+ * @param {object} mergedConfig - Merged configuration
300
+ * @returns {object} Resolved session config with absolute paths
301
+ *
302
+ * @example
303
+ * const session = Project.resolveSessionForLanguage(
304
+ * 'bash',
305
+ * '/home/user/thesis/chapter/doc.md',
306
+ * '/home/user/thesis',
307
+ * { name: 'thesis', session: { bash: { cwd: '.', name: 'default' } } }
308
+ * );
309
+ * // Returns { name: 'thesis:bash:default', cwd: '/home/user/thesis', autoStart: true }
310
+ */
311
+ export function resolveSessionForLanguage(language, documentPath, projectRoot, mergedConfig) {
312
+ const langConfig = mergedConfig?.session?.[language] || {};
313
+ const defaults = DEFAULTS.session[language] || {};
314
+ const projectName = mergedConfig?.name || 'unnamed';
315
+
316
+ // Common fields for all languages
317
+ const cwdRelative = langConfig.cwd || defaults.cwd || '.';
318
+ const sessionName = langConfig.name || defaults.name || 'default';
319
+ const autoStart = langConfig.auto_start !== undefined ? langConfig.auto_start : (defaults.auto_start !== undefined ? defaults.auto_start : true);
320
+
321
+ // Resolve cwd relative to project root
322
+ const cwd = resolvePath(projectRoot, cwdRelative);
323
+
324
+ // Base session info
325
+ const session = {
326
+ name: `${projectName}:${language}:${sessionName}`,
327
+ cwd,
328
+ autoStart,
329
+ language,
330
+ };
331
+
332
+ // Language-specific fields
333
+ if (language === 'python') {
334
+ const venvRelative = langConfig.venv || defaults.venv || '.venv';
335
+ session.venv = resolvePath(projectRoot, venvRelative);
336
+ } else if (language === 'term') {
337
+ // Term supports optional venv for activation in terminals
338
+ const venvRelative = langConfig.venv || defaults.venv;
339
+ if (venvRelative) {
340
+ session.venv = resolvePath(projectRoot, venvRelative);
341
+ }
342
+ }
343
+
344
+ return session;
345
+ }
346
+
214
347
  /**
215
348
  * Resolve a potentially relative path against a base directory
216
349
  * @private