mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-10-01 09:19:34 +05:30
- src/main/UrlTitle.js — fetch a URL, return its <title>. Pure module
with injectable fetch for tests. Decodes named + numeric + hex entities
(AT&T, Café, —), 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
220 lines
7.1 KiB
JavaScript
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 };
|