From 4c22413477937e3cac3c1b8ec207b98735acd57a Mon Sep 17 00:00:00 2001 From: konard Date: Mon, 3 Aug 2026 10:15:50 +0000 Subject: [PATCH 1/5] Initial commit with task details Adding .gitkeep for PR creation (default mode). This file will be removed when the task is complete. Issue: https://github.com/link-assistant/web-capture/issues/148 --- .gitkeep | 1 + 1 file changed, 1 insertion(+) create mode 100644 .gitkeep diff --git a/.gitkeep b/.gitkeep new file mode 100644 index 0000000..839c578 --- /dev/null +++ b/.gitkeep @@ -0,0 +1 @@ +# .gitkeep file auto-generated at 2026-08-03T10:15:50.269Z for PR creation at branch issue-148-ecaba4af8e3f for issue https://github.com/link-assistant/web-capture/issues/148 \ No newline at end of file From 707b1650ee9d3f95dd546c8c6e65d2d707a59742 Mon Sep 17 00:00:00 2001 From: konard Date: Mon, 3 Aug 2026 10:42:44 +0000 Subject: [PATCH 2/5] feat(rust): add minimal search feature --- .github/workflows/rust.yml | 6 ++ rust/Cargo.toml | 91 +++++++++++++++++++-------- rust/README.md | 18 ++++++ rust/src/lib.rs | 82 +++++++++++++++++++++--- rust/src/search.rs | 1 + rust/src/transport.rs | 5 ++ rust/tests/search_feature.rs | 60 ++++++++++++++++++ scripts/rust-check-search-feature.mjs | 54 ++++++++++++++++ 8 files changed, 283 insertions(+), 34 deletions(-) create mode 100644 rust/tests/search_feature.rs create mode 100644 scripts/rust-check-search-feature.mjs diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index d6ab42e..3685191 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -131,6 +131,12 @@ jobs: - name: Run Clippy run: cargo clippy --all-targets --all-features -- -D warnings + - name: Check minimal search feature dependency boundary + working-directory: . + run: | + cargo test --manifest-path rust/Cargo.toml --no-default-features --features search --test search_feature + node scripts/rust-check-search-feature.mjs + # Test matrix: Rust on multiple OS - only runs when Rust code changes test: name: Rust - Test (${{ matrix.os }}) diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 066af68..7549e2a 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -11,80 +11,121 @@ keywords = ["web", "capture", "screenshot", "markdown", "html"] categories = ["command-line-utilities", "web-programming"] rust-version = "1.96" +[features] +default = ["runtime"] +search = [ + "dep:html-escape", + "dep:scraper", + "dep:serde", + "dep:serde_json", + "dep:thiserror", + "dep:url", +] +runtime = [ + "search", + "dep:anyhow", + "dep:async-tungstenite", + "dep:axum", + "dep:base64", + "dep:browser-commander", + "dep:clap", + "dep:encoding_rs", + "dep:futures", + "dep:html-to-markdown-rs", + "dep:html2md", + "dep:lino-arguments", + "dep:regex", + "dep:reqwest", + "dep:tokio", + "dep:tower", + "dep:tower-http", + "dep:tracing", + "dep:tracing-subscriber", + "dep:zip", +] + [lib] path = "src/lib.rs" [[bin]] name = "web-capture" path = "src/main.rs" +required-features = ["runtime"] [[test]] name = "unit" path = "tests/unit/mod.rs" +required-features = ["runtime"] [[test]] name = "integration" path = "tests/integration/mod.rs" +required-features = ["runtime"] + +[[test]] +name = "search_feature" +path = "tests/search_feature.rs" +required-features = ["search"] [dependencies] # Browser automation using browser-commander from link-foundation -browser-commander = "0.9" -async-tungstenite = { version = "0.27", features = ["tokio-runtime"] } -futures = "0.3" +browser-commander = { version = "0.9", optional = true } +async-tungstenite = { version = "0.27", features = ["tokio-runtime"], optional = true } +futures = { version = "0.3", optional = true } # Unified configuration from CLI args, env vars, and .lenv files -lino-arguments = "0.3" +lino-arguments = { version = "0.3", optional = true } # Async runtime -tokio = { version = "1.0", features = ["full"] } +tokio = { version = "1.0", features = ["full"], optional = true } # Web framework -axum = "0.8" -tower = "0.5" -tower-http = { version = "0.6", features = ["cors", "trace"] } +axum = { version = "0.8", optional = true } +tower = { version = "0.5", optional = true } +tower-http = { version = "0.6", features = ["cors", "trace"], optional = true } # HTTP client -reqwest = { version = "0.12", features = ["cookies", "gzip"] } +reqwest = { version = "0.12", features = ["cookies", "gzip"], optional = true } # HTML parsing and manipulation -scraper = "0.21" +scraper = { version = "0.21", optional = true } # HTML to Markdown conversion -html2md = "0.2" -html-to-markdown-rs = { version = "3.6", features = ["inline-images", "metadata", "serde"] } +html2md = { version = "0.2", optional = true } +html-to-markdown-rs = { version = "3.6", features = ["inline-images", "metadata", "serde"], optional = true } # Command line argument parsing -clap = { version = "4.5", features = ["derive", "env"] } +clap = { version = "4.5", features = ["derive", "env"], optional = true } # Serialization -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" +serde = { version = "1.0", features = ["derive"], optional = true } +serde_json = { version = "1.0", optional = true } # Error handling -thiserror = "2.0" -anyhow = "1.0" +thiserror = { version = "2.0", optional = true } +anyhow = { version = "1.0", optional = true } # Logging -tracing = "0.1" -tracing-subscriber = { version = "0.3", features = ["env-filter"] } +tracing = { version = "0.1", optional = true } +tracing-subscriber = { version = "0.3", features = ["env-filter"], optional = true } # URL handling -url = "2.5" +url = { version = "2.5", optional = true } # Encoding -encoding_rs = "0.8" +encoding_rs = { version = "0.8", optional = true } # Base64 for image data -base64 = "0.22" +base64 = { version = "0.22", optional = true } # Regex for URL conversion -regex = "1.11" +regex = { version = "1.11", optional = true } # HTML entity decoding -html-escape = "0.2" +html-escape = { version = "0.2", optional = true } # ZIP archive creation -zip = { version = "4.0", default-features = false, features = ["deflate"] } +zip = { version = "4.0", default-features = false, features = ["deflate"], optional = true } [dev-dependencies] tokio-test = "0.4" diff --git a/rust/README.md b/rust/README.md index 4092178..3779bae 100644 --- a/rust/README.md +++ b/rust/README.md @@ -32,6 +32,24 @@ cd rust cargo build --release ``` +## Cargo Features + +The default `runtime` feature preserves the full CLI, HTTP server, HTTP client, +and browser capture behavior. Applications that only build provider URLs, +parse provider responses, or supply their own transport can disable default +features and select `search`: + +```toml +[dependencies] +web-capture = { version = "0.3", default-features = false, features = ["search"] } +``` + +The `search` feature exposes the pure URL/parser API and the caller-owned +transport contract, including `build_search_url`, `parse_search_results`, and +`search_with_transport`. It does not select `browser-commander`, `reqwest`, +Tokio, Axum, or OpenSSL. The convenience `search` function, which performs an +HTTP request with reqwest, remains part of `runtime`. + ## Quick Start ### CLI Usage diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 448baf2..451b197 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -10,11 +10,16 @@ //! - Convert relative URLs to absolute URLs //! - Support for headless browser rendering via browser-commander //! +//! The default `runtime` feature provides the complete CLI, server, browser, +//! and HTTP client. For transport-independent search URL construction and +//! response parsing only, disable default features and enable `search`. +//! //! ## Example //! //! ```rust,no_run -//! use web_capture::{fetch_html, convert_html_to_markdown, capture_screenshot}; -//! +//! # #[cfg(feature = "runtime")] +//! # use web_capture::{fetch_html, convert_html_to_markdown, capture_screenshot}; +//! # #[cfg(feature = "runtime")] //! #[tokio::main] //! async fn main() -> anyhow::Result<()> { //! // Fetch HTML from a URL @@ -31,37 +36,63 @@ //! //! Ok(()) //! } +//! # #[cfg(not(feature = "runtime"))] +//! # fn main() {} //! ``` +#[cfg(feature = "runtime")] pub mod animation; +#[cfg(feature = "runtime")] pub mod archive; +#[cfg(feature = "runtime")] pub mod batch; +#[cfg(feature = "runtime")] pub mod browser; +#[cfg(feature = "runtime")] pub mod extract_images; +#[cfg(feature = "runtime")] pub mod figures; +#[cfg(feature = "runtime")] pub mod gdocs; +#[cfg(feature = "runtime")] pub mod github; +#[cfg(feature = "runtime")] pub mod html; +#[cfg(feature = "runtime")] pub mod kreuzberg; +#[cfg(feature = "runtime")] pub mod latex; +#[cfg(feature = "runtime")] pub mod localize_images; +#[cfg(feature = "runtime")] pub mod markdown; +#[cfg(feature = "runtime")] pub mod metadata; +#[cfg(feature = "runtime")] pub mod postprocess; +#[cfg(feature = "search")] pub mod search; +#[cfg(feature = "runtime")] pub mod shared_dialog; +#[cfg(feature = "runtime")] pub mod stackoverflow; +#[cfg(feature = "runtime")] pub mod themed_image; +#[cfg(feature = "search")] pub mod transport; +#[cfg(feature = "runtime")] pub mod verify; +#[cfg(feature = "runtime")] pub mod xpaste; -use thiserror::Error; - /// Version of the web-capture library pub const VERSION: &str = env!("CARGO_PKG_VERSION"); +#[cfg(feature = "runtime")] +use thiserror::Error; + /// Error types for web-capture operations +#[cfg(feature = "runtime")] #[derive(Error, Debug)] pub enum WebCaptureError { #[error("Failed to fetch URL: {0}")] @@ -90,6 +121,7 @@ pub enum WebCaptureError { } /// Result type for web-capture operations +#[cfg(feature = "runtime")] pub type Result = std::result::Result; /// Fetch HTML content from a URL @@ -108,11 +140,13 @@ pub type Result = std::result::Result; /// # Errors /// /// Returns an error if the fetch fails or the response cannot be decoded +#[cfg(feature = "runtime")] pub async fn fetch_html(url: &str) -> Result { html::fetch_html(url).await } /// Fetch an undecoded HTML response receipt through caller-owned transport. +#[cfg(feature = "runtime")] pub async fn fetch_html_receipt_with_transport( url: &str, transport: &dyn transport::Transport, @@ -121,6 +155,7 @@ pub async fn fetch_html_receipt_with_transport( } /// Fetch an undecoded HTML response receipt through the default transport. +#[cfg(feature = "runtime")] pub async fn fetch_html_receipt( url: &str, ) -> std::result::Result { @@ -143,6 +178,7 @@ pub async fn fetch_html_receipt( /// # Errors /// /// Returns an error if browser operations fail +#[cfg(feature = "runtime")] pub async fn render_html(url: &str) -> Result { browser::render_html(url).await } @@ -161,6 +197,7 @@ pub async fn render_html(url: &str) -> Result { /// # Errors /// /// Returns an error if conversion fails +#[cfg(feature = "runtime")] pub fn convert_html_to_markdown(html: &str, base_url: Option<&str>) -> Result { markdown::convert_html_to_markdown(html, base_url) } @@ -181,6 +218,7 @@ pub fn convert_html_to_markdown(html: &str, base_url: Option<&str>) -> Result Result> { browser::capture_screenshot(url).await } @@ -195,6 +233,7 @@ pub async fn capture_screenshot(url: &str) -> Result> { /// # Returns /// /// The HTML content with absolute URLs +#[cfg(feature = "runtime")] #[must_use] pub fn convert_relative_urls(html: &str, base_url: &str) -> String { html::convert_relative_urls(html, base_url) @@ -211,12 +250,14 @@ pub fn convert_relative_urls(html: &str, base_url: &str) -> String { /// # Returns /// /// The UTF-8 encoded HTML content +#[cfg(feature = "runtime")] #[must_use] pub fn convert_to_utf8(html: &str) -> String { html::convert_to_utf8(html) } /// Options for enhanced HTML-to-Markdown conversion. +#[cfg(feature = "runtime")] #[allow(clippy::struct_excessive_bools)] #[derive(Debug, Clone)] pub struct EnhancedOptions { @@ -234,6 +275,7 @@ pub struct EnhancedOptions { pub body_selector: Option, } +#[cfg(feature = "runtime")] impl Default for EnhancedOptions { fn default() -> Self { Self { @@ -248,6 +290,7 @@ impl Default for EnhancedOptions { } /// Result of enhanced HTML-to-Markdown conversion. +#[cfg(feature = "runtime")] #[derive(Debug, Clone)] pub struct EnhancedMarkdownResult { pub markdown: String, @@ -272,6 +315,7 @@ pub struct EnhancedMarkdownResult { /// # Errors /// /// Returns an error if base conversion fails +#[cfg(feature = "runtime")] pub fn convert_html_to_markdown_enhanced( html: &str, base_url: Option<&str>, @@ -348,6 +392,7 @@ pub fn convert_html_to_markdown_enhanced( /// # Errors /// /// Returns an error if conversion fails +#[cfg(feature = "runtime")] pub fn convert_with_kreuzberg( html: &str, base_url: Option<&str>, @@ -363,6 +408,7 @@ pub fn convert_with_kreuzberg( /// # Errors /// /// Returns an error if conversion fails. +#[cfg(feature = "runtime")] pub fn convert_with_kreuzberg_enhanced( html: &str, base_url: Option<&str>, @@ -372,6 +418,7 @@ pub fn convert_with_kreuzberg_enhanced( kreuzberg::convert_with_kreuzberg(&scoped_html, base_url) } +#[cfg(feature = "runtime")] fn normalize_extracted_latex_markdown(markdown: &str) -> String { let re = regex::Regex::new(r"\$([^$\n]+)\$").expect("valid regex"); re.replace_all(markdown, |caps: ®ex::Captures<'_>| { @@ -381,6 +428,7 @@ fn normalize_extracted_latex_markdown(markdown: &str) -> String { .into_owned() } +#[cfg(feature = "runtime")] fn scope_html_with_selectors(html: &str, options: &EnhancedOptions) -> String { if let Some(body_selector) = options.body_selector.as_deref() { let body_html = markdown::select_html(html, body_selector); @@ -403,6 +451,7 @@ fn scope_html_with_selectors(html: &str, options: &EnhancedOptions) -> String { .unwrap_or_else(|| html.to_string()) } +#[cfg(feature = "runtime")] fn replace_latex_formula_elements(html: &str) -> String { let mut result = html.to_string(); @@ -455,6 +504,7 @@ fn replace_latex_formula_elements(html: &str) -> String { .into_owned() } +#[cfg(feature = "runtime")] fn correct_code_languages(html: &str) -> String { let code_re = regex::Regex::new(r"(?is)[^>]*)>(?P.*?)") .expect("valid regex"); @@ -479,11 +529,13 @@ fn correct_code_languages(html: &str) -> String { .into_owned() } +#[cfg(feature = "runtime")] fn is_formula_img_tag(tag: &str) -> bool { extract_attr(tag, "source").is_some() || extract_attr(tag, "class").is_some_and(|classes| classes.contains("formula")) } +#[cfg(feature = "runtime")] fn is_math_attrs(tag: &str, attrs: &str) -> bool { tag == "mjx-container" || extract_attr(attrs, "class").is_some_and(|classes| { @@ -491,6 +543,7 @@ fn is_math_attrs(tag: &str, attrs: &str) -> bool { }) } +#[cfg(feature = "runtime")] fn has_matlab_language(attrs: &str) -> bool { extract_attr(attrs, "class").is_some_and(|classes| { classes @@ -499,6 +552,7 @@ fn has_matlab_language(attrs: &str) -> bool { }) } +#[cfg(feature = "runtime")] fn looks_like_coq(text: &str) -> bool { let decoded = crate::html::decode_html_entities(text); [ @@ -516,10 +570,12 @@ fn looks_like_coq(text: &str) -> bool { .any(|needle| decoded.contains(needle)) } +#[cfg(feature = "runtime")] fn normalize_latex_for_html(latex: &str) -> String { latex.trim().replace('\\', "\") } +#[cfg(feature = "runtime")] fn extract_annotation_tex(html: &str) -> Option { let re = regex::Regex::new( r#"(?is)]*encoding\s*=\s*["']application/x-tex["'][^>]*>(.*?)"#, @@ -532,6 +588,7 @@ fn extract_annotation_tex(html: &str) -> Option { }) } +#[cfg(feature = "runtime")] fn extract_attr(tag: &str, attr: &str) -> Option { let re = regex::Regex::new(&format!( r#"(?is)\b{}\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+))"#, @@ -550,13 +607,20 @@ fn extract_attr(tag: &str, attr: &str) -> Option { }) } -// Re-export commonly used types +#[cfg(feature = "runtime")] pub use browser::BrowserEngine; +#[cfg(feature = "runtime")] +pub use search::search; +#[cfg(feature = "search")] pub use search::{ - search, search_with_transport, SearchCapture, SearchDiagnostics, SearchResult, - SearchResultItem, DEFAULT_LIMIT, DEFAULT_PROVIDER, SEARCH_PROVIDERS, + build_search_url, format_search_as_markdown, is_supported_provider, parse_search_results, + search_with_transport, SearchCapture, SearchDiagnostics, SearchResult, SearchResultItem, + DEFAULT_LIMIT, DEFAULT_PROVIDER, SEARCH_PROVIDERS, }; +#[cfg(feature = "runtime")] +pub use transport::{capture_response, ReqwestTransport}; +#[cfg(feature = "search")] pub use transport::{ - capture_response, capture_response_with_transport, ReqwestTransport, ResponseReceipt, - Transport, TransportDiagnostics, TransportError, TransportRequest, RECEIPT_HEADERS, + capture_response_with_transport, ResponseReceipt, Transport, TransportDiagnostics, + TransportError, TransportRequest, RECEIPT_HEADERS, }; diff --git a/rust/src/search.rs b/rust/src/search.rs index 2347b1e..80bf737 100644 --- a/rust/src/search.rs +++ b/rust/src/search.rs @@ -393,6 +393,7 @@ pub fn format_search_as_markdown(result: &SearchResult) -> String { /// # Errors /// /// Returns an error string for an empty query or unsupported provider. +#[cfg(feature = "runtime")] pub async fn search( query: &str, provider: &str, diff --git a/rust/src/transport.rs b/rust/src/transport.rs index 6afab62..b25382e 100644 --- a/rust/src/transport.rs +++ b/rust/src/transport.rs @@ -82,11 +82,13 @@ where } /// Default reqwest implementation. Callers may supply an already configured client. +#[cfg(feature = "runtime")] #[derive(Debug, Clone)] pub struct ReqwestTransport { client: reqwest::Client, } +#[cfg(feature = "runtime")] impl ReqwestTransport { #[must_use] pub const fn new(client: reqwest::Client) -> Self { @@ -94,12 +96,14 @@ impl ReqwestTransport { } } +#[cfg(feature = "runtime")] impl Default for ReqwestTransport { fn default() -> Self { Self::new(reqwest::Client::new()) } } +#[cfg(feature = "runtime")] impl Transport for ReqwestTransport { fn execute(&self, request: TransportRequest) -> TransportFuture<'_> { Box::pin(async move { @@ -168,6 +172,7 @@ pub async fn capture_response_with_transport( } /// Capture an HTTP response with the default reqwest transport. +#[cfg(feature = "runtime")] pub async fn capture_response( request: TransportRequest, ) -> std::result::Result { diff --git a/rust/tests/search_feature.rs b/rust/tests/search_feature.rs new file mode 100644 index 0000000..ae04668 --- /dev/null +++ b/rust/tests/search_feature.rs @@ -0,0 +1,60 @@ +//! Smoke test for the minimal, transport-independent search feature. + +use std::collections::BTreeMap; + +use web_capture::{ + build_search_url, parse_search_results, search_with_transport, ResponseReceipt, + TransportDiagnostics, TransportRequest, +}; + +#[test] +fn pure_search_api_builds_urls_and_parses_caller_owned_responses() { + let source_url = build_search_url("wikipedia", "formal methods", 1).unwrap(); + assert_eq!( + source_url, + "https://en.wikipedia.org/w/rest.php/v1/search/page?q=formal+methods&limit=1" + ); + + let receipt = ResponseReceipt { + body: br#"{"pages":[{"id":1,"key":"Formal_methods","title":"Formal methods","excerpt":"rigorous techniques","description":null}]}"#.to_vec(), + final_url: source_url, + status: 200, + headers: BTreeMap::new(), + diagnostics: TransportDiagnostics::response(), + }; + let body = String::from_utf8(receipt.body).unwrap(); + let (results, blocked) = parse_search_results("wikipedia", &body, 1); + + assert!(!blocked); + assert_eq!(results.len(), 1); + assert_eq!(results[0].title, "Formal methods"); + + // Caller-owned transport types stay available without selecting a runtime. + let request = TransportRequest { + url: receipt.final_url, + method: "GET".into(), + headers: BTreeMap::new(), + }; + assert_eq!(request.method, "GET"); + + let transport_receipt = ResponseReceipt { + body: br#"{"pages":[]}"#.to_vec(), + final_url: request.url, + status: 200, + headers: BTreeMap::new(), + diagnostics: TransportDiagnostics::response(), + }; + let transport = move |_request: TransportRequest| { + let response = transport_receipt.clone(); + async move { Ok(response) } + }; + let search = search_with_transport( + "formal methods", + "wikipedia", + 1, + "test", + "2026-08-03T00:00:00Z", + &transport, + ); + drop(search); +} diff --git a/scripts/rust-check-search-feature.mjs b/scripts/rust-check-search-feature.mjs new file mode 100644 index 0000000..9ccb332 --- /dev/null +++ b/scripts/rust-check-search-feature.mjs @@ -0,0 +1,54 @@ +#!/usr/bin/env node + +import { execFileSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; + +const repositoryRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "..", +); +const rustDirectory = path.join(repositoryRoot, "rust"); +const forbiddenPackages = [ + "axum", + "browser-commander", + "openssl-sys", + "reqwest", + "tokio", +]; + +const tree = execFileSync( + "cargo", + [ + "tree", + "--manifest-path", + path.join(rustDirectory, "Cargo.toml"), + "--no-default-features", + "--features", + "search", + "--edges", + "normal", + "--prefix", + "none", + ], + { encoding: "utf8" }, +); + +const selectedPackages = new Set( + tree + .split("\n") + .map((line) => line.match(/^([^\s]+) v\d/iu)?.[1]) + .filter(Boolean), +); +const found = forbiddenPackages.filter((name) => selectedPackages.has(name)); + +if (found.length > 0) { + console.error( + `Minimal Rust search feature selected forbidden packages: ${found.join(", ")}`, + ); + process.exit(1); +} + +console.log( + "Minimal Rust search feature excludes browser, server, HTTP, async runtime, and OpenSSL packages.", +); From 069d6fc131f251e696421acb96d0add3c64750c2 Mon Sep 17 00:00:00 2001 From: konard Date: Mon, 3 Aug 2026 10:44:03 +0000 Subject: [PATCH 3/5] chore(rust): sync lockfile version --- rust/Cargo.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 3ce1f52..ea86fb5 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -3593,7 +3593,7 @@ dependencies = [ [[package]] name = "web-capture" -version = "0.3.34" +version = "0.3.35" dependencies = [ "anyhow", "async-tungstenite", From abdc7e8f048544630cd19f5e54351e3a6c238bcf Mon Sep 17 00:00:00 2001 From: konard Date: Mon, 3 Aug 2026 10:44:27 +0000 Subject: [PATCH 4/5] Revert "Initial commit with task details" This reverts commit 4c22413477937e3cac3c1b8ec207b98735acd57a. --- .gitkeep | 1 - 1 file changed, 1 deletion(-) delete mode 100644 .gitkeep diff --git a/.gitkeep b/.gitkeep deleted file mode 100644 index 839c578..0000000 --- a/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -# .gitkeep file auto-generated at 2026-08-03T10:15:50.269Z for PR creation at branch issue-148-ecaba4af8e3f for issue https://github.com/link-assistant/web-capture/issues/148 \ No newline at end of file From 4922a6a66932d0a7bc385ee32b00d29cb2038135 Mon Sep 17 00:00:00 2001 From: konard Date: Mon, 3 Aug 2026 10:53:30 +0000 Subject: [PATCH 5/5] test(js): cover transport-independent search adapters --- js/tests/unit/search.test.js | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/js/tests/unit/search.test.js b/js/tests/unit/search.test.js index 78dc8cb..072db89 100644 --- a/js/tests/unit/search.test.js +++ b/js/tests/unit/search.test.js @@ -76,6 +76,25 @@ describe('search module (#130)', () => { }); }); + it('keeps URL construction and response parsing transport-independent', () => { + const sourceUrl = buildSearchUrl('wikipedia', 'formal ai', 1); + const { results } = parseSearchResults('wikipedia', WIKI_JSON, { + limit: 1, + }); + + expect(sourceUrl).toBe( + 'https://en.wikipedia.org/w/rest.php/v1/search/page?q=formal%20ai&limit=1' + ); + expect(results).toEqual([ + { + rank: 1, + title: 'Formal methods', + url: 'https://en.wikipedia.org/wiki/Formal_methods', + snippet: 'the study of formal', + }, + ]); + }); + describe('parseSearchResults', () => { it('normalizes Wikipedia REST JSON and strips markup', () => { const { results } = parseSearchResults('wikipedia', WIKI_JSON, {