mrmd-project 0.1.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.
package/spec.md ADDED
@@ -0,0 +1,1056 @@
1
+ # mrmd-project Specification
2
+
3
+ > Pure logic for understanding mrmd project structure and conventions.
4
+
5
+ **Mission:** Given file paths and contents, compute everything needed to understand project structure, resolve sessions, handle links, and manage assets.
6
+
7
+ **Properties:**
8
+ - Zero I/O, zero side effects
9
+ - Works in Node, browser, anywhere
10
+ - All functions are pure: same input → same output
11
+
12
+ ---
13
+
14
+ ## Installation & Usage
15
+
16
+ ```javascript
17
+ import { Project, FSML, Links, Assets, Scaffold, Search } from 'mrmd-project';
18
+ ```
19
+
20
+ ---
21
+
22
+ ## 1. Project Module
23
+
24
+ ### 1.1 Project.findRoot
25
+
26
+ Walk up from a path to find the project root (directory containing `mrmd.md`).
27
+
28
+ ```typescript
29
+ function findRoot(startPath: string, hasFile: (path: string) => boolean): string | null
30
+ ```
31
+
32
+ **Note:** `hasFile` is injected to avoid I/O in the pure function.
33
+
34
+ ```javascript
35
+ // Test: findRoot with mrmd.md present
36
+ const hasFile = (p) => [
37
+ '/home/user/thesis/mrmd.md',
38
+ '/home/user/thesis/chapter1.md',
39
+ '/home/user/thesis/02-methods/intro.md',
40
+ ].some(f => f === p || f.startsWith(p + '/'));
41
+
42
+ const root = Project.findRoot('/home/user/thesis/02-methods/intro.md',
43
+ (p) => hasFile(p + '/mrmd.md'));
44
+
45
+ console.assert(root === '/home/user/thesis', `Expected /home/user/thesis, got ${root}`);
46
+ console.log('✓ findRoot finds mrmd.md in ancestor');
47
+ ```
48
+
49
+ ```javascript
50
+ // Test: findRoot with no project
51
+ const root = Project.findRoot('/home/user/random/file.md', () => false);
52
+
53
+ console.assert(root === null, `Expected null, got ${root}`);
54
+ console.log('✓ findRoot returns null when no project');
55
+ ```
56
+
57
+ ---
58
+
59
+ ### 1.2 Project.parseConfig
60
+
61
+ Extract and merge `yaml config` blocks from mrmd.md content.
62
+
63
+ ```typescript
64
+ interface ProjectConfig {
65
+ name?: string;
66
+ description?: string;
67
+ session?: {
68
+ python?: {
69
+ venv?: string;
70
+ cwd?: string;
71
+ name?: string;
72
+ auto_start?: boolean;
73
+ };
74
+ };
75
+ nav?: {
76
+ order?: string[];
77
+ };
78
+ assets?: {
79
+ directory?: string;
80
+ };
81
+ build?: {
82
+ output?: string;
83
+ formats?: string[];
84
+ };
85
+ }
86
+
87
+ function parseConfig(mrmdMdContent: string): ProjectConfig
88
+ ```
89
+
90
+ ```javascript
91
+ // Test: parseConfig with single block
92
+ const content = `# My Project
93
+
94
+ Some description.
95
+
96
+ \`\`\`yaml config
97
+ name: "My Thesis"
98
+ session:
99
+ python:
100
+ venv: ".venv"
101
+ \`\`\`
102
+ `;
103
+
104
+ const config = Project.parseConfig(content);
105
+
106
+ console.assert(config.name === 'My Thesis', `Expected "My Thesis", got ${config.name}`);
107
+ console.assert(config.session.python.venv === '.venv', `Expected ".venv", got ${config.session?.python?.venv}`);
108
+ console.log('✓ parseConfig extracts single yaml config block');
109
+ ```
110
+
111
+ ```javascript
112
+ // Test: parseConfig with multiple blocks (deep merge)
113
+ const content = `# My Project
114
+
115
+ \`\`\`yaml config
116
+ name: "My Thesis"
117
+ session:
118
+ python:
119
+ venv: ".venv"
120
+ \`\`\`
121
+
122
+ More prose here.
123
+
124
+ \`\`\`yaml config
125
+ session:
126
+ python:
127
+ name: "default"
128
+ cwd: "."
129
+ \`\`\`
130
+ `;
131
+
132
+ const config = Project.parseConfig(content);
133
+
134
+ console.assert(config.name === 'My Thesis');
135
+ console.assert(config.session.python.venv === '.venv');
136
+ console.assert(config.session.python.name === 'default');
137
+ console.assert(config.session.python.cwd === '.');
138
+ console.log('✓ parseConfig deep merges multiple yaml config blocks');
139
+ ```
140
+
141
+ ```javascript
142
+ // Test: parseConfig ignores non-config yaml blocks
143
+ const content = `# Example
144
+
145
+ \`\`\`yaml
146
+ not: "config"
147
+ \`\`\`
148
+
149
+ \`\`\`yaml config
150
+ name: "Real Config"
151
+ \`\`\`
152
+ `;
153
+
154
+ const config = Project.parseConfig(content);
155
+
156
+ console.assert(config.name === 'Real Config');
157
+ console.assert(config.not === undefined);
158
+ console.log('✓ parseConfig ignores yaml blocks without config tag');
159
+ ```
160
+
161
+ ---
162
+
163
+ ### 1.3 Project.parseFrontmatter
164
+
165
+ Extract YAML frontmatter from document content.
166
+
167
+ ```typescript
168
+ interface DocumentFrontmatter {
169
+ title?: string;
170
+ session?: {
171
+ python?: {
172
+ name?: string;
173
+ venv?: string;
174
+ cwd?: string;
175
+ };
176
+ };
177
+ [key: string]: any;
178
+ }
179
+
180
+ function parseFrontmatter(documentContent: string): DocumentFrontmatter | null
181
+ ```
182
+
183
+ ```javascript
184
+ // Test: parseFrontmatter extracts frontmatter
185
+ const content = `---
186
+ title: "GPU Experiments"
187
+ session:
188
+ python:
189
+ name: "gpu-session"
190
+ ---
191
+
192
+ # GPU Experiments
193
+
194
+ Content here...
195
+ `;
196
+
197
+ const fm = Project.parseFrontmatter(content);
198
+
199
+ console.assert(fm.title === 'GPU Experiments');
200
+ console.assert(fm.session.python.name === 'gpu-session');
201
+ console.log('✓ parseFrontmatter extracts YAML frontmatter');
202
+ ```
203
+
204
+ ```javascript
205
+ // Test: parseFrontmatter returns null when no frontmatter
206
+ const content = `# No Frontmatter
207
+
208
+ Just content.
209
+ `;
210
+
211
+ const fm = Project.parseFrontmatter(content);
212
+
213
+ console.assert(fm === null);
214
+ console.log('✓ parseFrontmatter returns null when no frontmatter');
215
+ ```
216
+
217
+ ---
218
+
219
+ ### 1.4 Project.mergeConfig
220
+
221
+ Merge project config with document frontmatter (document wins).
222
+
223
+ ```typescript
224
+ function mergeConfig(
225
+ projectConfig: ProjectConfig,
226
+ frontmatter: DocumentFrontmatter | null
227
+ ): MergedConfig
228
+ ```
229
+
230
+ ```javascript
231
+ // Test: mergeConfig with document override
232
+ const projectConfig = {
233
+ name: 'My Thesis',
234
+ session: { python: { venv: '.venv', name: 'default', cwd: '.' } }
235
+ };
236
+
237
+ const frontmatter = {
238
+ title: 'GPU Chapter',
239
+ session: { python: { name: 'gpu-session' } }
240
+ };
241
+
242
+ const merged = Project.mergeConfig(projectConfig, frontmatter);
243
+
244
+ console.assert(merged.session.python.venv === '.venv', 'venv from project');
245
+ console.assert(merged.session.python.name === 'gpu-session', 'name overridden by doc');
246
+ console.assert(merged.session.python.cwd === '.', 'cwd from project');
247
+ console.log('✓ mergeConfig: document overrides project, rest preserved');
248
+ ```
249
+
250
+ ---
251
+
252
+ ### 1.5 Project.resolveSession
253
+
254
+ Determine the full session configuration for a document.
255
+
256
+ ```typescript
257
+ interface ResolvedSession {
258
+ name: string; // e.g., "my-thesis:default"
259
+ venv: string; // Absolute path to venv
260
+ cwd: string; // Absolute working directory
261
+ autoStart: boolean;
262
+ }
263
+
264
+ function resolveSession(
265
+ documentPath: string,
266
+ projectRoot: string,
267
+ mergedConfig: MergedConfig
268
+ ): ResolvedSession
269
+ ```
270
+
271
+ ```javascript
272
+ // Test: resolveSession computes full session config
273
+ const session = Project.resolveSession(
274
+ '/home/user/thesis/02-methods/intro.md',
275
+ '/home/user/thesis',
276
+ {
277
+ name: 'my-thesis',
278
+ session: { python: { venv: '.venv', cwd: '.', name: 'default', auto_start: true } }
279
+ }
280
+ );
281
+
282
+ console.assert(session.name === 'my-thesis:default');
283
+ console.assert(session.venv === '/home/user/thesis/.venv');
284
+ console.assert(session.cwd === '/home/user/thesis');
285
+ console.assert(session.autoStart === true);
286
+ console.log('✓ resolveSession computes absolute paths and full session name');
287
+ ```
288
+
289
+ ```javascript
290
+ // Test: resolveSession with relative venv path going up
291
+ const session = Project.resolveSession(
292
+ '/home/user/thesis/chapter.md',
293
+ '/home/user/thesis',
294
+ {
295
+ name: 'thesis',
296
+ session: { python: { venv: '../shared-env/.venv', cwd: '.', name: 'shared' } }
297
+ }
298
+ );
299
+
300
+ console.assert(session.venv === '/home/user/shared-env/.venv');
301
+ console.log('✓ resolveSession resolves relative paths correctly');
302
+ ```
303
+
304
+ ---
305
+
306
+ ### 1.6 Project.getDefaults
307
+
308
+ Return default configuration values.
309
+
310
+ ```javascript
311
+ // Test: getDefaults returns sensible defaults
312
+ const defaults = Project.getDefaults();
313
+
314
+ console.assert(defaults.session.python.venv === '.venv');
315
+ console.assert(defaults.session.python.cwd === '.');
316
+ console.assert(defaults.session.python.name === 'default');
317
+ console.assert(defaults.session.python.auto_start === true);
318
+ console.assert(defaults.assets.directory === '_assets');
319
+ console.log('✓ getDefaults returns expected defaults');
320
+ ```
321
+
322
+ ---
323
+
324
+ ## 2. FSML Module
325
+
326
+ ### 2.1 FSML.parsePath
327
+
328
+ Parse a path into FSML components.
329
+
330
+ ```typescript
331
+ interface FSMLPath {
332
+ path: string; // Original path
333
+ order: number | null; // Numeric prefix (null if none)
334
+ name: string; // Name without prefix/extension
335
+ title: string; // Human-readable title
336
+ extension: string; // File extension
337
+ isFolder: boolean;
338
+ isHidden: boolean; // Starts with _
339
+ isSystem: boolean; // Starts with .
340
+ depth: number; // Nesting level from project root
341
+ parent: string; // Parent directory path
342
+ }
343
+
344
+ function parsePath(relativePath: string): FSMLPath
345
+ ```
346
+
347
+ ```javascript
348
+ // Test: parsePath with numbered file
349
+ const p = FSML.parsePath('02-getting-started/01-installation.md');
350
+
351
+ console.assert(p.order === 1);
352
+ console.assert(p.name === 'installation');
353
+ console.assert(p.title === 'Installation');
354
+ console.assert(p.isFolder === false);
355
+ console.assert(p.isHidden === false);
356
+ console.assert(p.depth === 1);
357
+ console.assert(p.parent === '02-getting-started');
358
+ console.log('✓ parsePath parses numbered file correctly');
359
+ ```
360
+
361
+ ```javascript
362
+ // Test: parsePath with unnumbered file
363
+ const p = FSML.parsePath('appendix.md');
364
+
365
+ console.assert(p.order === null);
366
+ console.assert(p.name === 'appendix');
367
+ console.assert(p.title === 'Appendix');
368
+ console.assert(p.depth === 0);
369
+ console.log('✓ parsePath handles unnumbered files');
370
+ ```
371
+
372
+ ```javascript
373
+ // Test: parsePath with hidden folder
374
+ const p = FSML.parsePath('_assets/images/diagram.png');
375
+
376
+ console.assert(p.isHidden === true);
377
+ console.assert(p.isSystem === false);
378
+ console.log('✓ parsePath detects hidden folders');
379
+ ```
380
+
381
+ ```javascript
382
+ // Test: parsePath with system folder
383
+ const p = FSML.parsePath('.git/config');
384
+
385
+ console.assert(p.isHidden === false);
386
+ console.assert(p.isSystem === true);
387
+ console.log('✓ parsePath detects system folders');
388
+ ```
389
+
390
+ ```javascript
391
+ // Test: parsePath title derivation with hyphens and underscores
392
+ console.assert(FSML.parsePath('getting-started.md').title === 'Getting Started');
393
+ console.assert(FSML.parsePath('getting_started.md').title === 'Getting Started');
394
+ console.assert(FSML.parsePath('01-my-cool-doc.md').title === 'My Cool Doc');
395
+ console.log('✓ parsePath derives titles correctly');
396
+ ```
397
+
398
+ ---
399
+
400
+ ### 2.2 FSML.sortPaths
401
+
402
+ Sort paths according to FSML rules.
403
+
404
+ ```typescript
405
+ function sortPaths(paths: string[]): string[]
406
+ ```
407
+
408
+ **Rules:**
409
+ 1. Numbered items first, in numeric order
410
+ 2. Unnumbered items after, alphabetically
411
+ 3. Folders and files interleaved by their order
412
+
413
+ ```javascript
414
+ // Test: sortPaths orders correctly
415
+ const paths = [
416
+ 'appendix.md',
417
+ '03-results.md',
418
+ '01-intro.md',
419
+ '02-methods/01-setup.md',
420
+ '02-methods/02-analysis.md',
421
+ 'README.md',
422
+ ];
423
+
424
+ const sorted = FSML.sortPaths(paths);
425
+
426
+ console.assert(sorted[0] === '01-intro.md');
427
+ console.assert(sorted[1] === '02-methods/01-setup.md');
428
+ console.assert(sorted[2] === '02-methods/02-analysis.md');
429
+ console.assert(sorted[3] === '03-results.md');
430
+ // Unnumbered at end, alphabetically
431
+ console.assert(sorted[4] === 'appendix.md');
432
+ console.assert(sorted[5] === 'README.md');
433
+ console.log('✓ sortPaths orders by FSML rules');
434
+ ```
435
+
436
+ ---
437
+
438
+ ### 2.3 FSML.buildNavTree
439
+
440
+ Build a navigation tree from sorted paths.
441
+
442
+ ```typescript
443
+ interface NavNode {
444
+ path: string;
445
+ title: string;
446
+ order: number | null;
447
+ isFolder: boolean;
448
+ hasIndex: boolean; // Folder has index.md
449
+ children: NavNode[];
450
+ }
451
+
452
+ function buildNavTree(paths: string[]): NavNode[]
453
+ ```
454
+
455
+ ```javascript
456
+ // Test: buildNavTree creates nested structure
457
+ const paths = [
458
+ 'mrmd.md',
459
+ '01-intro.md',
460
+ '02-getting-started/index.md',
461
+ '02-getting-started/01-install.md',
462
+ '02-getting-started/02-config.md',
463
+ '03-tutorials/01-basic.md',
464
+ '_assets/image.png',
465
+ ];
466
+
467
+ const tree = FSML.buildNavTree(paths);
468
+
469
+ // mrmd.md should be excluded (it's config, not content)
470
+ console.assert(tree.find(n => n.path === 'mrmd.md') === undefined);
471
+
472
+ // _assets should be excluded (hidden)
473
+ console.assert(tree.find(n => n.path === '_assets') === undefined);
474
+
475
+ // Check structure
476
+ const intro = tree.find(n => n.path === '01-intro.md');
477
+ console.assert(intro.title === 'Intro');
478
+
479
+ const gettingStarted = tree.find(n => n.path === '02-getting-started');
480
+ console.assert(gettingStarted.isFolder === true);
481
+ console.assert(gettingStarted.hasIndex === true);
482
+ console.assert(gettingStarted.children.length === 2); // install and config (not index)
483
+
484
+ const tutorials = tree.find(n => n.path === '03-tutorials');
485
+ console.assert(tutorials.hasIndex === false);
486
+
487
+ console.log('✓ buildNavTree creates correct nested structure');
488
+ ```
489
+
490
+ ---
491
+
492
+ ### 2.4 FSML.titleFromFilename
493
+
494
+ Derive a human-readable title from a filename.
495
+
496
+ ```typescript
497
+ function titleFromFilename(filename: string): string
498
+ ```
499
+
500
+ ```javascript
501
+ // Test: titleFromFilename
502
+ console.assert(FSML.titleFromFilename('01-getting-started.md') === 'Getting Started');
503
+ console.assert(FSML.titleFromFilename('my_cool_doc.md') === 'My Cool Doc');
504
+ console.assert(FSML.titleFromFilename('README.md') === 'README');
505
+ console.assert(FSML.titleFromFilename('index.md') === 'Index');
506
+ console.log('✓ titleFromFilename works correctly');
507
+ ```
508
+
509
+ ---
510
+
511
+ ### 2.5 FSML.computeNewPath
512
+
513
+ Compute new path when reordering (for drag-drop).
514
+
515
+ ```typescript
516
+ function computeNewPath(
517
+ sourcePath: string,
518
+ targetPath: string,
519
+ position: 'before' | 'after' | 'inside'
520
+ ): { newPath: string; renames: Array<{ from: string; to: string }> }
521
+ ```
522
+
523
+ ```javascript
524
+ // Test: computeNewPath when moving before
525
+ const result = FSML.computeNewPath(
526
+ '03-results.md', // moving this
527
+ '01-intro.md', // before this
528
+ 'before'
529
+ );
530
+
531
+ // 03-results should become 01-results
532
+ // 01-intro should become 02-intro
533
+ // 02-methods should become 03-methods
534
+ console.assert(result.newPath === '01-results.md');
535
+ console.assert(result.renames.length >= 1);
536
+ console.log('✓ computeNewPath computes renames for reordering');
537
+ ```
538
+
539
+ ---
540
+
541
+ ## 3. Links Module
542
+
543
+ ### 3.1 Links.parse
544
+
545
+ Extract all internal links from content.
546
+
547
+ ```typescript
548
+ interface ParsedLink {
549
+ raw: string; // "[[installation]]" or "[[file#heading|text]]"
550
+ target: string; // "installation" or "file#heading"
551
+ anchor: string | null; // "heading" or null
552
+ display: string | null; // "text" or null
553
+ start: number; // Position in content
554
+ end: number;
555
+ }
556
+
557
+ function parse(content: string): ParsedLink[]
558
+ ```
559
+
560
+ ```javascript
561
+ // Test: Links.parse extracts links
562
+ const content = `
563
+ See [[installation]] for setup.
564
+ Check [[getting-started/config#advanced|advanced config]].
565
+ Go to [[next]] or [[prev]].
566
+ `;
567
+
568
+ const links = Links.parse(content);
569
+
570
+ console.assert(links.length === 4);
571
+ console.assert(links[0].target === 'installation');
572
+ console.assert(links[0].display === null);
573
+ console.assert(links[1].target === 'getting-started/config');
574
+ console.assert(links[1].anchor === 'advanced');
575
+ console.assert(links[1].display === 'advanced config');
576
+ console.log('✓ Links.parse extracts all link types');
577
+ ```
578
+
579
+ ---
580
+
581
+ ### 3.2 Links.resolve
582
+
583
+ Resolve a link target to an actual file path.
584
+
585
+ ```typescript
586
+ function resolve(
587
+ target: string,
588
+ fromDocument: string,
589
+ projectFiles: string[]
590
+ ): string | null
591
+ ```
592
+
593
+ **Resolution rules:**
594
+ 1. Exact match (with or without .md)
595
+ 2. Fuzzy match on filename
596
+ 3. Special links: `[[next]]`, `[[prev]]`, `[[home]]`, `[[up]]`
597
+
598
+ ```javascript
599
+ // Test: Links.resolve with exact match
600
+ const files = [
601
+ '01-intro.md',
602
+ '02-getting-started/01-installation.md',
603
+ '02-getting-started/02-configuration.md',
604
+ ];
605
+
606
+ const resolved = Links.resolve('installation', '01-intro.md', files);
607
+ console.assert(resolved === '02-getting-started/01-installation.md');
608
+ console.log('✓ Links.resolve finds exact filename match');
609
+ ```
610
+
611
+ ```javascript
612
+ // Test: Links.resolve with path
613
+ const resolved = Links.resolve(
614
+ 'getting-started/configuration',
615
+ '01-intro.md',
616
+ files
617
+ );
618
+ console.assert(resolved === '02-getting-started/02-configuration.md');
619
+ console.log('✓ Links.resolve handles path-based links');
620
+ ```
621
+
622
+ ```javascript
623
+ // Test: Links.resolve special links
624
+ const files = ['01-intro.md', '02-methods.md', '03-results.md'];
625
+
626
+ console.assert(Links.resolve('next', '01-intro.md', files) === '02-methods.md');
627
+ console.assert(Links.resolve('prev', '02-methods.md', files) === '01-intro.md');
628
+ console.assert(Links.resolve('home', '03-results.md', files) === '01-intro.md');
629
+ console.log('✓ Links.resolve handles special links');
630
+ ```
631
+
632
+ ---
633
+
634
+ ### 3.3 Links.refactor
635
+
636
+ Update links in content when files are moved/renamed.
637
+
638
+ ```typescript
639
+ interface FileMove {
640
+ from: string;
641
+ to: string;
642
+ }
643
+
644
+ function refactor(
645
+ content: string,
646
+ moves: FileMove[],
647
+ currentDocPath: string
648
+ ): string
649
+ ```
650
+
651
+ ```javascript
652
+ // Test: Links.refactor updates links
653
+ const content = `
654
+ See [[installation]] for setup.
655
+ Check [[old-name]] for details.
656
+ `;
657
+
658
+ const updated = Links.refactor(content, [
659
+ { from: '02-getting-started/01-installation.md', to: '02-setup/01-installation.md' },
660
+ { from: 'old-name.md', to: 'new-name.md' },
661
+ ], 'index.md');
662
+
663
+ console.assert(updated.includes('[[installation]]')); // Still works (fuzzy)
664
+ console.assert(updated.includes('[[new-name]]'));
665
+ console.log('✓ Links.refactor updates links for moved files');
666
+ ```
667
+
668
+ ---
669
+
670
+ ## 4. Assets Module
671
+
672
+ ### 4.1 Assets.computeRelativePath
673
+
674
+ Compute relative path from document to asset.
675
+
676
+ ```typescript
677
+ function computeRelativePath(
678
+ documentPath: string,
679
+ assetPath: string
680
+ ): string
681
+ ```
682
+
683
+ ```javascript
684
+ // Test: Assets.computeRelativePath from root
685
+ const rel = Assets.computeRelativePath(
686
+ '01-intro.md',
687
+ '_assets/screenshot.png'
688
+ );
689
+ console.assert(rel === '_assets/screenshot.png');
690
+ console.log('✓ Assets.computeRelativePath from root level');
691
+ ```
692
+
693
+ ```javascript
694
+ // Test: Assets.computeRelativePath from nested doc
695
+ const rel = Assets.computeRelativePath(
696
+ '02-getting-started/01-installation.md',
697
+ '_assets/screenshot.png'
698
+ );
699
+ console.assert(rel === '../_assets/screenshot.png');
700
+ console.log('✓ Assets.computeRelativePath from nested document');
701
+ ```
702
+
703
+ ```javascript
704
+ // Test: Assets.computeRelativePath deeply nested
705
+ const rel = Assets.computeRelativePath(
706
+ '02-section/sub/deep/doc.md',
707
+ '_assets/img.png'
708
+ );
709
+ console.assert(rel === '../../../_assets/img.png');
710
+ console.log('✓ Assets.computeRelativePath handles deep nesting');
711
+ ```
712
+
713
+ ---
714
+
715
+ ### 4.2 Assets.refactorPaths
716
+
717
+ Update asset paths in content when document moves.
718
+
719
+ ```typescript
720
+ function refactorPaths(
721
+ content: string,
722
+ oldDocPath: string,
723
+ newDocPath: string,
724
+ assetsDir: string
725
+ ): string
726
+ ```
727
+
728
+ ```javascript
729
+ // Test: Assets.refactorPaths when doc moves deeper
730
+ const content = `
731
+ ![Screenshot](_assets/screenshot.png)
732
+ ![Diagram](_assets/diagrams/arch.svg)
733
+ `;
734
+
735
+ const updated = Assets.refactorPaths(
736
+ content,
737
+ '01-intro.md', // was at root
738
+ '02-section/01-intro.md', // moved into section
739
+ '_assets'
740
+ );
741
+
742
+ console.assert(updated.includes('../_assets/screenshot.png'));
743
+ console.assert(updated.includes('../_assets/diagrams/arch.svg'));
744
+ console.log('✓ Assets.refactorPaths updates paths when doc moves');
745
+ ```
746
+
747
+ ---
748
+
749
+ ### 4.3 Assets.extractPaths
750
+
751
+ Extract all asset paths from content.
752
+
753
+ ```typescript
754
+ interface AssetReference {
755
+ path: string;
756
+ start: number;
757
+ end: number;
758
+ type: 'image' | 'link';
759
+ }
760
+
761
+ function extractPaths(content: string): AssetReference[]
762
+ ```
763
+
764
+ ```javascript
765
+ // Test: Assets.extractPaths finds all references
766
+ const content = `
767
+ ![Alt](../‌_assets/img.png)
768
+ [Download](../_assets/file.pdf)
769
+ ![Another](_assets/other.jpg)
770
+ `;
771
+
772
+ const refs = Assets.extractPaths(content);
773
+
774
+ console.assert(refs.length === 3);
775
+ console.assert(refs.some(r => r.path.includes('img.png')));
776
+ console.assert(refs.some(r => r.type === 'link'));
777
+ console.log('✓ Assets.extractPaths finds all asset references');
778
+ ```
779
+
780
+ ---
781
+
782
+ ## 5. Scaffold Module
783
+
784
+ ### 5.1 Scaffold.project
785
+
786
+ Generate project scaffold files.
787
+
788
+ ```typescript
789
+ interface ScaffoldFile {
790
+ path: string;
791
+ content: string;
792
+ }
793
+
794
+ interface ProjectScaffold {
795
+ files: ScaffoldFile[];
796
+ venvPath: string;
797
+ }
798
+
799
+ function project(name: string): ProjectScaffold
800
+ ```
801
+
802
+ ```javascript
803
+ // Test: Scaffold.project generates correct structure
804
+ const scaffold = Scaffold.project('my-research');
805
+
806
+ const paths = scaffold.files.map(f => f.path);
807
+ console.assert(paths.includes('mrmd.md'));
808
+ console.assert(paths.includes('01-index.md'));
809
+ console.assert(paths.includes('_assets/.gitkeep'));
810
+
811
+ const mrmdMd = scaffold.files.find(f => f.path === 'mrmd.md');
812
+ console.assert(mrmdMd.content.includes('name: "my-research"'));
813
+ console.assert(mrmdMd.content.includes('venv: ".venv"'));
814
+
815
+ console.assert(scaffold.venvPath === '.venv');
816
+ console.log('✓ Scaffold.project generates correct structure');
817
+ ```
818
+
819
+ ---
820
+
821
+ ### 5.2 Scaffold.standaloneFrontmatter
822
+
823
+ Generate frontmatter for standalone files.
824
+
825
+ ```typescript
826
+ function standaloneFrontmatter(config: {
827
+ venv: string;
828
+ cwd: string;
829
+ title?: string;
830
+ }): string
831
+ ```
832
+
833
+ ```javascript
834
+ // Test: Scaffold.standaloneFrontmatter
835
+ const fm = Scaffold.standaloneFrontmatter({
836
+ venv: '/home/user/.venv',
837
+ cwd: '/home/user/work',
838
+ title: 'Quick Analysis'
839
+ });
840
+
841
+ console.assert(fm.startsWith('---'));
842
+ console.assert(fm.includes('title: "Quick Analysis"'));
843
+ console.assert(fm.includes('venv: "/home/user/.venv"'));
844
+ console.assert(fm.includes('cwd: "/home/user/work"'));
845
+ console.assert(fm.endsWith('---\n'));
846
+ console.log('✓ Scaffold.standaloneFrontmatter generates valid frontmatter');
847
+ ```
848
+
849
+ ---
850
+
851
+ ## 6. Search Module
852
+
853
+ ### 6.1 Search.fuzzyMatch
854
+
855
+ Fuzzy match a query against a string.
856
+
857
+ ```typescript
858
+ interface MatchResult {
859
+ score: number;
860
+ matches: number[]; // Indices of matched characters
861
+ }
862
+
863
+ function fuzzyMatch(query: string, target: string): MatchResult
864
+ ```
865
+
866
+ ```javascript
867
+ // Test: Search.fuzzyMatch basic
868
+ const result = Search.fuzzyMatch('instal', 'installation');
869
+
870
+ console.assert(result.score > 0);
871
+ console.assert(result.matches.length === 6);
872
+ console.assert(result.matches[0] === 0); // 'i' at position 0
873
+ console.log('✓ Search.fuzzyMatch matches prefix');
874
+ ```
875
+
876
+ ```javascript
877
+ // Test: Search.fuzzyMatch non-consecutive
878
+ const result = Search.fuzzyMatch('ist', 'installation');
879
+
880
+ console.assert(result.score > 0);
881
+ // i(0), s(2), t(6)
882
+ console.log('✓ Search.fuzzyMatch handles non-consecutive matches');
883
+ ```
884
+
885
+ ```javascript
886
+ // Test: Search.fuzzyMatch no match
887
+ const result = Search.fuzzyMatch('xyz', 'installation');
888
+
889
+ console.assert(result.score === 0);
890
+ console.assert(result.matches.length === 0);
891
+ console.log('✓ Search.fuzzyMatch returns 0 for no match');
892
+ ```
893
+
894
+ ---
895
+
896
+ ### 6.2 Search.files
897
+
898
+ Search files by full path with scoring.
899
+
900
+ ```typescript
901
+ interface SearchResult {
902
+ path: string;
903
+ score: number;
904
+ nameMatches: number[];
905
+ dirMatches: number[];
906
+ }
907
+
908
+ function files(query: string, paths: string[]): SearchResult[]
909
+ ```
910
+
911
+ ```javascript
912
+ // Test: Search.files matches path components
913
+ const paths = [
914
+ '/home/user/thesis/README.md',
915
+ '/home/user/thesis/02-methods/analysis.md',
916
+ '/home/user/work/other/README.md',
917
+ ];
918
+
919
+ const results = Search.files('thesis readme', paths);
920
+
921
+ // thesis/README should rank higher than work/README
922
+ console.assert(results[0].path.includes('thesis/README'));
923
+ console.log('✓ Search.files ranks by path match quality');
924
+ ```
925
+
926
+ ```javascript
927
+ // Test: Search.files empty query returns all
928
+ const results = Search.files('', paths);
929
+
930
+ console.assert(results.length === paths.length);
931
+ console.log('✓ Search.files returns all for empty query');
932
+ ```
933
+
934
+ ---
935
+
936
+ ## 7. Integration Tests
937
+
938
+ These tests verify the modules work together correctly.
939
+
940
+ ```javascript
941
+ // Integration: Full project workflow
942
+ const mrmdContent = `# My Thesis
943
+
944
+ \`\`\`yaml config
945
+ name: "My Thesis"
946
+ session:
947
+ python:
948
+ venv: ".venv"
949
+ \`\`\`
950
+ `;
951
+
952
+ const docContent = `---
953
+ title: "GPU Chapter"
954
+ session:
955
+ python:
956
+ name: "gpu"
957
+ ---
958
+
959
+ # GPU Experiments
960
+
961
+ See [[installation]] for setup.
962
+ ![Diagram](_assets/diagram.png)
963
+ `;
964
+
965
+ // 1. Parse project config
966
+ const projectConfig = Project.parseConfig(mrmdContent);
967
+ console.assert(projectConfig.name === 'My Thesis');
968
+
969
+ // 2. Parse document frontmatter
970
+ const frontmatter = Project.parseFrontmatter(docContent);
971
+ console.assert(frontmatter.title === 'GPU Chapter');
972
+
973
+ // 3. Merge configs
974
+ const merged = Project.mergeConfig(projectConfig, frontmatter);
975
+ console.assert(merged.session.python.name === 'gpu'); // Doc override
976
+
977
+ // 4. Resolve session
978
+ const session = Project.resolveSession(
979
+ '/home/user/thesis/03-gpu/experiments.md',
980
+ '/home/user/thesis',
981
+ merged
982
+ );
983
+ console.assert(session.name === 'My Thesis:gpu');
984
+ console.assert(session.venv === '/home/user/thesis/.venv');
985
+
986
+ // 5. Parse links
987
+ const links = Links.parse(docContent);
988
+ console.assert(links.length === 1);
989
+ console.assert(links[0].target === 'installation');
990
+
991
+ // 6. Extract assets
992
+ const assets = Assets.extractPaths(docContent);
993
+ console.assert(assets.length === 1);
994
+ console.assert(assets[0].path.includes('diagram.png'));
995
+
996
+ console.log('✓ Integration: Full project workflow works');
997
+ ```
998
+
999
+ ---
1000
+
1001
+ ## 8. Type Definitions Summary
1002
+
1003
+ ```typescript
1004
+ // Project types
1005
+ interface ProjectConfig { ... }
1006
+ interface DocumentFrontmatter { ... }
1007
+ interface MergedConfig { ... }
1008
+ interface ResolvedSession { ... }
1009
+
1010
+ // FSML types
1011
+ interface FSMLPath { ... }
1012
+ interface NavNode { ... }
1013
+
1014
+ // Links types
1015
+ interface ParsedLink { ... }
1016
+ interface FileMove { ... }
1017
+
1018
+ // Assets types
1019
+ interface AssetReference { ... }
1020
+
1021
+ // Scaffold types
1022
+ interface ScaffoldFile { ... }
1023
+ interface ProjectScaffold { ... }
1024
+
1025
+ // Search types
1026
+ interface MatchResult { ... }
1027
+ interface SearchResult { ... }
1028
+ ```
1029
+
1030
+ ---
1031
+
1032
+ ## 9. Error Handling
1033
+
1034
+ All functions should handle edge cases gracefully:
1035
+
1036
+ ```javascript
1037
+ // Test: Empty inputs don't crash
1038
+ console.assert(Project.parseConfig('') !== undefined);
1039
+ console.assert(Project.parseFrontmatter('') === null);
1040
+ console.assert(FSML.parsePath('').name === '');
1041
+ console.assert(Links.parse('').length === 0);
1042
+ console.assert(Assets.extractPaths('').length === 0);
1043
+ console.log('✓ All functions handle empty inputs');
1044
+ ```
1045
+
1046
+ ```javascript
1047
+ // Test: Invalid YAML doesn't crash
1048
+ const config = Project.parseConfig(`
1049
+ \`\`\`yaml config
1050
+ invalid: yaml: content: [
1051
+ \`\`\`
1052
+ `);
1053
+ // Should return empty config or partial, not throw
1054
+ console.assert(config !== undefined);
1055
+ console.log('✓ Invalid YAML handled gracefully');
1056
+ ```