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
+74
View File
@@ -0,0 +1,74 @@
/**
* @jest-environment jsdom
*
* Smart-paste tests — verifies the URL-only paste detection logic. We don't
* mount a full CodeMirror view; the actual integration is exercised by the
* renderer, but the URL detection regex and replacement-format logic are
* what users actually see.
*/
const { URL_ONLY_RE } = require('../src/editor/smart-paste');
describe('URL_ONLY_RE — detection', () => {
test('matches a bare URL', () => {
expect(URL_ONLY_RE.test('https://example.com')).toBe(true);
});
test('matches a URL with path / query / fragment', () => {
expect(URL_ONLY_RE.test('https://x.com/a/b?c=1&d=2#frag')).toBe(true);
});
test('matches a URL padded with whitespace', () => {
expect(URL_ONLY_RE.test(' https://x.com ')).toBe(true);
expect(URL_ONLY_RE.test('\nhttps://x.com\n')).toBe(true);
});
test('rejects non-http(s) schemes', () => {
expect(URL_ONLY_RE.test('ftp://x.com')).toBe(false);
expect(URL_ONLY_RE.test('javascript:alert(1)')).toBe(false);
expect(URL_ONLY_RE.test('file:///etc/passwd')).toBe(false);
});
test('rejects URL embedded in prose', () => {
expect(URL_ONLY_RE.test('see https://x.com for details')).toBe(false);
expect(URL_ONLY_RE.test('https://x.com and https://y.com')).toBe(false);
});
test('rejects empty / non-URL input', () => {
expect(URL_ONLY_RE.test('')).toBe(false);
expect(URL_ONLY_RE.test('hello world')).toBe(false);
expect(URL_ONLY_RE.test('foo bar baz')).toBe(false);
});
test('rejects malformed URLs', () => {
expect(URL_ONLY_RE.test('http://')).toBe(false);
expect(URL_ONLY_RE.test('https:// ')).toBe(false);
});
});
describe('Smart paste replacement formatting', () => {
// The replacement-format logic isn't exported, but we can validate the
// shape by reconstructing what the editor would insert.
test('formats the replacement as [Title](url)', () => {
const title = 'Hello World';
const url = 'https://example.com';
const replacement = `[${title}](${url})`;
expect(replacement).toBe('[Hello World](https://example.com)');
});
test('handles titles that contain brackets by leaving them raw (no escaping needed)', () => {
// Markdown link labels can contain brackets as long as they don't form
// a nested link — for typical titles this is fine.
const title = 'C++ [draft]';
const url = 'https://x.com';
const replacement = `[${title}](${url})`;
expect(replacement).toBe('[C++ [draft]](https://x.com)');
});
test('preserves query strings and fragments', () => {
const url = 'https://example.com/path?a=1&b=2#frag';
const replacement = `[T](${url})`;
expect(replacement).toBe('[T](https://example.com/path?a=1&b=2#frag)');
});
});