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
This commit is contained in:
2026-09-14 08:58:32 +05:30
parent 6d08c138d8
commit bd86748c47
9 changed files with 533 additions and 0 deletions
+8
View File
@@ -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());
}
+73
View File
@@ -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 };