mrmd-editor 0.6.0 → 0.7.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.
@@ -0,0 +1,224 @@
1
+ /**
2
+ * Frontmatter Session Updater
3
+ *
4
+ * Programmatically updates session/runtime configuration in a document's
5
+ * YAML frontmatter. Used by the runtimes panel UI to persist configuration
6
+ * changes back to the document.
7
+ *
8
+ * @module frontmatter-updater
9
+ */
10
+
11
+ import yaml from 'yaml';
12
+
13
+ /**
14
+ * @typedef {Object} SessionConfig
15
+ * @property {string} [name] - Session name
16
+ * @property {string} [venv] - Virtual environment path (Python)
17
+ * @property {string} [cwd] - Working directory
18
+ * @property {boolean} [auto_start] - Auto-start on project open
19
+ */
20
+
21
+ /**
22
+ * Parse existing frontmatter from document content.
23
+ *
24
+ * @param {string} content - Full document content
25
+ * @returns {{ exists: boolean, yaml: object|null, range: {start: number, end: number}|null, raw: string|null }}
26
+ */
27
+ export function parseFrontmatter(content) {
28
+ if (!content.startsWith('---')) {
29
+ return { exists: false, yaml: null, range: null, raw: null };
30
+ }
31
+
32
+ const endIdx = content.indexOf('\n---', 3);
33
+ if (endIdx === -1) {
34
+ return { exists: false, yaml: null, range: null, raw: null };
35
+ }
36
+
37
+ const rawYaml = content.slice(4, endIdx); // Skip opening ---\n
38
+ const endOfClosing = endIdx + 4; // Include \n---
39
+
40
+ try {
41
+ const parsed = yaml.parse(rawYaml) || {};
42
+ return {
43
+ exists: true,
44
+ yaml: parsed,
45
+ range: { start: 0, end: endOfClosing },
46
+ raw: rawYaml,
47
+ };
48
+ } catch (e) {
49
+ console.warn('[frontmatter-updater] Failed to parse YAML:', e.message);
50
+ return { exists: true, yaml: null, range: { start: 0, end: endOfClosing }, raw: rawYaml };
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Build frontmatter YAML string from an object.
56
+ *
57
+ * @param {object} data - Frontmatter data
58
+ * @returns {string} Complete frontmatter block including --- delimiters
59
+ */
60
+ function buildFrontmatter(data) {
61
+ // Remove empty/null values
62
+ const cleaned = cleanObject(data);
63
+
64
+ if (!cleaned || Object.keys(cleaned).length === 0) {
65
+ return '';
66
+ }
67
+
68
+ const yamlStr = yaml.stringify(cleaned, {
69
+ indent: 2,
70
+ lineWidth: 0, // Don't wrap lines
71
+ }).trimEnd();
72
+
73
+ return `---\n${yamlStr}\n---`;
74
+ }
75
+
76
+ /**
77
+ * Recursively remove null, undefined, and empty object values.
78
+ * @param {any} obj
79
+ * @returns {any}
80
+ */
81
+ function cleanObject(obj) {
82
+ if (obj === null || obj === undefined) return undefined;
83
+ if (typeof obj !== 'object' || Array.isArray(obj)) return obj;
84
+
85
+ const result = {};
86
+ for (const [key, value] of Object.entries(obj)) {
87
+ if (value === null || value === undefined) continue;
88
+ if (typeof value === 'object' && !Array.isArray(value)) {
89
+ const cleaned = cleanObject(value);
90
+ if (cleaned && Object.keys(cleaned).length > 0) {
91
+ result[key] = cleaned;
92
+ }
93
+ } else {
94
+ result[key] = value;
95
+ }
96
+ }
97
+ return Object.keys(result).length > 0 ? result : undefined;
98
+ }
99
+
100
+ /**
101
+ * Update session configuration in document frontmatter.
102
+ *
103
+ * If frontmatter doesn't exist, creates it.
104
+ * If values match project defaults, omits them (keeps frontmatter clean).
105
+ *
106
+ * @param {import('@codemirror/view').EditorView} view - CodeMirror editor view
107
+ * @param {string} language - Runtime language (e.g. 'python', 'bash', 'r', 'julia')
108
+ * @param {SessionConfig} config - Session configuration to set
109
+ * @param {SessionConfig} [projectDefaults] - Defaults from mrmd.md to diff against
110
+ */
111
+ export function updateFrontmatterSession(view, language, config, projectDefaults = {}) {
112
+ const doc = view.state.doc;
113
+ const content = doc.toString();
114
+ const fm = parseFrontmatter(content);
115
+
116
+ // Start with existing frontmatter data or empty object
117
+ const data = fm.yaml ? { ...fm.yaml } : {};
118
+
119
+ // Build session config, omitting values that match project defaults
120
+ const sessionConfig = {};
121
+
122
+ if (config.name !== undefined && config.name !== (projectDefaults.name || 'default')) {
123
+ sessionConfig.name = config.name;
124
+ }
125
+ if (config.venv !== undefined && config.venv !== (projectDefaults.venv || '.venv')) {
126
+ sessionConfig.venv = config.venv;
127
+ }
128
+ if (config.cwd !== undefined && config.cwd !== (projectDefaults.cwd || '.')) {
129
+ sessionConfig.cwd = config.cwd;
130
+ }
131
+ if (config.auto_start !== undefined && config.auto_start !== true) {
132
+ sessionConfig.auto_start = config.auto_start;
133
+ }
134
+
135
+ // Update the session section
136
+ if (Object.keys(sessionConfig).length > 0) {
137
+ if (!data.session) data.session = {};
138
+ data.session[language] = {
139
+ ...(data.session[language] || {}),
140
+ ...sessionConfig,
141
+ };
142
+ } else {
143
+ // All values match defaults — remove the language key if it exists
144
+ if (data.session && data.session[language]) {
145
+ delete data.session[language];
146
+ if (Object.keys(data.session).length === 0) {
147
+ delete data.session;
148
+ }
149
+ }
150
+ }
151
+
152
+ // Build new frontmatter string
153
+ const newFrontmatter = buildFrontmatter(data);
154
+
155
+ // Apply the change to the editor
156
+ if (fm.exists && fm.range) {
157
+ // Replace existing frontmatter
158
+ if (newFrontmatter) {
159
+ view.dispatch({
160
+ changes: { from: fm.range.start, to: fm.range.end, insert: newFrontmatter },
161
+ });
162
+ } else {
163
+ // Remove frontmatter entirely (and trailing newline if present)
164
+ let removeEnd = fm.range.end;
165
+ if (content[removeEnd] === '\n') removeEnd++;
166
+ view.dispatch({
167
+ changes: { from: fm.range.start, to: removeEnd, insert: '' },
168
+ });
169
+ }
170
+ } else if (newFrontmatter) {
171
+ // Insert new frontmatter at the top
172
+ view.dispatch({
173
+ changes: { from: 0, to: 0, insert: newFrontmatter + '\n\n' },
174
+ });
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Read current session configuration from document frontmatter.
180
+ *
181
+ * @param {string} content - Document content
182
+ * @param {string} language - Runtime language
183
+ * @returns {SessionConfig} Current session config (may be empty object if using defaults)
184
+ */
185
+ export function readFrontmatterSession(content, language) {
186
+ const fm = parseFrontmatter(content);
187
+ if (!fm.yaml) return {};
188
+
189
+ // Check verbose syntax: session.python.venv
190
+ if (fm.yaml.session && fm.yaml.session[language]) {
191
+ return { ...fm.yaml.session[language] };
192
+ }
193
+
194
+ // Check minimal syntax: python: ".venv"
195
+ if (fm.yaml[language]) {
196
+ const value = fm.yaml[language];
197
+ if (typeof value === 'string') {
198
+ if (language === 'python') return { venv: value };
199
+ return { cwd: value };
200
+ }
201
+ if (typeof value === 'object') return { ...value };
202
+ }
203
+
204
+ return {};
205
+ }
206
+
207
+ /**
208
+ * Get the effective session configuration for a document,
209
+ * merging project defaults with document-level overrides.
210
+ *
211
+ * @param {string} content - Document content
212
+ * @param {string} language - Runtime language
213
+ * @param {SessionConfig} projectDefaults - Defaults from mrmd.md
214
+ * @returns {SessionConfig} Effective configuration
215
+ */
216
+ export function getEffectiveSessionConfig(content, language, projectDefaults = {}) {
217
+ const docConfig = readFrontmatterSession(content, language);
218
+ return {
219
+ name: docConfig.name || projectDefaults.name || 'default',
220
+ venv: docConfig.venv || projectDefaults.venv || '.venv',
221
+ cwd: docConfig.cwd || projectDefaults.cwd || '.',
222
+ auto_start: docConfig.auto_start ?? projectDefaults.auto_start ?? true,
223
+ };
224
+ }