From 8725f26631f1d5e731d2a9110db81643c13cb5fa Mon Sep 17 00:00:00 2001 From: boon Date: Wed, 19 Aug 2026 11:46:05 +0800 Subject: [PATCH] Support image in link --- src/plugins/kagi_plugins/image.rs | 55 +++++++++++++++++-------------- src/plugins/kagi_plugins/link.rs | 42 +++++++++++++++-------- tests/test_kagi.py | 19 +++++++++++ 3 files changed, 78 insertions(+), 38 deletions(-) diff --git a/src/plugins/kagi_plugins/image.rs b/src/plugins/kagi_plugins/image.rs index eb4e253..f4249ca 100644 --- a/src/plugins/kagi_plugins/image.rs +++ b/src/plugins/kagi_plugins/image.rs @@ -31,36 +31,43 @@ impl NodeValue for Image { } } +/// Parse `![alt](url)` at the start of `input` into an [`Image`] node and the +/// number of bytes it consumes. +/// +/// Shared with the link scanner: an image used as a link's text +/// (`[![alt](url)](href)`) has to become a real child node, because a link +/// renders its text as escaped characters and would otherwise print the raw +/// image markdown. +pub(super) fn parse_image(input: &str) -> Option<(Node, usize)> { + if !input.starts_with("![") { + return None; + } + // Match the `[...](...)` portion starting right after the `!`. LINK_MD_PATTERN + // is anchored with `^`, so we pass `&input[1..]` to align the anchor with the + // `[`. `!` is ASCII, so slicing by 1 is safe. + let caps = LINK_MD_PATTERN.captures(&input[1..])?; + let complete_match = &caps[0]; + let link_text = caps.name("link_text").map(|m| m.as_str().to_string())?; + let link_text = decode_html_entities(&link_text).to_string(); + let url = caps + .name("url") + .map(|m| decode_html_entities(m.as_str()).to_string()); + + Some(( + Node::new(Image {url, title: link_text}), + // NOTE(Rehan): + 1 for exclamation mark + // trim end to not replace trailing newline + 1 + complete_match.trim_end().len(), + )) +} + struct ImageScanner; impl InlineRule for ImageScanner { const MARKER: char = '!'; fn run(state: &mut InlineState) -> Option<(Node, usize)> { - let input = &state.src[state.pos..state.pos_max]; - if !input.starts_with("![") { - return None; - } - // Match the `[...](...)` portion starting right after the `!`. LINK_MD_PATTERN - // is anchored with `^`, so we pass `&input[1..]` to align the anchor with the - // `[`. `!` is ASCII, so slicing by 1 is safe. - if let Some(caps) = LINK_MD_PATTERN.captures(&input[1..]) { - let complete_match = &caps[0]; - let link_text = caps.name("link_text").map(|m| m.as_str().to_string())?; - let link_text = decode_html_entities(&link_text).to_string(); - let url = caps - .name("url") - .map(|m| decode_html_entities(m.as_str()).to_string()); - - Some(( - Node::new(Image {url, title: link_text}), - // NOTE(Rehan): + 1 for exclamation mark - // trim end to not replace trailing newline - 1 + complete_match.trim_end().len(), - )) - } else { - None - } + parse_image(&state.src[state.pos..state.pos_max]) } } diff --git a/src/plugins/kagi_plugins/link.rs b/src/plugins/kagi_plugins/link.rs index 7359215..e914198 100644 --- a/src/plugins/kagi_plugins/link.rs +++ b/src/plugins/kagi_plugins/link.rs @@ -11,6 +11,7 @@ use once_cell::sync::Lazy; use regex::Regex; use url::Url; +use super::image::parse_image; use super::is_url_to_be_proxied; /// Parse Youtube ID from url @@ -30,10 +31,13 @@ pub static LINK_MD_PATTERN: Lazy = Lazy::new(|| { // byte length of the later match and the caller would advance `state.pos` into // the middle of a multi-byte character sitting between the two brackets, // panicking with "byte index N is not a char boundary". + // NOTE(Boon): the `!\[..\](..)` alternative must come first. `link_text` is + // otherwise lazy and stops at the inner image's `]`, so `[![alt](img)](href)` + // would match only `[![alt](img)` and render the leftovers as text. Regex::new( r"(?x) ^\[ - (?P.*?) + (?P!\[[^\]]*\]\([^)]*\)|.*?) \] (?P\() (?P[^)]*) @@ -156,27 +160,37 @@ impl InlineRule for LinkScanner { let config = state.md.ext.get::().unwrap(); if let Some(caps) = LINK_MD_PATTERN.captures(input) { let complete_match = &caps[0]; - let link_text = caps - .name("link_text") - .map(|m| decode_html_entities(m.as_str()).to_string())?; + let raw_link_text = caps.name("link_text").map(|m| m.as_str())?; + let link_text = decode_html_entities(raw_link_text).to_string(); let url = caps .name("url") .map(|m| decode_html_entities(m.as_str()).to_string()); - let title = if link_text.is_empty() { + let close_parenthesis: Option = caps + .name("close_parenthesis") + .map(|m| decode_html_entities(m.as_str()).to_string()); + // An image as the link text renders as a child ``; the title is + // then empty so the raw markdown isn't printed alongside it. An + // unterminated link keeps its text, since it may still be streaming. + let image = close_parenthesis + .as_ref() + .and_then(|_| parse_image(raw_link_text)) + .map(|(node, _)| node); + let title = if image.is_some() { + String::new() + } else if link_text.is_empty() { url.clone().unwrap_or_default() } else { link_text }; - let close_parenthesis: Option = caps - .name("close_parenthesis") - .map(|m| decode_html_entities(m.as_str()).to_string()); + let mut node = Node::new(Link { + url: url, + title, + close_parenthesis: close_parenthesis, + config: *config, + }); + node.children.extend(image); Some(( - Node::new(Link { - url: url, - title, - close_parenthesis: close_parenthesis, - config: *config, - }), + node, // NOTE(Rehan): trim end to not replace trailing newline complete_match.trim_end().len(), )) diff --git a/tests/test_kagi.py b/tests/test_kagi.py index 9819231..af5beac 100644 --- a/tests/test_kagi.py +++ b/tests/test_kagi.py @@ -36,6 +36,25 @@ def test_image_empty_alt_text(self): assert ", not as the literal characters. + md_text = "[![](/maps/inline_snapshot/51.5582,-0.1557)](https://kagi.com/maps?q=cafe)" + html_text = md_to_html(md_text) + assert '