pagesift_frontier/focus/
context.rs1use std::collections::HashMap;
4use std::sync::LazyLock;
5
6use regex::Regex;
7
8const CONTEXT_BYTES: usize = 400;
12
13pub struct LinkContext {
15 pub anchor: String,
16 pub line: String,
17}
18
19static MD_LINK: LazyLock<Regex> = LazyLock::new(|| {
20 Regex::new(r"\[([^\[\]]*)\]\(([^()\s]+)(?:\s+\x22[^\x22]*\x22)?\)").expect("valid regex")
21});
22
23pub fn link_contexts(markdown: &str) -> HashMap<String, LinkContext> {
26 let mut out: HashMap<String, LinkContext> = HashMap::new();
27 for line in markdown.lines() {
28 if !line.contains("](") {
29 continue;
30 }
31 let plain = MD_LINK.replace_all(line, "$1");
32 for cap in MD_LINK.captures_iter(line) {
33 let url = cap[2].split('#').next().unwrap_or_default();
34 if url.is_empty() || out.contains_key(url) {
35 continue;
36 }
37 let anchor = cap[1].trim().to_string();
38 out.insert(
39 url.to_string(),
40 LinkContext {
41 line: around(&plain, &anchor),
42 anchor,
43 },
44 );
45 }
46 }
47 out
48}
49
50pub fn prose_words(markdown: &str) -> usize {
53 MD_LINK
54 .replace_all(markdown, "")
55 .split_whitespace()
56 .filter(|w| w.chars().any(char::is_alphanumeric))
57 .count()
58}
59
60fn around(line: &str, anchor: &str) -> String {
62 if line.len() <= CONTEXT_BYTES {
63 return line.to_string();
64 }
65 let at = line.find(anchor).unwrap_or(0);
66 let mut lo = at.saturating_sub(CONTEXT_BYTES / 2);
67 let mut hi = (lo + CONTEXT_BYTES).min(line.len());
68 while !line.is_char_boundary(lo) {
69 lo -= 1;
70 }
71 while !line.is_char_boundary(hi) {
72 hi += 1;
73 }
74 line[lo..hi].to_string()
75}
76
77#[cfg(test)]
78mod tests {
79 use super::*;
80
81 #[test]
82 fn link_contexts_come_from_markdown_lines() {
83 let md = "# T\n\nRead the [refund rules](https://e.com/r#top) before buying.\n\n- [About](https://e.com/a \"title\")\n- [Refund again](https://e.com/r)\n\n\n";
84 let c = link_contexts(md);
85 let r = &c["https://e.com/r"];
86 assert_eq!(r.anchor, "refund rules");
87 assert_eq!(r.line, "Read the refund rules before buying.");
88 assert_eq!(c["https://e.com/a"].anchor, "About");
89 let long = format!(
90 "x{} [k](https://e.com/k) {}",
91 "a".repeat(600),
92 "b".repeat(600)
93 );
94 let c = link_contexts(&long);
95 assert!(c["https://e.com/k"].line.len() <= CONTEXT_BYTES + 1);
96 assert!(c["https://e.com/k"].line.contains('k'));
97 }
98
99 #[test]
100 fn prose_words_exclude_link_labels() {
101 let md = "Intro line here.\n\n- [Refund policy for everyone](https://e.com/a)\n- [Second long label](https://e.com/b)\n";
102 assert_eq!(prose_words(md), 3);
103 assert_eq!(prose_words("plain words only"), 3);
104 }
105}