Files
markdown-converter/src/editor/codemirror-setup.js
T
amitwh bd86748c47 feat(paste): smart URL → markdown-link on URL-only pastes
- src/main/UrlTitle.js — fetch a URL, return its <title>. Pure module
  with injectable fetch for tests. Decodes named + numeric + hex entities
  (AT&amp;T, Caf&#233;, &#x2014;), strips the <title> tags, collapses
  whitespace, caps the label at 200 chars. 5s timeout via AbortController,
  2 MiB body cap to avoid OOM on big downloads, streaming reader with
  overflow cancellation. Rejects non-http(s) URLs up front.
- src/main.js — IPC url-title:fetch proxies to fetchTitle.
- src/editor/smart-paste.js — CodeMirror 6 extension that detects
  URL-only pastes (one URL, surrounded only by whitespace), inserts the
  URL immediately, then async-rewrites the insertion range to
  [Title](url) once the title arrives. Multi-line / prose pastes pass
  through untouched.
- src/editor/codemirror-setup.js — accepts smartPasteFetcher option and
  pushes the extension when provided.
- src/renderer.js — passes ipcRenderer.invoke('url-title:fetch') as
  the fetcher.
- src/preload.js — url-title:fetch added to ALLOWED_SEND_CHANNELS.
- eslint.config.js — AbortController / TextDecoder / TextEncoder added
  to globals (available in Node 20+ and Chromium).

Tests (34 new):
- tests/main/UrlTitle.test.js (24): isHttpUrl scheme filter, decodeTitle
  named/numeric/hex entities + whitespace + non-string, extractTitleFromHtml
  first match + case-insensitive + null fallback, fetchTitle success +
  long-title cap + streaming body, all failure paths (non-http,
  no-fetch, non-OK, wrong content-type, no <title>, network error,
  body over maxBytes — including streaming overflow cancellation).
- tests/smart-paste.test.js (10): URL_ONLY_RE detection (bare URL,
  path/query/fragment, whitespace padding, scheme rejection, embedded
  rejection, empty/malformed), replacement format ([T](url), brackets
  in title survive, query strings preserved).

Full suite: 71 suites, 831 tests, lint+format clean.

Amit Haridas
2026-09-14 08:58:32 +05:30

220 lines
7.1 KiB
JavaScript

// CodeMirror 6 wrapper module
// Provides createEditor() and getLanguageExtension() for the rest of the app.
const {
EditorView,
keymap,
lineNumbers,
highlightActiveLine,
drawSelection,
} = require('@codemirror/view');
const { EditorState, Compartment } = require('@codemirror/state');
const { markdown, markdownLanguage } = require('@codemirror/lang-markdown');
// Language extensions loaded lazily on first use
let _javascript, _html, _css, _json, _python;
const { defaultKeymap, history, historyKeymap, indentWithTab } = require('@codemirror/commands');
const { searchKeymap, highlightSelectionMatches } = require('@codemirror/search');
const { autocompletion, completionKeymap } = require('@codemirror/autocomplete');
const { bracketMatching, foldGutter, indentOnInput } = require('@codemirror/language');
const { oneDark } = require('@codemirror/theme-one-dark');
// Compartments allow toggling features (vim mode) on a live editor without
// recreating the view — CodeMirror 6's intended mechanism for reconfiguration.
const vimCompartment = new Compartment();
// Vim is loaded lazily: most users never enable it, and the module is heavy.
let _vimExtension = null;
function getVimExtension() {
if (!_vimExtension) {
_vimExtension = require('@replit/codemirror-vim').vim();
}
return _vimExtension;
}
/**
* Toggle vim keybindings on an editor created by createEditor.
* @param {EditorView} view
* @param {boolean} enabled
*/
function setVimMode(view, enabled) {
if (!view) return;
view.dispatch({
effects: vimCompartment.reconfigure(enabled ? getVimExtension() : []),
});
}
// Custom theme for JetBrains Mono font
const jetBrainsMonoTheme = EditorView.theme({
'&': {
fontFamily: "'JetBrains Mono', 'Fira Code', 'SF Mono', Monaco, 'Courier New', monospace",
},
'.cm-content': {
fontFamily: 'inherit',
},
'.cm-scroller': {
fontFamily: 'inherit',
},
});
/**
* Build the Tab keymap entry that expands snippets before falling through to
* indent. `getExpansion(prefix)` is synchronous and returns the replacement
* text (or null to decline), letting the host keep its snippets in a Map.
*/
function snippetTabKeymap(getExpansion) {
return {
key: 'Tab',
run: (view) => {
if (typeof getExpansion !== 'function') return false;
const { head } = view.state.selection.main;
const line = view.state.doc.lineAt(head);
// Word-before-cursor: letters, digits, underscore and hyphen
const before = line.text.slice(0, head - line.from);
const match = /[\w-]+$/.exec(before);
if (!match) return false;
const expansion = getExpansion(match[0].toLowerCase());
if (typeof expansion !== 'string') return false;
view.dispatch({
changes: {
from: head - match[0].length,
to: head,
insert: expansion,
},
selection: { anchor: head - match[0].length + expansion.length },
});
return true;
},
};
}
/**
* Create a CodeMirror 6 editor instance.
*
* @param {HTMLElement} parentElement - DOM element to mount the editor in
* @param {Object} options
* @param {string} options.content - initial document content (default '')
* @param {Function} options.onChange - called with new content string on every doc change
* @param {Function} options.onUpdate - called with the EditorView on every update (selection, doc change, etc.)
* @param {boolean} options.isDark - apply oneDark theme when true (default false)
* @param {boolean} options.showLineNumbers - show line-number gutter (default true)
* @param {boolean} options.vimMode - start with vim keybindings (default false; toggle later via setVimMode)
* @param {Function} options.getTabExpansion - (prefix)=>string|null; Tab expands a matching snippet (default null)
* @returns {EditorView} the created editor view
*/
function createEditor(parentElement, options = {}) {
console.log(
'[createEditor] Called with parentElement:',
parentElement?.id,
'dimensions:',
parentElement?.clientWidth,
'x',
parentElement?.clientHeight
);
if (!parentElement) {
console.error('[createEditor] ERROR: parentElement is null or undefined!');
return null;
}
const {
content = '',
onChange = () => {},
onUpdate = null,
isDark = false,
showLineNumbers = true,
vimMode = false,
getTabExpansion = null,
smartPasteFetcher = null,
} = options;
const extensions = [
markdown({ base: markdownLanguage }),
history(),
drawSelection(),
highlightActiveLine(),
bracketMatching(),
indentOnInput(),
highlightSelectionMatches(),
autocompletion(),
foldGutter(),
jetBrainsMonoTheme,
// Snippet Tab expansion must precede indentWithTab so it wins when matched
keymap.of([
...(getTabExpansion ? [snippetTabKeymap(getTabExpansion)] : []),
...defaultKeymap,
...historyKeymap,
...searchKeymap,
...completionKeymap,
indentWithTab,
]),
vimCompartment.of(vimMode ? getVimExtension() : []),
EditorView.updateListener.of((update) => {
if (update.docChanged) {
onChange(update.state.doc.toString());
}
if (onUpdate && (update.docChanged || update.selectionSet)) {
onUpdate(update.view);
}
}),
EditorView.lineWrapping,
];
// Smart-paste: a URL-only paste is intercepted and rewritten to a
// markdown link once the page title comes back from the main process.
// The host (renderer) injects the IPC-backed fetcher; null disables.
if (smartPasteFetcher) {
extensions.push(require('./smart-paste').smartPaste({ fetchTitle: smartPasteFetcher }));
}
if (showLineNumbers) {
extensions.push(lineNumbers());
}
if (isDark) {
extensions.push(oneDark);
}
const state = EditorState.create({ doc: content, extensions });
const view = new EditorView({ state, parent: parentElement });
return view;
}
/**
* Return the appropriate CodeMirror language extension for a given language name.
*
* Supported values: javascript, js, html, css, json, python, py, markdown.
* Falls back to markdown when the language is unrecognised.
*
* @param {string} lang - language identifier
* @returns {Extension} CodeMirror language extension
*/
function getLanguageExtension(lang) {
const loaders = {
javascript: () => {
if (!_javascript) _javascript = require('@codemirror/lang-javascript').javascript;
return _javascript();
},
html: () => {
if (!_html) _html = require('@codemirror/lang-html').html;
return _html();
},
css: () => {
if (!_css) _css = require('@codemirror/lang-css').css;
return _css();
},
json: () => {
if (!_json) _json = require('@codemirror/lang-json').json;
return _json();
},
python: () => {
if (!_python) _python = require('@codemirror/lang-python').python;
return _python();
},
markdown: () => markdown({ base: markdownLanguage }),
};
loaders.js = loaders.javascript;
loaders.py = loaders.python;
const loader = loaders[lang];
return loader ? loader() : markdown({ base: markdownLanguage });
}
module.exports = { createEditor, getLanguageExtension, setVimMode, snippetTabKeymap };