mrmd-editor 0.7.0 → 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 (61) 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/execution.js +69 -15
  8. package/src/frontmatter-updater.js +204 -74
  9. package/src/grammar.js +758 -0
  10. package/src/index.js +1120 -55
  11. package/src/keymap.js +11 -2
  12. package/src/markdown/block-decorations.js +108 -5
  13. package/src/markdown/facets.js +37 -0
  14. package/src/markdown/html-inline.js +9 -5
  15. package/src/markdown/index.js +13 -3
  16. package/src/markdown/inline-commands.js +256 -0
  17. package/src/markdown/inline-model.js +578 -0
  18. package/src/markdown/inline-state.js +103 -0
  19. package/src/markdown/renderer.js +219 -12
  20. package/src/markdown/styles.js +290 -3
  21. package/src/markdown/widgets/alert-title.js +10 -8
  22. package/src/markdown/widgets/frontmatter.js +0 -6
  23. package/src/markdown/widgets/index.js +1 -0
  24. package/src/markdown/widgets/list-marker.js +29 -0
  25. package/src/markdown/wysiwyg.js +1158 -0
  26. package/src/mrp-types.js +2 -0
  27. package/src/output-widget.js +532 -18
  28. package/src/page-view-pagination.js +127 -0
  29. package/src/runtime-lsp.js +1757 -150
  30. package/src/section-controls/commands.js +617 -0
  31. package/src/section-controls/index.js +63 -0
  32. package/src/section-controls/plugin.js +165 -0
  33. package/src/section-controls/widgets.js +936 -0
  34. package/src/shell/ai-menu.js +11 -0
  35. package/src/shell/components/context-panel.js +572 -0
  36. package/src/shell/components/status-bar.js +218 -8
  37. package/src/shell/dialogs/file-picker.js +211 -0
  38. package/src/shell/layouts/studio.js +229 -14
  39. package/src/shell/orchestrator-client.js +114 -0
  40. package/src/shell/styles.js +62 -0
  41. package/src/spellcheck.js +166 -0
  42. package/src/tables/README.md +97 -0
  43. package/src/tables/commands/insert-linked-table.js +122 -0
  44. package/src/tables/commands/open-table-workspace.js +43 -0
  45. package/src/tables/index.js +24 -0
  46. package/src/tables/jobs/client.js +158 -0
  47. package/src/tables/parsing/anchors.js +82 -0
  48. package/src/tables/parsing/linked-table-blocks.js +61 -0
  49. package/src/tables/state/linked-table-state.js +68 -0
  50. package/src/tables/widgets/linked-table-source-banner.js +77 -0
  51. package/src/tables/widgets/linked-table-widget.js +256 -0
  52. package/src/tables/workspace/controller.js +616 -0
  53. package/src/term-pty-client.js +111 -7
  54. package/src/term-widget.js +43 -3
  55. package/src/widgets/theme-utils.js +24 -16
  56. package/src/widgets/theme.js +1535 -1
  57. package/src/runtime-codelens/detector.js +0 -279
  58. package/src/runtime-codelens/index.js +0 -76
  59. package/src/runtime-codelens/plugin.js +0 -142
  60. package/src/runtime-codelens/styles.js +0 -184
  61. package/src/runtime-codelens/widgets.js +0 -216
