mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-10-01 17:29:29 +05:30
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&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
This commit is contained in:
@@ -121,6 +121,7 @@ function createEditor(parentElement, options = {}) {
|
||||
showLineNumbers = true,
|
||||
vimMode = false,
|
||||
getTabExpansion = null,
|
||||
smartPasteFetcher = null,
|
||||
} = options;
|
||||
|
||||
const extensions = [
|
||||
@@ -155,6 +156,13 @@ function createEditor(parentElement, options = {}) {
|
||||
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());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* CodeMirror 6 smart-paste extension.
|
||||
*
|
||||
* When the pasted text is a single URL (or a URL surrounded only by
|
||||
* whitespace), intercept it and ask the main process for the page title
|
||||
* via the `url-title:fetch` IPC channel. Replace the paste with a
|
||||
* markdown link "[Title](url)" if a title is found, or leave the URL
|
||||
* untouched if the fetch fails or returns no title.
|
||||
*
|
||||
* Other paste content passes through unchanged — we never want to mangle
|
||||
* a multi-line paste just because one token looks URL-ish.
|
||||
*
|
||||
* @param {object} deps
|
||||
* @param {(args:{url:string,timeoutMs?:number}) => Promise<{url:string,title:string}|null>} deps.fetchTitle
|
||||
* @returns {import('@codemirror/view').Extension}
|
||||
*/
|
||||
const { EditorView } = require('@codemirror/view');
|
||||
|
||||
const URL_ONLY_RE = /^\s*(https?:\/\/[^\s]+)\s*$/i;
|
||||
const TIMEOUT_MS = 4000;
|
||||
|
||||
function smartPaste(deps) {
|
||||
const { fetchTitle } = deps || {};
|
||||
if (typeof fetchTitle !== 'function') {
|
||||
// No-op extension if no IPC bridge was passed in
|
||||
return [];
|
||||
}
|
||||
return EditorView.domEventHandlers({
|
||||
paste(event, view) {
|
||||
const text = event.clipboardData && event.clipboardData.getData('text/plain');
|
||||
const match = text && URL_ONLY_RE.exec(text);
|
||||
if (!match) return; // not a URL-only paste — let the default handler run
|
||||
|
||||
const url = match[1];
|
||||
event.preventDefault();
|
||||
|
||||
// Insert the URL immediately so the paste isn't lost on slow networks,
|
||||
// then async-fetch the title and rewrite the just-pasted range.
|
||||
const head = view.state.selection.main.head;
|
||||
const from = head;
|
||||
|
||||
view.dispatch({
|
||||
changes: { from, insert: url },
|
||||
selection: { anchor: from + url.length },
|
||||
});
|
||||
|
||||
// Best-effort fetch; if it fails, leave the URL as-is.
|
||||
Promise.race([
|
||||
fetchTitle({ url, timeoutMs: TIMEOUT_MS }),
|
||||
new Promise((resolve) => setTimeout(() => resolve(null), TIMEOUT_MS)),
|
||||
])
|
||||
.then((result) => {
|
||||
if (!result || !result.title) return;
|
||||
// The user may have continued typing in the meantime. Cap the
|
||||
// rewrite at the original insertion length so we don't clobber
|
||||
// anything else.
|
||||
const currentLen = view.state.doc.length;
|
||||
const rewriteTo = Math.min(from + url.length, currentLen);
|
||||
if (rewriteTo <= from) return;
|
||||
const replacement = `[${result.title}](${url})`;
|
||||
view.dispatch({
|
||||
changes: { from, to: rewriteTo, insert: replacement },
|
||||
selection: { anchor: from + replacement.length },
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
/* leave URL as-is */
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { smartPaste, URL_ONLY_RE };
|
||||
Reference in New Issue
Block a user