mrmd-editor 0.7.1 → 0.8.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.
Files changed (58) hide show
  1. package/package.json +3 -1
  2. package/src/commands.js +112 -4
  3. package/src/comment-syntax.js +364 -39
  4. package/src/config/handlers.js +1 -2
  5. package/src/config/schema.js +46 -4
  6. package/src/document-template.js +2236 -0
  7. package/src/frontmatter-updater.js +204 -74
  8. package/src/grammar.js +758 -0
  9. package/src/index.js +1074 -55
  10. package/src/keymap.js +11 -2
  11. package/src/markdown/block-decorations.js +108 -5
  12. package/src/markdown/facets.js +37 -0
  13. package/src/markdown/html-inline.js +9 -5
  14. package/src/markdown/index.js +13 -3
  15. package/src/markdown/inline-commands.js +256 -0
  16. package/src/markdown/inline-model.js +578 -0
  17. package/src/markdown/inline-state.js +103 -0
  18. package/src/markdown/renderer.js +219 -12
  19. package/src/markdown/styles.js +290 -3
  20. package/src/markdown/widgets/alert-title.js +10 -8
  21. package/src/markdown/widgets/frontmatter.js +0 -6
  22. package/src/markdown/widgets/index.js +1 -0
  23. package/src/markdown/widgets/list-marker.js +29 -0
  24. package/src/markdown/wysiwyg.js +1158 -0
  25. package/src/mrp-types.js +2 -0
  26. package/src/output-widget.js +532 -18
  27. package/src/page-view-pagination.js +127 -0
  28. package/src/runtime-lsp.js +1757 -150
  29. package/src/section-controls/commands.js +617 -0
  30. package/src/section-controls/index.js +63 -0
  31. package/src/section-controls/plugin.js +165 -0
  32. package/src/section-controls/widgets.js +936 -0
  33. package/src/shell/ai-menu.js +11 -0
  34. package/src/shell/components/context-panel.js +572 -0
  35. package/src/shell/components/status-bar.js +10 -2
  36. package/src/shell/layouts/studio.js +206 -14
  37. package/src/shell/orchestrator-client.js +69 -0
  38. package/src/spellcheck.js +166 -0
  39. package/src/tables/README.md +97 -0
  40. package/src/tables/commands/insert-linked-table.js +122 -0
  41. package/src/tables/commands/open-table-workspace.js +43 -0
  42. package/src/tables/index.js +24 -0
  43. package/src/tables/jobs/client.js +158 -0
  44. package/src/tables/parsing/anchors.js +82 -0
  45. package/src/tables/parsing/linked-table-blocks.js +61 -0
  46. package/src/tables/state/linked-table-state.js +68 -0
  47. package/src/tables/widgets/linked-table-source-banner.js +77 -0
  48. package/src/tables/widgets/linked-table-widget.js +256 -0
  49. package/src/tables/workspace/controller.js +616 -0
  50. package/src/term-pty-client.js +51 -2
  51. package/src/term-widget.js +43 -3
  52. package/src/widgets/theme-utils.js +24 -16
  53. package/src/widgets/theme.js +1015 -1
  54. package/src/runtime-codelens/detector.js +0 -279
  55. package/src/runtime-codelens/index.js +0 -76
  56. package/src/runtime-codelens/plugin.js +0 -142
  57. package/src/runtime-codelens/styles.js +0 -184
  58. package/src/runtime-codelens/widgets.js +0 -216
@@ -35,19 +35,17 @@ export function parseFrontmatter(content) {
35
35
  }
36
36
 
37
37
  const rawYaml = content.slice(4, endIdx); // Skip opening ---\n
