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/README.md +335 -0
- package/package.json +46 -0
- package/spec.md +1056 -0
- package/src/assets.js +142 -0
- package/src/fsml.js +371 -0
- package/src/index.js +65 -0
- package/src/links.js +198 -0
- package/src/project.js +293 -0
- package/src/scaffold.js +136 -0
- package/src/search.js +177 -0
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
|
+

|
|
732
|
+

|
|
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
|
+

|
|
768
|
+
[Download](../_assets/file.pdf)
|
|
769
|
+

|
|
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
|
+

|
|
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
|
+
```
|