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/links.js ADDED
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Links module - Internal link parsing and resolution
3
+ *
4
+ * Handles [[wiki-style links]] used in mrmd documents.
5
+ *
6
+ * @module Links
7
+ */
8
+
9
+ /**
10
+ * Parse all internal links from content
11
+ *
12
+ * @param {string} content - Document content
13
+ * @returns {object[]} Array of parsed links
14
+ *
15
+ * @example
16
+ * Links.parse('See [[installation]] and [[config#advanced|advanced config]].')
17
+ * // Returns [
18
+ * // { raw: '[[installation]]', target: 'installation', anchor: null, display: null, start: 4, end: 20 },
19
+ * // { raw: '[[config#advanced|advanced config]]', target: 'config', anchor: 'advanced', display: 'advanced config', start: 25, end: 61 }
20
+ * // ]
21
+ */
22
+ export function parse(content) {
23
+ if (!content) return [];
24
+
25
+ const links = [];
26
+ // Match [[target]], [[target#anchor]], [[target|display]], [[target#anchor|display]]
27
+ const regex = /\[\[([^\]|#]+)(?:#([^\]|]+))?(?:\|([^\]]+))?\]\]/g;
28
+ let match;
29
+
30
+ while ((match = regex.exec(content)) !== null) {
31
+ links.push({
32
+ raw: match[0],
33
+ target: match[1].trim(),
34
+ anchor: match[2] ? match[2].trim() : null,
35
+ display: match[3] ? match[3].trim() : null,
36
+ start: match.index,
37
+ end: match.index + match[0].length,
38
+ });
39
+ }
40
+
41
+ return links;
42
+ }
43
+
44
+ /**
45
+ * Resolve a link target to an actual file path
46
+ *
47
+ * Resolution rules:
48
+ * 1. Exact match (with or without .md)
49
+ * 2. Fuzzy match on filename
50
+ * 3. Special links: next, prev, home, up
51
+ *
52
+ * @param {string} target - Link target
53
+ * @param {string} fromDocument - Document containing the link
54
+ * @param {string[]} projectFiles - All files in project
55
+ * @returns {string | null} Resolved path or null
56
+ */
57
+ export function resolve(target, fromDocument, projectFiles) {
58
+ if (!target || !projectFiles || projectFiles.length === 0) return null;
59
+
60
+ const sortedFiles = [...projectFiles].sort();
61
+
62
+ // Handle special links
63
+ const specialLinks = ['next', 'prev', 'home', 'up'];
64
+ if (specialLinks.includes(target.toLowerCase())) {
65
+ return resolveSpecialLink(target.toLowerCase(), fromDocument, sortedFiles);
66
+ }
67
+
68
+ // Normalize target (remove .md if present, lowercase for matching)
69
+ const targetNorm = target.replace(/\.md$/, '').toLowerCase();
70
+
71
+ // 1. Try exact path match
72
+ for (const file of projectFiles) {
73
+ const fileNorm = file.replace(/\.md$/, '').toLowerCase();
74
+ if (fileNorm === targetNorm || fileNorm === targetNorm + '/index') {
75
+ return file;
76
+ }
77
+ }
78
+
79
+ // 2. Try matching just the filename part of target against filenames
80
+ const targetFilename = targetNorm.split('/').pop();
81
+ for (const file of projectFiles) {
82
+ const filename = file.replace(/\.md$/, '').split('/').pop().toLowerCase();
83
+ // Remove numeric prefix for matching
84
+ const filenameNoPrefix = filename.replace(/^\d+-/, '');
85
+ if (filenameNoPrefix === targetFilename || filename === targetFilename) {
86
+ return file;
87
+ }
88
+ }
89
+
90
+ // 3. Try fuzzy matching - look for files containing the target name
91
+ for (const file of projectFiles) {
92
+ const fileLower = file.toLowerCase();
93
+ if (fileLower.includes(targetNorm)) {
94
+ return file;
95
+ }
96
+ }
97
+
98
+ return null;
99
+ }
100
+
101
+ /**
102
+ * Resolve special links (next, prev, home, up)
103
+ * @private
104
+ */
105
+ function resolveSpecialLink(target, fromDocument, sortedFiles) {
106
+ // Filter to only .md files (content files)
107
+ const mdFiles = sortedFiles.filter(f => f.endsWith('.md') && f !== 'mrmd.md');
108
+
109
+ // Sort by FSML order
110
+ const sorted = mdFiles.sort((a, b) => {
111
+ const aMatch = a.match(/^(\d+)-/);
112
+ const bMatch = b.match(/^(\d+)-/);
113
+ const aOrder = aMatch ? parseInt(aMatch[1]) : Infinity;
114
+ const bOrder = bMatch ? parseInt(bMatch[1]) : Infinity;
115
+ if (aOrder !== bOrder) return aOrder - bOrder;
116
+ return a.localeCompare(b);
117
+ });
118
+
119
+ const currentIndex = sorted.indexOf(fromDocument);
120
+
121
+ switch (target) {
122
+ case 'next':
123
+ return currentIndex >= 0 && currentIndex < sorted.length - 1
124
+ ? sorted[currentIndex + 1]
125
+ : null;
126
+
127
+ case 'prev':
128
+ return currentIndex > 0
129
+ ? sorted[currentIndex - 1]
130
+ : null;
131
+
132
+ case 'home':
133
+ return sorted[0] || null;
134
+
135
+ case 'up': {
136
+ // Go to parent directory's index or first file
137
+ const parts = fromDocument.split('/');
138
+ if (parts.length > 1) {
139
+ const parentDir = parts.slice(0, -1).join('/');
140
+ // Look for index.md in parent
141
+ const parentIndex = sorted.find(f => f === parentDir + '/index.md');
142
+ if (parentIndex) return parentIndex;
143
+ // Or first file in parent
144
+ const parentFile = sorted.find(f => f.startsWith(parentDir + '/'));
145
+ if (parentFile) return parentFile;
146
+ }
147
+ return sorted[0] || null;
148
+ }
149
+
150
+ default:
151
+ return null;
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Update links in content when files are moved/renamed
157
+ *
158
+ * @param {string} content - Document content
159
+ * @param {object[]} moves - Array of { from, to } moves
160
+ * @param {string} currentDocPath - Path of document being refactored
161
+ * @returns {string} Content with updated links
162
+ */
163
+ export function refactor(content, moves, currentDocPath) {
164
+ if (!content || !moves || moves.length === 0) return content;
165
+
166
+ // Build a map of old names to new names
167
+ const renameMap = new Map();
168
+ for (const move of moves) {
169
+ // Extract just the filename without path and extension
170
+ const oldName = move.from.replace(/\.md$/, '').split('/').pop().replace(/^\d+-/, '');
171
+ const newName = move.to.replace(/\.md$/, '').split('/').pop().replace(/^\d+-/, '');
172
+ renameMap.set(oldName.toLowerCase(), newName);
173
+ }
174
+
175
+ // Find and replace links
176
+ let result = content;
177
+ const links = parse(content);
178
+
179
+ // Process links in reverse order to preserve positions
180
+ for (let i = links.length - 1; i >= 0; i--) {
181
+ const link = links[i];
182
+ const targetName = link.target.split('/').pop().replace(/^\d+-/, '').toLowerCase();
183
+
184
+ if (renameMap.has(targetName)) {
185
+ const newName = renameMap.get(targetName);
186
+
187
+ // Build new link
188
+ let newLink = `[[${newName}`;
189
+ if (link.anchor) newLink += `#${link.anchor}`;
190
+ if (link.display) newLink += `|${link.display}`;
191
+ newLink += ']]';
192
+
193
+ result = result.slice(0, link.start) + newLink + result.slice(link.end);
194
+ }
195
+ }
196
+
197
+ return result;
198
+ }
package/src/project.js ADDED
@@ -0,0 +1,293 @@
1
+ /**
2
+ * Project module - Configuration parsing and session resolution
3
+ *
4
+ * @module Project
5
+ */
6
+
7
+ import YAML from 'yaml';
8
+
9
+ /**
10
+ * Default project configuration values
11
+ */
12
+ const DEFAULTS = {
13
+ session: {
14
+ python: {
15
+ venv: '.venv',
16
+ cwd: '.',
17
+ name: 'default',
18
+ auto_start: true,
19
+ },
20
+ },
21
+ assets: {
22
+ directory: '_assets',
23
+ },
24
+ };
25
+
26
+ /**
27
+ * Find the project root by walking up from startPath looking for mrmd.md
28
+ *
29
+ * @param {string} startPath - Path to start searching from
30
+ * @param {(path: string) => boolean} hasFile - Function to check if mrmd.md exists at path
31
+ * @returns {string | null} Project root path or null if not found
32
+ *
33
+ * @example
34
+ * const root = Project.findRoot('/home/user/thesis/chapter/doc.md', (p) => fs.existsSync(p + '/mrmd.md'));
35
+ * // Returns '/home/user/thesis' if mrmd.md exists there
36
+ */
37
+ export function findRoot(startPath, hasFile) {
38
+ if (!startPath) return null;
39
+
40
+ // Normalize path (remove trailing slash)
41
+ let current = startPath.replace(/\/+$/, '');
42
+
43
+ // If startPath is a file, start from its directory
44
+ // We detect this heuristically - if it has an extension, treat as file
45
+ if (/\.[^/]+$/.test(current)) {
46
+ const lastSlash = current.lastIndexOf('/');
47
+ if (lastSlash > 0) {
48
+ current = current.slice(0, lastSlash);
49
+ }
50
+ }
51
+
52
+ // Walk up the directory tree
53
+ while (current && current !== '/') {
54
+ if (hasFile(current)) {
55
+ return current;
56
+ }
57
+
58
+ // Go up one level
59
+ const lastSlash = current.lastIndexOf('/');
60
+ if (lastSlash <= 0) break;
61
+ current = current.slice(0, lastSlash);
62
+ }
63
+
64
+ // Check root as well
65
+ if (current === '/' && hasFile('/')) {
66
+ return '/';
67
+ }
68
+
69
+ return null;
70
+ }
71
+
72
+ /**
73
+ * Parse yaml config blocks from mrmd.md content
74
+ *
75
+ * Extracts all ```yaml config blocks and deep merges them in document order.
76
+ *
77
+ * @param {string} content - Content of mrmd.md file
78
+ * @returns {object} Merged configuration object
79
+ *
80
+ * @example
81
+ * const config = Project.parseConfig(`
82
+ * # My Project
83
+ * \`\`\`yaml config
84
+ * name: "My Thesis"
85
+ * session:
86
+ * python:
87
+ * venv: ".venv"
88
+ * \`\`\`
89
+ * `);
90
+ * // Returns { name: 'My Thesis', session: { python: { venv: '.venv' } } }
91
+ */
92
+ export function parseConfig(content) {
93
+ if (!content) return {};
94
+
95
+ // Match ```yaml config blocks (with optional whitespace)
96
+ const regex = /```yaml\s+config\s*\n([\s\S]*?)```/g;
97
+ let match;
98
+ let config = {};
99
+
100
+ while ((match = regex.exec(content)) !== null) {
101
+ const yamlContent = match[1];
102
+ try {
103
+ const parsed = YAML.parse(yamlContent);
104
+ if (parsed && typeof parsed === 'object') {
105
+ config = deepMerge(config, parsed);
106
+ }
107
+ } catch (e) {
108
+ // Invalid YAML, skip this block
109
+ console.warn('Failed to parse yaml config block:', e.message);
110
+ }
111
+ }
112
+
113
+ return config;
114
+ }
115
+
116
+ /**
117
+ * Parse YAML frontmatter from document content
118
+ *
119
+ * @param {string} content - Document content
120
+ * @returns {object | null} Parsed frontmatter or null if none
121
+ *
122
+ * @example
123
+ * const fm = Project.parseFrontmatter(`---
124
+ * title: "Chapter 1"
125
+ * session:
126
+ * python:
127
+ * name: "gpu"
128
+ * ---
129
+ * # Content
130
+ * `);
131
+ * // Returns { title: 'Chapter 1', session: { python: { name: 'gpu' } } }
132
+ */
133
+ export function parseFrontmatter(content) {
134
+ if (!content) return null;
135
+
136
+ // Frontmatter must start at the very beginning of the file
137
+ if (!content.startsWith('---')) return null;
138
+
139
+ // Find the closing ---
140
+ const endMatch = content.slice(3).indexOf('\n---');
141
+ if (endMatch === -1) return null;
142
+
143
+ const yamlContent = content.slice(4, endMatch + 3);
144
+
145
+ try {
146
+ const parsed = YAML.parse(yamlContent);
147
+ if (parsed && typeof parsed === 'object') {
148
+ return parsed;
149
+ }
150
+ return null;
151
+ } catch (e) {
152
+ // Invalid YAML
153
+ return null;
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Deep merge project config with document frontmatter
159
+ *
160
+ * Document frontmatter values override project config.
161
+ *
162
+ * @param {object} projectConfig - Project configuration
163
+ * @param {object | null} frontmatter - Document frontmatter
164
+ * @returns {object} Merged configuration
165
+ */
166
+ export function mergeConfig(projectConfig, frontmatter) {
167
+ if (!frontmatter) return projectConfig || {};
168
+ if (!projectConfig) return frontmatter;
169
+
170
+ return deepMerge(projectConfig, frontmatter);
171
+ }
172
+
173
+ /**
174
+ * Resolve full session configuration for a document
175
+ *
176
+ * Computes absolute paths and full session name.
177
+ *
178
+ * @param {string} documentPath - Absolute path to document
179
+ * @param {string} projectRoot - Absolute path to project root
180
+ * @param {object} mergedConfig - Merged configuration
181
+ * @returns {object} Resolved session config with absolute paths
182
+ *
183
+ * @example
184
+ * const session = Project.resolveSession(
185
+ * '/home/user/thesis/chapter/doc.md',
186
+ * '/home/user/thesis',
187
+ * { name: 'thesis', session: { python: { venv: '.venv', cwd: '.', name: 'default' } } }
188
+ * );
189
+ * // Returns { name: 'thesis:default', venv: '/home/user/thesis/.venv', cwd: '/home/user/thesis', autoStart: true }
190
+ */
191
+ export function resolveSession(documentPath, projectRoot, mergedConfig) {
192
+ const pythonConfig = mergedConfig?.session?.python || {};
193
+ const defaults = DEFAULTS.session.python;
194
+
195
+ // Get values with defaults
196
+ const venvRelative = pythonConfig.venv || defaults.venv;
197
+ const cwdRelative = pythonConfig.cwd || defaults.cwd;
198
+ const sessionName = pythonConfig.name || defaults.name;
199
+ const autoStart = pythonConfig.auto_start !== undefined ? pythonConfig.auto_start : defaults.auto_start;
200
+ const projectName = mergedConfig?.name || 'unnamed';
201
+
202
+ // Resolve paths relative to project root
203
+ const venv = resolvePath(projectRoot, venvRelative);
204
+ const cwd = resolvePath(projectRoot, cwdRelative);
205
+
206
+ return {
207
+ name: `${projectName}:${sessionName}`,
208
+ venv,
209
+ cwd,
210
+ autoStart,
211
+ };
212
+ }
213
+
214
+ /**
215
+ * Resolve a potentially relative path against a base directory
216
+ * @private
217
+ */
218
+ function resolvePath(basePath, relativePath) {
219
+ if (!relativePath) return basePath;
220
+
221
+ // If already absolute, return as-is
222
+ if (relativePath.startsWith('/')) {
223
+ return relativePath;
224
+ }
225
+
226
+ // Handle . as current directory
227
+ if (relativePath === '.') {
228
+ return basePath;
229
+ }
230
+
231
+ // Split paths into segments
232
+ const baseSegments = basePath.split('/').filter(Boolean);
233
+ const relativeSegments = relativePath.split('/').filter(Boolean);
234
+
235
+ // Process relative path segments
236
+ const resultSegments = [...baseSegments];
237
+
238
+ for (const segment of relativeSegments) {
239
+ if (segment === '..') {
240
+ resultSegments.pop();
241
+ } else if (segment !== '.') {
242
+ resultSegments.push(segment);
243
+ }
244
+ }
245
+
246
+ return '/' + resultSegments.join('/');
247
+ }
248
+
249
+ /**
250
+ * Get default configuration values
251
+ *
252
+ * @returns {object} Default configuration
253
+ */
254
+ export function getDefaults() {
255
+ return JSON.parse(JSON.stringify(DEFAULTS));
256
+ }
257
+
258
+ // ============================================================================
259
+ // Internal helpers
260
+ // ============================================================================
261
+
262
+ /**
263
+ * Deep merge two objects (source into target)
264
+ * @private
265
+ */
266
+ function deepMerge(target, source) {
267
+ if (!source) return target;
268
+ if (!target) return source;
269
+
270
+ const result = { ...target };
271
+
272
+ for (const key of Object.keys(source)) {
273
+ const sourceVal = source[key];
274
+ const targetVal = target[key];
275
+
276
+ if (
277
+ sourceVal !== null &&
278
+ typeof sourceVal === 'object' &&
279
+ !Array.isArray(sourceVal) &&
280
+ targetVal !== null &&
281
+ typeof targetVal === 'object' &&
282
+ !Array.isArray(targetVal)
283
+ ) {
284
+ // Both are objects, recurse
285
+ result[key] = deepMerge(targetVal, sourceVal);
286
+ } else {
287
+ // Source wins (including arrays)
288
+ result[key] = sourceVal;
289
+ }
290
+ }
291
+
292
+ return result;
293
+ }
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Scaffold module - Project and document templates
3
+ *
4
+ * Generates scaffolding content for new projects and standalone files.
5
+ *
6
+ * @module Scaffold
7
+ */
8
+
9
+ /**
10
+ * Generate project scaffold
11
+ *
12
+ * @param {string} name - Project name
13
+ * @returns {object} Scaffold with files array and venvPath
14
+ *
15
+ * @example
16
+ * Scaffold.project('my-research')
17
+ * // Returns {
18
+ * // files: [
19
+ * // { path: 'mrmd.md', content: '# my-research\n...' },
20
+ * // { path: '01-index.md', content: '# my-research\n...' },
21
+ * // { path: '_assets/.gitkeep', content: '' }
22
+ * // ],
23
+ * // venvPath: '.venv'
24
+ * // }
25
+ */
26
+ export function project(name) {
27
+ const safeName = name.replace(/[^a-zA-Z0-9-_]/g, '-');
28
+
29
+ const mrmdMd = `# ${name}
30
+
31
+ Welcome to your new mrmd project.
32
+
33
+ ## Configuration
34
+
35
+ \`\`\`yaml config
36
+ name: "${name}"
37
+ \`\`\`
38
+
39
+ ## Session Setup
40
+
41
+ We use a shared session for all documents in this project.
42
+
43
+ \`\`\`yaml config
44
+ session:
45
+ python:
46
+ venv: ".venv"
47
+ cwd: "."
48
+ name: "default"
49
+ auto_start: true
50
+ \`\`\`
51
+
52
+ ## Getting Started
53
+
54
+ - Edit this file to configure your project
55
+ - Create new documents with \`Ctrl+P\`
56
+ - Run code blocks with \`Ctrl+Enter\`
57
+
58
+ ## Environment Check
59
+
60
+ \`\`\`python
61
+ import sys
62
+ print(f"Python: {sys.version}")
63
+ print(f"Working directory: {__import__('os').getcwd()}")
64
+ \`\`\`
65
+ `;
66
+
67
+ const indexMd = `# ${name}
68
+
69
+ This is your project's main document.
70
+
71
+ ## Quick Start
72
+
73
+ \`\`\`python
74
+ print("Hello from mrmd!")
75
+ \`\`\`
76
+
77
+ ## Project Structure
78
+
79
+ | Path | Purpose |
80
+ |------|---------|
81
+ | \`mrmd.md\` | Project configuration |
82
+ | \`_assets/\` | Images and data files |
83
+ | \`.venv/\` | Python environment |
84
+
85
+ ## Next Steps
86
+
87
+ - Create new documents with \`Ctrl+P\`
88
+ - Organize with folders: \`02-section/01-document.md\`
89
+ - Add images to \`_assets/\` and reference with \`![](_assets/image.png)\`
90
+ `;
91
+
92
+ return {
93
+ files: [
94
+ { path: 'mrmd.md', content: mrmdMd },
95
+ { path: '01-index.md', content: indexMd },
96
+ { path: '_assets/.gitkeep', content: '' },
97
+ ],
98
+ venvPath: '.venv',
99
+ };
100
+ }
101
+
102
+ /**
103
+ * Generate frontmatter for standalone files
104
+ *
105
+ * @param {object} config - Configuration
106
+ * @param {string} config.venv - Absolute path to venv
107
+ * @param {string} config.cwd - Absolute path to working directory
108
+ * @param {string} [config.title] - Optional title
109
+ * @returns {string} YAML frontmatter string
110
+ *
111
+ * @example
112
+ * Scaffold.standaloneFrontmatter({
113
+ * venv: '/home/user/.venv',
114
+ * cwd: '/home/user/work',
115
+ * title: 'Quick Analysis'
116
+ * })
117
+ * // Returns '---\ntitle: "Quick Analysis"\nsession:\n python:\n venv: "/home/user/.venv"\n cwd: "/home/user/work"\n---\n'
118
+ */
119
+ export function standaloneFrontmatter(config) {
120
+ const { venv, cwd, title } = config;
121
+
122
+ let yaml = '---\n';
123
+
124
+ if (title) {
125
+ yaml += `title: "${title}"\n`;
126
+ }
127
+
128
+ yaml += `session:
129
+ python:
130
+ venv: "${venv}"
131
+ cwd: "${cwd}"
132
+ ---
133
+ `;
134
+
135
+ return yaml;
136
+ }