forked from akai/readest
a1279a65ce
Builds the URL-clipping path of the "Send to Readest" feature: paste a
link, the renderer ingests the rendered page, and a self-contained EPUB
lands in the library. No server proxy, no external CDN refs left in the
EPUB once it's saved.
Architecture
- New Rust `clip_url` command spawns a hidden Tauri WebviewWindow at the
target URL with a real Chrome UA + WebKit fingerprint mask, so TLS-
fingerprint and JS-challenge walls (Cloudflare, Medium, X, WeChat MP)
resolve naturally instead of bouncing the server proxy.
- Capture transport is URL-payload navigation to a one-shot
127.0.0.1:RANDOM_PORT/clip/{token}?d={url-safe-base64} listener.
Top-level navigation isn't governed by CSP connect-src / form-action /
WebKit Private Network Access — the four earlier transports
(fetch, <form>, custom URI scheme, window.name) were each blocked by
one of those.
- Page-to-EPUB bundler (`assetBundler`) walks <img>/<picture> with
src → data-src → data-original → data-srcset → srcset fallback so lazy-
loading sites don't ship a 60px LQIP; fetches assets in parallel with a
per-asset timeout + per-asset/total caps; failed images degrade to alt-
text placeholders. A per-site rules table (seeded with WeChat MP) + a
selector fallback catches articles Readability misextracts. Builder
prepends the article <h1> + byline so the EPUB has a proper opening.
- Nested EPUB TOC built from h1–h6.
UI surfaces
- "From Web URL" entry in the library Import menu, gated to Tauri; web
build hides the URL field and points at the browser extension.
- `ImportFromUrlDialog` with auto-height (overrides Dialog's `sm:h-[65%]`
default) and a dim placeholder for the URL field.
- Clip webview window styled to match Readest's main window — macOS
decorations + overlay title bar; other desktops decorationless with a
drop shadow; native background + in-page loading overlay pick up the
caller's `themeCode.bg`/`fg` so light/dark/eink/custom themes all
render correctly. Title localised, all five overlay/title strings
translated across 33 locales.
Notes
- Gates the macOS traffic-light positioner to main/reader-* windows so
the decorationless clip window no longer null-derefs in
`position_traffic_lights`.
- Stricter validation across the path: schemes restricted to http/https,
hex-color parsing rejects malformed values, server endpoint returns
400 on missing/invalid base64.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
130 lines
4.4 KiB
TypeScript
130 lines
4.4 KiB
TypeScript
import { describe, expect, test } from 'vitest';
|
||
import { buildNavMap, extractHeadings } from '@/services/send/conversion/toc';
|
||
|
||
const ESC = (s: string) => s.replace(/&/g, '&').replace(/</g, '<').replace(/"/g, '"');
|
||
|
||
describe('extractHeadings', () => {
|
||
test('returns h1–h6 in document order with their level', () => {
|
||
const { headings } = extractHeadings(`
|
||
<h1>Intro</h1>
|
||
<p>...</p>
|
||
<h2>Setup</h2>
|
||
<h3>Install</h3>
|
||
<h2>Usage</h2>
|
||
`);
|
||
expect(headings.map((h) => [h.level, h.text])).toEqual([
|
||
[1, 'Intro'],
|
||
[2, 'Setup'],
|
||
[3, 'Install'],
|
||
[2, 'Usage'],
|
||
]);
|
||
});
|
||
|
||
test('slugifies heading text into ids and writes them onto the elements', () => {
|
||
const { html, headings } = extractHeadings(`<h2>Hello, world!</h2>`);
|
||
expect(headings[0]!.id).toBe('hello-world');
|
||
expect(html).toContain('id="hello-world"');
|
||
});
|
||
|
||
test('preserves an existing id verbatim instead of slugging', () => {
|
||
const { headings, html } = extractHeadings(`<h2 id="custom-anchor">Section</h2>`);
|
||
expect(headings[0]!.id).toBe('custom-anchor');
|
||
expect(html).toContain('id="custom-anchor"');
|
||
});
|
||
|
||
test('de-duplicates colliding slugs with -2, -3, …', () => {
|
||
const { headings, html } = extractHeadings(`
|
||
<h2>Intro</h2>
|
||
<h2>Intro</h2>
|
||
<h2>Intro</h2>
|
||
`);
|
||
expect(headings.map((h) => h.id)).toEqual(['intro', 'intro-2', 'intro-3']);
|
||
expect((html.match(/id="intro"/g) || []).length).toBe(1);
|
||
expect((html.match(/id="intro-2"/g) || []).length).toBe(1);
|
||
expect((html.match(/id="intro-3"/g) || []).length).toBe(1);
|
||
});
|
||
|
||
test('falls back to h-N when the heading text strips to nothing (CJK, emoji)', () => {
|
||
const { headings } = extractHeadings(`<h2>简介</h2><h2>🚀</h2>`);
|
||
expect(headings[0]!.id).toBe('h-1');
|
||
expect(headings[1]!.id).toBe('h-2');
|
||
});
|
||
|
||
test('skips empty headings entirely (no id, no toc entry)', () => {
|
||
const { headings } = extractHeadings(`<h2></h2><h2> </h2><h2>Real</h2>`);
|
||
expect(headings).toHaveLength(1);
|
||
expect(headings[0]!.text).toBe('Real');
|
||
});
|
||
});
|
||
|
||
describe('buildNavMap', () => {
|
||
test('nests deeper levels under shallower ones', () => {
|
||
const xml = buildNavMap(
|
||
[
|
||
{ id: 'intro', text: 'Intro', level: 1 },
|
||
{ id: 'setup', text: 'Setup', level: 2 },
|
||
{ id: 'install', text: 'Install', level: 3 },
|
||
{ id: 'usage', text: 'Usage', level: 2 },
|
||
],
|
||
'OEBPS/chapter1.xhtml',
|
||
ESC,
|
||
);
|
||
// Intro contains Setup, Setup contains Install, then Usage is a sibling of Setup.
|
||
expect(xml).toContain('<navPoint id="np1"');
|
||
expect(xml).toContain('<navPoint id="np2"');
|
||
expect(xml).toContain('<navPoint id="np3"');
|
||
expect(xml).toContain('<navPoint id="np4"');
|
||
// Install is closed before Usage opens (sibling of Setup).
|
||
const installIdx = xml.indexOf('install');
|
||
const usageIdx = xml.indexOf('usage');
|
||
expect(installIdx).toBeLessThan(usageIdx);
|
||
const installClose = xml.indexOf('</navPoint>', installIdx);
|
||
expect(installClose).toBeLessThan(usageIdx);
|
||
});
|
||
|
||
test('uses sequential playOrder starting at 1', () => {
|
||
const xml = buildNavMap(
|
||
[
|
||
{ id: 'a', text: 'A', level: 2 },
|
||
{ id: 'b', text: 'B', level: 2 },
|
||
{ id: 'c', text: 'C', level: 2 },
|
||
],
|
||
'OEBPS/chapter1.xhtml',
|
||
ESC,
|
||
);
|
||
expect(xml).toContain('playOrder="1"');
|
||
expect(xml).toContain('playOrder="2"');
|
||
expect(xml).toContain('playOrder="3"');
|
||
expect(xml).not.toContain('playOrder="4"');
|
||
});
|
||
|
||
test('emits the chapter#fragment link for each entry', () => {
|
||
const xml = buildNavMap(
|
||
[{ id: 'intro', text: 'Intro', level: 2 }],
|
||
'OEBPS/chapter1.xhtml',
|
||
ESC,
|
||
);
|
||
expect(xml).toContain('<content src="OEBPS/chapter1.xhtml#intro"/>');
|
||
});
|
||
|
||
test('returns empty string when given no headings', () => {
|
||
expect(buildNavMap([], 'OEBPS/chapter1.xhtml', ESC)).toBe('');
|
||
});
|
||
|
||
test('balances opens and closes so the navMap parses', () => {
|
||
const xml = buildNavMap(
|
||
[
|
||
{ id: 'a', text: 'A', level: 1 },
|
||
{ id: 'b', text: 'B', level: 3 }, // skip level 2
|
||
{ id: 'c', text: 'C', level: 2 },
|
||
{ id: 'd', text: 'D', level: 4 },
|
||
],
|
||
'OEBPS/chapter1.xhtml',
|
||
ESC,
|
||
);
|
||
const opens = (xml.match(/<navPoint/g) || []).length;
|
||
const closes = (xml.match(/<\/navPoint>/g) || []).length;
|
||
expect(opens).toBe(closes);
|
||
});
|
||
});
|