38
- const endOfClosing = endIdx + 4; // Include \n---
39
-
40
38
  try {
41
39
  const parsed = yaml.parse(rawYaml) || {};
42
40
  return {
43
41
  exists: true,
44
42
  yaml: parsed,
45
- range: { start: 0, end: endOfClosing },
43
+ range: { start: 0, end: endIdx + 4 },
46
44
  raw: rawYaml,
47
45
  };
48
46
  } catch (e) {
49
47
  console.warn('[frontmatter-updater] Failed to parse YAML:', e.message);
50
- return { exists: true, yaml: null, range: { start: 0, end: endOfClosing }, raw: rawYaml };
48
+ return { exists: true, yaml: null, range: { start: 0, end: endIdx + 4 }, raw: rawYaml };
51
49
  }
52
50
  }
53
51
 
@@ -57,8 +55,7 @@ export function parseFrontmatter(content) {
57
55
  * @param {object} data - Frontmatter data
58
56
  * @returns {string} Complete frontmatter block including --- delimiters
59
57
  */
60
- function buildFrontmatter(data) {
61
- // Remove empty/null values
58
+ export function buildFrontmatter(data) {
62
59
  const cleaned = cleanObject(data);
63
60
 
64
61
  if (!cleaned || Object.keys(cleaned).length === 0) {
@@ -67,7 +64,7 @@ function buildFrontmatter(data) {
67
64
 
68
65
  const yamlStr = yaml.stringify(cleaned, {
69
66
  indent: 2,
70
- lineWidth: 0, // Don't wrap lines
67
+ lineWidth: 0,
71
68
  }).trimEnd();
72
69
 
73
70
  return `---\n${yamlStr}\n---`;
@@ -75,12 +72,14 @@ function buildFrontmatter(data) {
75
72
 
76
73
  /**
77
74
  * Recursively remove null, undefined, and empty object values.
75
+ *
78
76
  * @param {any} obj
79
77
  * @returns {any}
80
78
  */
81
79
  function cleanObject(obj) {
82
80
  if (obj === null || obj === undefined) return undefined;
83
- if (typeof obj !== 'object' || Array.isArray(obj)) return obj;
81
+ if (Array.isArray(obj)) return obj.map(cloneValue).filter(v => v !== undefined);
82
+ if (typeof obj !== 'object') return obj;
84
83
 
85
84
  const result = {};
86
85
  for (const [key, value] of Object.entries(obj)) {
@@ -90,6 +89,11 @@ function cleanObject(obj) {
90
89
  if (cleaned && Object.keys(cleaned).length > 0) {
91
90
  result[key] = cleaned;
92
91
  }
92
+ } else if (Array.isArray(value)) {
93
+ const cleanedArray = value.map(cloneValue).filter(v => v !== undefined);
94
+ if (cleanedArray.length > 0) {
95
+ result[key] = cleanedArray;
96
+ }
93
97
  } else {
94
98
  result[key] = value;
95
99
  }
@@ -97,88 +101,152 @@ function cleanObject(obj) {
97
101
  return Object.keys(result).length > 0 ? result : undefined;
98
102
  }
99
103
 
104
+ function isPlainObject(value) {
105
+ return !!value && typeof value === 'object' && !Array.isArray(value);
106
+ }
107
+
108
+ function cloneValue(value) {
109
+ if (Array.isArray(value)) {
110
+ return value.map(cloneValue);
111
+ }
112
+ if (isPlainObject(value)) {
113
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, cloneValue(v)]));
114
+ }
115
+ return value;
116
+ }
117
+
118
+ function mergeTemplateWithExisting(templateValue, existingValue) {
119
+ if (existingValue === undefined || existingValue === null) {
120
+ return cloneValue(templateValue);
121
+ }
122
+
123
+ if (Array.isArray(existingValue)) {
124
+ return cloneValue(existingValue);
125
+ }
126
+
127
+ if (isPlainObject(templateValue) && isPlainObject(existingValue)) {
128
+ const result = {};
129
+ const keys = new Set([...Object.keys(templateValue), ...Object.keys(existingValue)]);
130
+ for (const key of keys) {
131
+ result[key] = mergeTemplateWithExisting(templateValue?.[key], existingValue?.[key]);
132
+ }
133
+ return result;
134
+ }
135
+
136
+ return cloneValue(existingValue);
137
+ }
138
+
139
+ function formatLocalDate(date = new Date()) {
140
+ const year = date.getFullYear();
141
+ const month = String(date.getMonth() + 1).padStart(2, '0');
142
+ const day = String(date.getDate()).padStart(2, '0');
143
+ return `${year}-${month}-${day}`;
144
+ }
145
+
146
+ function createTitleSelection(frontmatterBlock) {
147
+ const prefix = 'title: ';
148
+ const start = frontmatterBlock.indexOf(prefix);
149
+ if (start === -1) return null;
150
+ const from = start + prefix.length;
151
+ const lineEnd = frontmatterBlock.indexOf('\n', from);
152
+ return {
153
+ from,
154
+ to: lineEnd === -1 ? from : lineEnd,
155
+ };
156
+ }
157
+
100
158
  /**
101
- * Update session configuration in document frontmatter.
159
+ * Create a scholarly frontmatter template.
102
160
  *
103
- * If frontmatter doesn't exist, creates it.
104
- * If values match project defaults, omits them (keeps frontmatter clean).
161
+ * @param {Date} [now]
162
+ * @returns {object}
163
+ */
164
+ export function createArticleFrontmatterTemplate(now = new Date()) {
165
+ return {
166
+ title: 'Untitled',
167
+ date: formatLocalDate(now),
168
+ author: [
169
+ {
170
+ name: 'Your Name',
171
+ id: 'your-id',
172
+ orcid: '0000-0000-0000-0000',
173
+ email: 'you@example.com',
174
+ affiliation: [
175
+ {
176
+ name: 'Your Institution',
177
+ city: 'City',
178
+ state: 'State',
179
+ url: 'https://example.org',
180
+ },
181
+ ],
182
+ },
183
+ ],
184
+ abstract: 'Write your abstract here.\n',
185
+ keywords: ['Keyword 1', 'Keyword 2'],
186
+ license: 'CC BY',
187
+ copyright: {
188
+ holder: 'Your Name',
189
+ year: now.getFullYear(),
190
+ },
191
+ citation: {
192
+ 'container-title': 'Journal or Venue',
193
+ volume: 1,
194
+ issue: 1,
195
+ doi: '10.0000/example',
196
+ },
197
+ funding: 'Add funding information here.',
198
+ };
199
+ }
200
+
201
+ /**
202
+ * Build a document edit that inserts or augments frontmatter with a template.
203
+ * Existing values win, while missing keys are added from the template.
105
204
  *
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
205
+ * @param {string} content - Full document content
206
+ * @param {object} [templateData] - Template frontmatter data
207
+ * @returns {{ changes: {from: number, to: number, insert: string}, selection: {from: number, to: number}|null, data: object }|null}
110
208
  */
111
- export function updateFrontmatterSession(view, language, config, projectDefaults = {}) {
112
- const doc = view.state.doc;
113
- const content = doc.toString();
209
+ export function applyFrontmatterTemplate(content, templateData = createArticleFrontmatterTemplate()) {
114
210
  const fm = parseFrontmatter(content);
115
211
 
116
- // Start with existing frontmatter data or empty object
117
- const data = fm.yaml ? { ...fm.yaml } : {};
212
+ if (fm.exists && !fm.yaml) {
213
+ return null;
214
+ }
118
215
 
119
- // Build session config, omitting values that match project defaults
120
- const sessionConfig = {};
216
+ const merged = fm.exists
217
+ ? mergeTemplateWithExisting(templateData, fm.yaml || {})
218
+ : cloneValue(templateData);
121
219
 
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
- }
220
+ const frontmatterBlock = buildFrontmatter(merged);
221
+ const selection = createTitleSelection(frontmatterBlock);
134
222
 
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,
223
+ if (!fm.exists) {
224
+ return {
225
+ changes: {
226
+ from: 0,
227
+ to: 0,
228
+ insert: `${frontmatterBlock}${content.length > 0 ? '\n\n' : '\n'}`,
229
+ },
230
+ selection,
231
+ data: merged,
141
232
  };
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
233
  }
151
234
 
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
- }
235
+ return {
236
+ changes: {
237
+ from: fm.range?.start ?? 0,
238
+ to: fm.range?.end ?? 0,
239
+ insert: frontmatterBlock,
240
+ },
241
+ selection,
242
+ data: merged,
243
+ };
176
244
  }
177
245
 
178
246
  /**
179
247
  * Read current session configuration from document frontmatter.
180
248
  *
181
- * @param {string} content - Document content
249
+ * @param {string} content - Full document content
182
250
  * @param {string} language - Runtime language
183
251
  * @returns {SessionConfig} Current session config (may be empty object if using defaults)
184
252
  */
@@ -208,7 +276,7 @@ export function readFrontmatterSession(content, language) {
208
276
  * Get the effective session configuration for a document,
209
277
  * merging project defaults with document-level overrides.
210
278
  *
211
- * @param {string} content - Document content
279
+ * @param {string} content - Full document content
212
280
  * @param {string} language - Runtime language
213
281
  * @param {SessionConfig} projectDefaults - Defaults from mrmd.md
214
282
  * @returns {SessionConfig} Effective configuration
@@ -222,3 +290,65 @@ export function getEffectiveSessionConfig(content, language, projectDefaults = {
222
290
  auto_start: docConfig.auto_start ?? projectDefaults.auto_start ?? true,
223
291
  };
224
292
  }
293
+
294
+ function setPathValue(target, path, value) {
295
+ const parts = Array.isArray(path) ? path : String(path).split('.').filter(Boolean);
296
+ if (!parts.length) return target;
297
+
298
+ let obj = target;
299
+ for (let i = 0; i < parts.length - 1; i++) {
300
+ const key = parts[i];
301
+ if (!obj[key] || typeof obj[key] !== 'object' || Array.isArray(obj[key])) {
302
+ obj[key] = {};
303
+ }
304
+ obj = obj[key];
305
+ }
306
+
307
+ const leaf = parts[parts.length - 1];
308
+ if (value === undefined || value === null || value === '') {
309
+ delete obj[leaf];
310
+ } else {
311
+ obj[leaf] = value;
312
+ }
313
+ return target;
314
+ }
315
+
316
+ export function readFrontmatterValue(content, path, fallback = undefined) {
317
+ const fm = parseFrontmatter(content);
318
+ const parts = Array.isArray(path) ? path : String(path).split('.').filter(Boolean);
319
+ let value = fm.yaml || {};
320
+ for (const part of parts) {
321
+ if (value == null || typeof value !== 'object') return fallback;
322
+ value = value[part];
323
+ }
324
+ return value === undefined ? fallback : value;
325
+ }
326
+
327
+ export function updateFrontmatterField(content, path, value) {
328
+ const fm = parseFrontmatter(content);
329
+ if (fm.exists && !fm.yaml) return null;
330
+
331
+ const data = fm.exists ? cloneValue(fm.yaml || {}) : {};
332
+ setPathValue(data, path, value);
333
+ const frontmatterBlock = buildFrontmatter(data);
334
+
335
+ if (!fm.exists) {
336
+ return {
337
+ changes: {
338
+ from: 0,
339
+ to: 0,
340
+ insert: `${frontmatterBlock}${content.length > 0 ? '\n\n' : '\n'}`,
341
+ },
342
+ data,
343
+ };
344
+ }
345
+
346
+ return {
347
+ changes: {
348
+ from: fm.range?.start ?? 0,
349
+ to: fm.range?.end ?? 0,
350
+ insert: frontmatterBlock,
351
+ },
352
+ data,
353
+ };
354
+ }