Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 31 additions & 24 deletions src/plugins/kagi_plugins/image.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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])
}
}

Expand Down
42 changes: 28 additions & 14 deletions src/plugins/kagi_plugins/link.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -30,10 +31,13 @@ pub static LINK_MD_PATTERN: Lazy<Regex> = 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<link_text>.*?)
(?P<link_text>!\[[^\]]*\]\([^)]*\)|.*?)
\]
(?P<open_parenthesis>\()
(?P<url>[^)]*)
Expand Down Expand Up @@ -156,27 +160,37 @@ impl InlineRule for LinkScanner {
let config = state.md.ext.get::<LinkExtensionPlugin>().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<String> = caps
.name("close_parenthesis")
.map(|m| decode_html_entities(m.as_str()).to_string());
// An image as the link text renders as a child `<img>`; 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<String> = 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(),
))
Expand Down
19 changes: 19 additions & 0 deletions tests/test_kagi.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,25 @@ def test_image_empty_alt_text(self):
assert "<img" in html_text
assert "https://www.example.com/image.png" in html_text

def test_image_inside_link(self):
# Assistant emits map snapshots and proxied images as an image wrapped
# in a link. The link text is image markdown and must render as an
# <img>, 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 '<a href="https://kagi.com/maps?q=cafe"' in html_text
assert "<img" in html_text
assert 'src="/maps/inline_snapshot/51.5582,-0.1557"' in html_text
assert 'alt=""' in html_text
assert "![" not in html_text

def test_image_inside_link_keeps_alt_text(self):
md_text = "[![Plot](https://example.com/graph.png)](https://example.com/source)"
html_text = md_to_html(md_text)
assert '<a href="https://example.com/source"' in html_text
assert 'alt="Plot"' in html_text
assert 'src="https://example.com/graph.png"' in html_text

def test_link_empty_text_falls_back_to_url(self):
md_text = "Check [](https://www.example.com/page) out."
html_text = md_to_html(md_text)
Expand Down