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/src/assets.js ADDED
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Assets module - Asset path computation and refactoring
3
+ *
4
+ * Handles relative paths between documents and assets in _assets/.
5
+ *
6
+ * @module Assets
7
+ */
8
+
9
+ /**
10
+ * Compute the relative path from a document to an asset
11
+ *
12
+ * @param {string} documentPath - Path of the document (relative to project root)
13
+ * @param {string} assetPath - Path of the asset (relative to project root)
14
+ * @returns {string} Relative path from document to asset
15
+ *
16
+ * @example
17
+ * Assets.computeRelativePath('01-intro.md', '_assets/img.png')
18
+ * // Returns '_assets/img.png'
19
+ *
20
+ * Assets.computeRelativePath('02-section/01-doc.md', '_assets/img.png')
21
+ * // Returns '../_assets/img.png'
22
+ */
23
+ export function computeRelativePath(documentPath, assetPath) {
24
+ if (!documentPath || !assetPath) return assetPath || '';
25
+
26
+ // Get document directory (remove filename)
27
+ const docDir = documentPath.includes('/')
28
+ ? documentPath.split('/').slice(0, -1).join('/')
29
+ : '';
30
+
31
+ // If document is at root, return asset path as-is
32
+ if (!docDir) {
33
+ return assetPath;
34
+ }
35
+
36
+ // Count how many levels up we need to go
37
+ const docDepth = docDir.split('/').length;
38
+
39
+ // Build the relative path
40
+ const upPath = '../'.repeat(docDepth);
41
+ return upPath + assetPath;
42
+ }
43
+
44
+ /**
45
+ * Update asset paths in content when a document moves
46
+ *
47
+ * @param {string} content - Document content
48
+ * @param {string} oldDocPath - Old document path
49
+ * @param {string} newDocPath - New document path
50
+ * @param {string} assetsDir - Assets directory name (default '_assets')
51
+ * @returns {string} Content with updated asset paths
52
+ *
53
+ * @example
54
+ * Assets.refactorPaths(
55
+ * '![Img](_assets/img.png)',
56
+ * '01-intro.md',
57
+ * '02-section/01-intro.md',
58
+ * '_assets'
59
+ * )
60
+ * // Returns '![Img](../_assets/img.png)'
61
+ */
62
+ export function refactorPaths(content, oldDocPath, newDocPath, assetsDir = '_assets') {
63
+ if (!content) return content;
64
+
65
+ // Calculate old and new depths
66
+ const oldDir = oldDocPath.includes('/') ? oldDocPath.split('/').slice(0, -1).join('/') : '';
67
+ const newDir = newDocPath.includes('/') ? newDocPath.split('/').slice(0, -1).join('/') : '';
68
+
69
+ const oldDepth = oldDir ? oldDir.split('/').length : 0;
70
+ const newDepth = newDir ? newDir.split('/').length : 0;
71
+
72
+ // Find all asset references and update them
73
+ const regex = /(!?)\[([^\]]*)\]\(([^)]+)\)/g;
74
+
75
+ return content.replace(regex, (match, bang, alt, path) => {
76
+ // Check if this path references assets
77
+ if (!path.includes(assetsDir)) {
78
+ return match;
79
+ }
80
+
81
+ // Extract the asset path relative to project root
82
+ // Old path might be: _assets/img.png or ../_assets/img.png or ../../_assets/img.png
83
+ let assetRelativePath;
84
+
85
+ if (path.startsWith(assetsDir)) {
86
+ // Direct path from root: _assets/img.png
87
+ assetRelativePath = path;
88
+ } else if (path.includes('/' + assetsDir)) {
89
+ // Relative path: ../_assets/img.png
90
+ const idx = path.indexOf(assetsDir);
91
+ assetRelativePath = path.slice(idx);
92
+ } else {
93
+ // Can't parse, leave as-is
94
+ return match;
95
+ }
96
+
97
+ // Compute new relative path
98
+ const newRelativePath = computeRelativePath(newDocPath, assetRelativePath);
99
+
100
+ return `${bang}[${alt}](${newRelativePath})`;
101
+ });
102
+ }
103
+
104
+ /**
105
+ * Extract all asset references from content
106
+ *
107
+ * Finds both image syntax ![](path) and link syntax [](path) that reference assets.
108
+ *
109
+ * @param {string} content - Document content
110
+ * @returns {object[]} Array of asset references
111
+ *
112
+ * @example
113
+ * Assets.extractPaths('![Alt](../_assets/img.png)')
114
+ * // Returns [{ path: '../_assets/img.png', start: 6, end: 26, type: 'image' }]
115
+ */
116
+ export function extractPaths(content) {
117
+ if (!content) return [];
118
+
119
+ const refs = [];
120
+
121
+ // Match both images ![alt](path) and links [text](path)
122
+ // that reference _assets
123
+ const regex = /(!?)\[([^\]]*)\]\(([^)]+)\)/g;
124
+ let match;
125
+
126
+ while ((match = regex.exec(content)) !== null) {
127
+ const isImage = match[1] === '!';
128
+ const path = match[3];
129
+
130
+ // Only include paths that look like assets (contain _assets or relative paths to _assets)
131
+ if (path.includes('_assets') || path.includes('assets')) {
132
+ refs.push({
133
+ path,
134
+ start: match.index + match[1].length + match[2].length + 3, // Position of path start
135
+ end: match.index + match[0].length - 1, // Position before closing )
136
+ type: isImage ? 'image' : 'link',
137
+ });
138
+ }
139
+ }
140
+
141
+ return refs;
142
+ }
package/src/fsml.js ADDED
@@ -0,0 +1,371 @@
1
+ /**
2
+ * FSML module - Filesystem Markup Language utilities
3
+ *
4
+ * Handles path parsing, sorting, and navigation tree building
5
+ * according to FSML conventions.
6
+ *
7
+ * @module FSML
8
+ */
9
+
10
+ /**
11
+ * Parse a relative path into FSML components
12
+ *
13
+ * @param {string} relativePath - Path relative to project root
14
+ * @returns {object} Parsed path information
15
+ *
16
+ * @example
17
+ * FSML.parsePath('02-getting-started/01-installation.md')
18
+ * // Returns {
19
+ * // path: '02-getting-started/01-installation.md',
20
+ * // order: 1,
21
+ * // name: 'installation',
22
+ * // title: 'Installation',
23
+ * // extension: '.md',
24
+ * // isFolder: false,
25
+ * // isHidden: false,
26
+ * // isSystem: false,
27
+ * // depth: 1,
28
+ * // parent: '02-getting-started'
29
+ * // }
30
+ */
31
+ export function parsePath(relativePath) {
32
+ if (!relativePath) {
33
+ return {
34
+ path: '',
35
+ order: null,
36
+ name: '',
37
+ title: '',
38
+ extension: '',
39
+ isFolder: false,
40
+ isHidden: false,
41
+ isSystem: false,
42
+ depth: 0,
43
+ parent: '',
44
+ };
45
+ }
46
+
47
+ // Normalize path (remove trailing slash)
48
+ const path = relativePath.replace(/\/+$/, '');
49
+
50
+ // Get segments
51
+ const segments = path.split('/').filter(Boolean);
52
+ const depth = segments.length - 1;
53
+ const parent = segments.slice(0, -1).join('/');
54
+
55
+ // Get filename (last segment)
56
+ const filename = segments[segments.length - 1] || '';
57
+
58
+ // Check if it's a folder (no extension or explicitly ends with /)
59
+ const hasExtension = /\.[^./]+$/.test(filename);
60
+ const isFolder = !hasExtension;
61
+
62
+ // Get extension
63
+ const extMatch = filename.match(/(\.[^.]+)$/);
64
+ const extension = extMatch ? extMatch[1] : '';
65
+
66
+ // Get name without extension
67
+ const nameWithoutExt = filename.replace(/\.[^.]+$/, '');
68
+
69
+ // Parse numeric prefix (e.g., "01-" or "02_")
70
+ const prefixMatch = nameWithoutExt.match(/^(\d+)[-_]/);
71
+ const order = prefixMatch ? parseInt(prefixMatch[1], 10) : null;
72
+
73
+ // Get name without prefix
74
+ const name = prefixMatch ? nameWithoutExt.replace(/^\d+[-_]/, '') : nameWithoutExt;
75
+
76
+ // Derive title
77
+ const title = titleFromFilename(filename);
78
+
79
+ // Check hidden/system status based on first segment
80
+ const firstSegment = segments[0] || filename;
81
+ const isHidden = firstSegment.startsWith('_');
82
+ const isSystem = firstSegment.startsWith('.');
83
+
84
+ return {
85
+ path,
86
+ order,
87
+ name,
88
+ title,
89
+ extension,
90
+ isFolder,
91
+ isHidden,
92
+ isSystem,
93
+ depth,
94
+ parent,
95
+ };
96
+ }
97
+
98
+ /**
99
+ * Sort paths according to FSML rules
100
+ *
101
+ * Rules:
102
+ * 1. Numbered items first, in numeric order
103
+ * 2. Unnumbered items after, alphabetically
104
+ * 3. Folders and files interleaved by their order
105
+ *
106
+ * @param {string[]} paths - Array of relative paths
107
+ * @returns {string[]} Sorted paths
108
+ */
109
+ export function sortPaths(paths) {
110
+ if (!paths || paths.length === 0) return [];
111
+
112
+ // Parse all paths
113
+ const parsed = paths.map(p => ({
114
+ path: p,
115
+ ...parsePath(p),
116
+ }));
117
+
118
+ // Sort function
119
+ return parsed
120
+ .sort((a, b) => {
121
+ // First, compare by parent path to keep hierarchy
122
+ const aParts = a.path.split('/');
123
+ const bParts = b.path.split('/');
124
+
125
+ // Compare common ancestor parts
126
+ const minLen = Math.min(aParts.length, bParts.length);
127
+
128
+ for (let i = 0; i < minLen; i++) {
129
+ const aPart = aParts[i];
130
+ const bPart = bParts[i];
131
+
132
+ if (aPart === bPart) continue;
133
+
134
+ // Parse parts for comparison
135
+ const aPartParsed = parsePath(aPart);
136
+ const bPartParsed = parsePath(bPart);
137
+
138
+ // Numbered items first
139
+ if (aPartParsed.order !== null && bPartParsed.order !== null) {
140
+ return aPartParsed.order - bPartParsed.order;
141
+ }
142
+ if (aPartParsed.order !== null) return -1;
143
+ if (bPartParsed.order !== null) return 1;
144
+
145
+ // Alphabetical for unnumbered
146
+ return aPart.localeCompare(bPart);
147
+ }
148
+
149
+ // Shorter paths (parents) come before longer paths (children) -- actually not, children should come after parent
150
+ return aParts.length - bParts.length;
151
+ })
152
+ .map(p => p.path);
153
+ }
154
+
155
+ /**
156
+ * Build a navigation tree from sorted paths
157
+ *
158
+ * @param {string[]} paths - Array of relative paths
159
+ * @returns {object[]} Navigation tree nodes
160
+ *
161
+ * @example
162
+ * FSML.buildNavTree(['01-intro.md', '02-methods/01-setup.md', '02-methods/02-analysis.md'])
163
+ * // Returns [
164
+ * // { path: '01-intro.md', title: 'Intro', isFolder: false, children: [] },
165
+ * // { path: '02-methods', title: 'Methods', isFolder: true, hasIndex: false, children: [...] }
166
+ * // ]
167
+ */
168
+ export function buildNavTree(paths) {
169
+ if (!paths || paths.length === 0) return [];
170
+
171
+ // Filter out mrmd.md, hidden (_), and system (.) paths
172
+ const filtered = paths.filter(p => {
173
+ const parsed = parsePath(p);
174
+ if (parsed.isHidden || parsed.isSystem) return false;
175
+ // Exclude mrmd.md at root
176
+ if (p === 'mrmd.md') return false;
177
+ return true;
178
+ });
179
+
180
+ // Sort the paths
181
+ const sorted = sortPaths(filtered);
182
+
183
+ // Build folder structure first - collect all unique folders
184
+ const folders = new Map(); // folder path -> { hasIndex, children: [] }
185
+ const rootChildren = [];
186
+
187
+ // First pass: identify folders and track index.md
188
+ for (const path of sorted) {
189
+ const segments = path.split('/');
190
+
191
+ // Track folder hierarchy
192
+ for (let i = 0; i < segments.length - 1; i++) {
193
+ const folderPath = segments.slice(0, i + 1).join('/');
194
+ if (!folders.has(folderPath)) {
195
+ const parsed = parsePath(folderPath);
196
+ folders.set(folderPath, {
197
+ path: folderPath,
198
+ title: titleFromFilename(segments[i]),
199
+ order: parsed.order,
200
+ isFolder: true,
201
+ hasIndex: false,
202
+ children: [],
203
+ });
204
+ }
205
+ }
206
+
207
+ // Check if this is an index.md
208
+ const filename = segments[segments.length - 1];
209
+ if (filename === 'index.md' && segments.length > 1) {
210
+ const parentPath = segments.slice(0, -1).join('/');
211
+ if (folders.has(parentPath)) {
212
+ folders.get(parentPath).hasIndex = true;
213
+ }
214
+ }
215
+ }
216
+
217
+ // Second pass: build tree
218
+ for (const path of sorted) {
219
+ const segments = path.split('/');
220
+ const filename = segments[segments.length - 1];
221
+
222
+ // Skip index.md files (they're represented by the folder itself)
223
+ if (filename === 'index.md') continue;
224
+
225
+ const parsed = parsePath(path);
226
+
227
+ const node = {
228
+ path,
229
+ title: parsed.title,
230
+ order: parsed.order,
231
+ isFolder: false,
232
+ hasIndex: false,
233
+ children: [],
234
+ };
235
+
236
+ if (segments.length === 1) {
237
+ // Top-level file
238
+ rootChildren.push(node);
239
+ } else {
240
+ // File in a folder
241
+ const parentPath = segments.slice(0, -1).join('/');
242
+ if (folders.has(parentPath)) {
243
+ folders.get(parentPath).children.push(node);
244
+ }
245
+ }
246
+ }
247
+
248
+ // Third pass: add folders to tree
249
+ // Sort folders by depth (shallow first) to build hierarchy
250
+ const folderList = Array.from(folders.values());
251
+ folderList.sort((a, b) => a.path.split('/').length - b.path.split('/').length);
252
+
253
+ for (const folder of folderList) {
254
+ const segments = folder.path.split('/');
255
+
256
+ if (segments.length === 1) {
257
+ // Top-level folder
258
+ rootChildren.push(folder);
259
+ } else {
260
+ // Nested folder
261
+ const parentPath = segments.slice(0, -1).join('/');
262
+ if (folders.has(parentPath)) {
263
+ folders.get(parentPath).children.push(folder);
264
+ }
265
+ }
266
+ }
267
+
268
+ // Sort root children
269
+ rootChildren.sort((a, b) => {
270
+ if (a.order !== null && b.order !== null) return a.order - b.order;
271
+ if (a.order !== null) return -1;
272
+ if (b.order !== null) return 1;
273
+ return a.title.localeCompare(b.title);
274
+ });
275
+
276
+ // Sort children of each folder
277
+ for (const folder of folders.values()) {
278
+ folder.children.sort((a, b) => {
279
+ if (a.order !== null && b.order !== null) return a.order - b.order;
280
+ if (a.order !== null) return -1;
281
+ if (b.order !== null) return 1;
282
+ return a.title.localeCompare(b.title);
283
+ });
284
+ }
285
+
286
+ return rootChildren;
287
+ }
288
+
289
+ /**
290
+ * Derive a human-readable title from a filename
291
+ *
292
+ * @param {string} filename - Filename (with or without extension)
293
+ * @returns {string} Human-readable title
294
+ *
295
+ * @example
296
+ * FSML.titleFromFilename('01-getting-started.md') // 'Getting Started'
297
+ * FSML.titleFromFilename('my_cool_doc.md') // 'My Cool Doc'
298
+ */
299
+ export function titleFromFilename(filename) {
300
+ if (!filename) return '';
301
+
302
+ // Remove extension
303
+ let name = filename.replace(/\.[^.]+$/, '');
304
+
305
+ // Remove numeric prefix (e.g., "01-" or "02_")
306
+ name = name.replace(/^\d+-/, '');
307
+
308
+ // Replace hyphens and underscores with spaces
309
+ name = name.replace(/[-_]/g, ' ');
310
+
311
+ // Title case: capitalize first letter of each word
312
+ name = name
313
+ .split(' ')
314
+ .map(word => word.charAt(0).toUpperCase() + word.slice(1))
315
+ .join(' ');
316
+
317
+ return name;
318
+ }
319
+
320
+ /**
321
+ * Compute new paths when reordering files
322
+ *
323
+ * @param {string} sourcePath - Path being moved
324
+ * @param {string} targetPath - Path to move relative to
325
+ * @param {'before' | 'after' | 'inside'} position - Where to place
326
+ * @returns {object} New path and required renames
327
+ */
328
+ export function computeNewPath(sourcePath, targetPath, position) {
329
+ const source = parsePath(sourcePath);
330
+ const target = parsePath(targetPath);
331
+
332
+ // Get target directory
333
+ let targetDir = '';
334
+ if (position === 'inside') {
335
+ // Moving inside a folder
336
+ targetDir = targetPath;
337
+ } else {
338
+ // Moving before/after a file or folder
339
+ targetDir = target.parent;
340
+ }
341
+
342
+ // Determine the new order number
343
+ let newOrder;
344
+ if (position === 'before') {
345
+ // Take the target's order
346
+ newOrder = target.order || 1;
347
+ } else if (position === 'after') {
348
+ newOrder = (target.order || 0) + 1;
349
+ } else {
350
+ // Inside: use order 1
351
+ newOrder = 1;
352
+ }
353
+
354
+ // Build new filename
355
+ const paddedOrder = String(newOrder).padStart(2, '0');
356
+ const newFilename = `${paddedOrder}-${source.name}${source.extension}`;
357
+ const newPath = targetDir ? `${targetDir}/${newFilename}` : newFilename;
358
+
359
+ // Calculate renames needed (simplified - just returns the main rename)
360
+ const renames = [];
361
+
362
+ // The source file rename
363
+ if (sourcePath !== newPath) {
364
+ renames.push({ from: sourcePath, to: newPath });
365
+ }
366
+
367
+ return {
368
+ newPath,
369
+ renames,
370
+ };
371
+ }
package/src/index.js ADDED
@@ -0,0 +1,65 @@
1
+ /**
2
+ * mrmd-project
3
+ *
4
+ * Pure logic for understanding mrmd project structure and conventions.
5
+ *
6
+ * This package has NO side effects - no file I/O, no process spawning.
7
+ * All functions are pure: same input → same output.
8
+ *
9
+ * @example
10
+ * import { Project, FSML, Links, Assets, Scaffold, Search } from 'mrmd-project';
11
+ *
12
+ * // Find and parse project
13
+ * const root = Project.findRoot('/path/to/file.md', hasFile);
14
+ * const config = Project.parseConfig(mrmdMdContent);
15
+ *
16
+ * // Build navigation tree
17
+ * const tree = FSML.buildNavTree(files);
18
+ *
19
+ * // Resolve internal links
20
+ * const resolved = Links.resolve('installation', 'index.md', projectFiles);
21
+ *
22
+ * // Compute asset paths
23
+ * const relativePath = Assets.computeRelativePath('chapter/doc.md', '_assets/img.png');
24
+ *
25
+ * // Generate scaffolding
26
+ * const scaffold = Scaffold.project('my-project');
27
+ *
28
+ * // Fuzzy search
29
+ * const results = Search.files('thesis readme', allFiles);
30
+ */
31
+
32
+ // Project configuration
33
+ export * as Project from './project.js';
34
+
35
+ // Filesystem Markup Language
36
+ export * as FSML from './fsml.js';
37
+
38
+ // Internal links
39
+ export * as Links from './links.js';
40
+
41
+ // Asset management
42
+ export * as Assets from './assets.js';
43
+
44
+ // Scaffolding templates
45
+ export * as Scaffold from './scaffold.js';
46
+
47
+ // Search utilities
48
+ export * as Search from './search.js';
49
+
50
+ // Re-export everything as default for convenience
51
+ import * as Project from './project.js';
52
+ import * as FSML from './fsml.js';
53
+ import * as Links from './links.js';
54
+ import * as Assets from './assets.js';
55
+ import * as Scaffold from './scaffold.js';
56
+ import * as Search from './search.js';
57
+
58
+ export default {
59
+ Project,
60
+ FSML,
61
+ Links,
62
+ Assets,
63
+ Scaffold,
64
+ Search,
65
+ };