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/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
|
+
}
|
package/src/scaffold.js
ADDED
|
@@ -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 \`\`
|
|
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
|
+
}
|