package/src/execution.js CHANGED
@@ -737,6 +737,9 @@ export class ExecutionManager {
737
737
  // Emit start event
738
738
  this._emit('cellRun', index, cell, execId);
739
739
 
740
+ // Direct execution output write throttling (reduces CRDT churn for progress bars)
741
+ let clearChunkFlushTimer = null;
742
+
740
743
  try {
741
744
  // Prepare output position
742
745
  // Re-read content as it may have changed
@@ -815,22 +818,11 @@ export class ExecutionManager {
815
818
 
816
819
  // Track current output length in document for replacement
817
820
  let currentDocOutputLen = 0;
821
+ const chunkFlushMs = 100;
822
+ let chunkFlushTimer = null;
823
+ let latestProcessedOutput = '';
818
824
 
819
- const onChunk = (chunk, accumulatedRaw, done) => {
820
- if (controller.signal.aborted) return;
821
-
822
- // Process through terminal buffer (handles \r, cursor movement, ANSI)
823
- buffer.write(chunk);
824
-
825
- // Get processed output with ANSI codes preserved
826
- let processedOutput = buffer.toAnsi();
827
-
828
- // Ensure output ends with newline so closing ``` stays on its own line
829
- // This is critical for maintaining valid markdown structure
830
- if (processedOutput && !processedOutput.endsWith('\n')) {
831
- processedOutput += '\n';
832
- }
833
-
825
+ const applyProcessedOutput = (processedOutput) => {
834
826
  // Get current position - prefer finding by execId for robustness
835
827
  let currentOutputStart = outputContentStart;
836
828
 
@@ -865,6 +857,61 @@ export class ExecutionManager {
865
857
  });
866
858
 
867
859
  currentDocOutputLen = processedOutput.length;
860
+ };
861
+
862
+ const flushChunkOutputNow = () => {
863
+ if (chunkFlushTimer) {
864
+ clearTimeout(chunkFlushTimer);
865
+ chunkFlushTimer = null;
866
+ }
867
+ applyProcessedOutput(latestProcessedOutput);
868
+ };
869
+
870
+ clearChunkFlushTimer = () => {
871
+ if (chunkFlushTimer) {
872
+ clearTimeout(chunkFlushTimer);
873
+ chunkFlushTimer = null;
874
+ }
875
+ };
876
+
877
+ const scheduleChunkOutputFlush = () => {
878
+ if (chunkFlushMs === 0) {
879
+ flushChunkOutputNow();
880
+ return;
881
+ }
882
+ if (chunkFlushTimer) return;
883
+ chunkFlushTimer = setTimeout(() => {
884
+ chunkFlushTimer = null;
885
+ applyProcessedOutput(latestProcessedOutput);
886
+ }, chunkFlushMs);
887
+ };
888
+
889
+ controller.signal.addEventListener('abort', () => {
890
+ clearChunkFlushTimer?.();
891
+ }, { once: true });
892
+
893
+ const onChunk = (chunk, accumulatedRaw, done) => {
894
+ if (controller.signal.aborted) return;
895
+
896
+ // Process through terminal buffer (handles \r, cursor movement, ANSI)
897
+ buffer.write(chunk);
898
+
899
+ // Get processed output with ANSI codes preserved
900
+ let processedOutput = buffer.toAnsi();
901
+
902
+ // Ensure output ends with newline so closing ``` stays on its own line
903
+ // This is critical for maintaining valid markdown structure
904
+ if (processedOutput && !processedOutput.endsWith('\n')) {
905
+ processedOutput += '\n';
906
+ }
907
+
908
+ latestProcessedOutput = processedOutput;
909
+
910
+ if (done) {
911
+ flushChunkOutputNow();
912
+ } else {
913
+ scheduleChunkOutputFlush();
914
+ }
868
915
 
869
916
  this._emit('cellOutput', index, chunk, processedOutput, execId);
870
917
  };
@@ -883,6 +930,9 @@ export class ExecutionManager {
883
930
  return;
884
931
  }
885
932
 
933
+ // Flush pending output so prompt context is visible immediately
934
+ flushChunkOutputNow();
935
+
886
936
  const stdinExecId = execId;
887
937
 
888
938
  // Wait for any pending output to be written to the document
@@ -1009,6 +1059,9 @@ export class ExecutionManager {
1009
1059
  onAsset,
1010
1060
  });
1011
1061
 
1062
+ // Flush any pending throttled output before final normalization
1063
+ flushChunkOutputNow();
1064
+
1012
1065
  // Final update - find output block by execId for robustness
1013
1066
  content = this.editor.getContent();
1014
1067
  const finalOutputBlock = findOutputBlockByExecId(content, execId);
@@ -1127,6 +1180,7 @@ export class ExecutionManager {
1127
1180
  }
1128
1181
  return execId;
1129
1182
  } finally {
1183
+ clearChunkFlushTimer?.();
1130
1184
  this.running.delete(execId);
1131
1185
  this.buffers.delete(execId);
1132
1186
 
@@ -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
+ }