1use regex::Regex;
6use std::sync::LazyLock;
7
8use super::headers::{JinaParams, RespondWith, Retain};
9use crate::error::{ApiError, ErrorCode};
10use crate::types::Document;
11
12static IMAGE: LazyLock<Regex> =
13 LazyLock::new(|| Regex::new(r#"!\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)"#).expect("regex"));
14static LINK: LazyLock<Regex> = LazyLock::new(|| {
15 Regex::new(r#"(^|[^!])\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)"#).expect("regex")
16});
17
18fn is_media(url: &str) -> bool {
20 let path = url
21 .split(['?', '#'])
22 .next()
23 .unwrap_or(url)
24 .to_ascii_lowercase();
25 const EXT: &[&str] = &[
26 ".mp4", ".webm", ".mov", ".m4v", ".ogv", ".mp3", ".m4a", ".ogg", ".oga", ".wav", ".flac",
27 ".m3u8", ".mpd",
28 ];
29 const HOSTS: &[&str] = &[
30 "youtube.com/watch",
31 "youtube.com/embed/",
32 "youtu.be/",
33 "vimeo.com/",
34 "player.vimeo.com/",
35 "bilibili.com/video/",
36 "player.bilibili.com/",
37 ];
38 EXT.iter().any(|e| path.ends_with(e)) || HOSTS.iter().any(|h| path.contains(h))
39}
40
41type Ref = (String, String);
43
44pub fn reshape(md: &str, p: &JinaParams) -> (String, Vec<Ref>, Vec<Ref>) {
46 let images: Vec<Ref> = IMAGE
47 .captures_iter(md)
48 .filter(|c| !is_media(&c[2]))
49 .map(|c| (c[1].to_string(), c[2].to_string()))
50 .collect();
51 let links: Vec<Ref> = LINK
52 .captures_iter(md)
53 .filter(|c| !is_media(&c[3]))
54 .map(|c| (c[2].to_string(), c[3].to_string()))
55 .collect();
56 let out = IMAGE.replace_all(md, |c: ®ex::Captures<'_>| {
57 let (alt, url) = (&c[1], &c[2]);
58 let retain = if is_media(url) {
59 p.retain_media
60 } else {
61 p.retain_images
62 };
63 match retain {
64 Retain::All | Retain::Image => c[0].to_string(),
65 Retain::None => String::new(),
66 Retain::Text => alt.to_string(),
67 }
68 });
69 let out = LINK.replace_all(&out, |c: ®ex::Captures<'_>| {
70 let (lead, text, url) = (&c[1], &c[2], &c[3]);
71 if is_media(url) {
72 return match p.retain_media {
73 Retain::All => c[0].to_string(),
74 Retain::None => lead.to_string(),
75 Retain::Text => format!("{lead}{text}"),
76 Retain::Image => format!("{lead}"),
77 };
78 }
79 match p.retain_links {
80 Retain::All | Retain::Image => c[0].to_string(),
81 Retain::None => lead.to_string(),
82 Retain::Text => format!("{lead}{text}"),
83 }
84 });
85 (out.into_owned(), links, images)
86}
87
88fn summary(title: &str, refs: &[Ref], all: bool, image: bool) -> String {
89 let mut seen = std::collections::HashSet::new();
90 let mut s = format!("\n\n{title}:\n");
91 for (text, url) in refs {
92 if !all && !seen.insert(url.clone()) {
93 continue;
94 }
95 if image {
96 s.push_str(&format!("- \n"));
97 } else {
98 s.push_str(&format!("- [{text}]({url})\n"));
99 }
100 }
101 s
102}
103
104pub fn markdown_text(md: &str) -> String {
106 let no_img = IMAGE.replace_all(md, "$1");
107 let no_link = LINK.replace_all(&no_img, "$1$2");
108 no_link
109 .lines()
110 .map(|l| {
111 l.trim_start_matches('#')
112 .trim_start()
113 .replace("**", "")
114 .replace("__", "")
115 })
116 .collect::<Vec<_>>()
117 .join("\n")
118}
119
120pub fn token_count(s: &str) -> usize {
121 pagesift_extract::TokenIndex::new(s).len()
122}
123
124pub fn truncate_tokens(s: &str, max: usize) -> (String, bool) {
126 let idx = pagesift_extract::TokenIndex::new(s);
127 match idx.starts.get(max) {
128 Some(&cut) => (s[..cut].trim_end().to_string(), true),
129 None => (s.to_string(), false),
130 }
131}
132
133fn yaml_str(v: &str) -> String {
134 serde_json::to_string(v).unwrap_or_else(|_| "\"\"".into())
135}
136
137#[derive(Debug)]
139pub struct Rendered {
140 pub content_type: &'static str,
141 pub body: String,
142 pub text: String,
144 pub warnings: Vec<String>,
145 pub chunks: Option<Vec<String>>,
147}
148
149pub fn render(doc: &Document, url: &str, p: &JinaParams) -> Result<Rendered, ApiError> {
150 if let Some(want) = p.assert_status
151 && doc.metadata.status_code != want
152 {
153 return Err(ApiError::new(
154 ErrorCode::AssertionFailed,
155 format!(
156 "the page answered {}, not the asserted {want}",
157 doc.metadata.status_code
158 ),
159 ));
160 }
161 let title = doc.metadata.title.as_deref().unwrap_or_default();
162 let mut warnings = p.notes.clone();
163 let (mut head, mut tail) = (String::new(), String::new());
165 let (content_type, body) = match p.respond_with {
166 RespondWith::Html => (
167 "text/html; charset=utf-8",
168 doc.raw_html.clone().unwrap_or_default(),
169 ),
170 RespondWith::Screenshot { .. } => {
171 let shot = doc
172 .actions
173 .as_ref()
174 .and_then(|a| a["screenshots"].get(0))
175 .and_then(|v| v.as_str())
176 .ok_or_else(|| {
177 ApiError::unsupported(
178 "screenshot needs browser rendering, which is not available here",
179 )
180 })?;
181 ("text/plain; charset=utf-8", shot.to_string())
182 }
183 _ => {
184 let md = doc.markdown.clone().unwrap_or_default();
185 let (mut md, links, images) = reshape(&md, p);
186 if let Some(all) = p.images_summary {
187 tail.push_str(&summary("Images", &images, all, true));
188 }
189 if let Some(all) = p.links_summary {
190 tail.push_str(&summary("Links/Buttons", &links, all, false));
191 }
192 md.push_str(&tail);
193 match p.respond_with {
194 RespondWith::Text => ("text/plain; charset=utf-8", markdown_text(&md)),
195 RespondWith::Frontmatter | RespondWith::MarkdownFrontmatter => {
196 let desc = doc.metadata.description.as_deref().unwrap_or_default();
197 head = format!(
198 "---\ntitle: {}\ndescription: {}\nurl: {}\n---\n\n",
199 yaml_str(title),
200 yaml_str(desc),
201 yaml_str(url)
202 );
203 ("text/markdown; charset=utf-8", format!("{head}{md}"))
204 }
205 _ => ("text/markdown; charset=utf-8", md),
206 }
207 }
208 };
209 let mut body = body;
210 if let Some(budget) = p.token_budget {
211 let n = token_count(&body);
212 if n > budget {
213 return Err(ApiError::new(
214 ErrorCode::TooLarge,
215 format!("the content has {n} tokens, over x-token-budget {budget}"),
216 )
217 .with_details(serde_json::json!({ "tokens": n, "budget": budget })));
218 }
219 }
220 let mut chunks = p.chunking.as_ref().and(doc.chunks.as_ref()).map(|c| {
222 c.iter()
223 .map(|ch| {
224 let md = reshape(&ch.text, p).0;
225 match p.respond_with {
226 RespondWith::Text => markdown_text(&md),
227 _ => md,
228 }
229 })
230 .collect::<Vec<_>>()
231 });
232 if let Some(max) = p.max_tokens {
233 let (cut, truncated) = truncate_tokens(&body, max);
234 if truncated {
235 warnings.push(format!(
236 "output_truncated: {} of {} tokens",
237 max,
238 token_count(&body)
239 ));
240 body = cut;
241 }
242 if let Some(c) = &mut chunks {
244 truncate_chunks(c, max);
245 }
246 }
247 let text = match &chunks {
248 Some(c) => {
249 let (joined, _) = truncate_tokens(
250 &format!("{head}{}{tail}", c.join(CHUNK_SEPARATOR)),
251 p.max_tokens.unwrap_or(usize::MAX),
252 );
253 joined
254 }
255 None => body.clone(),
256 };
257 Ok(Rendered {
258 content_type,
259 body,
260 text,
261 warnings,
262 chunks,
263 })
264}
265
266const CHUNK_SEPARATOR: &str = "\n\n---\n\n";
268
269fn truncate_chunks(chunks: &mut Vec<String>, max: usize) {
271 let mut left = max;
272 let mut keep = 0;
273 for c in chunks.iter_mut() {
274 if left == 0 {
275 break;
276 }
277 let n = token_count(c);
278 if n > left {
279 *c = truncate_tokens(c, left).0;
280 keep += 1;
281 break;
282 }
283 left -= n;
284 keep += 1;
285 }
286 chunks.truncate(keep);
287}
288
289#[cfg(test)]
290mod tests {
291 use super::*;
292 use crate::jina::headers::parse;
293 use axum::http::HeaderMap;
294
295 fn params(pairs: &[(&str, &str)]) -> JinaParams {
296 let mut h = HeaderMap::new();
297 for (k, v) in pairs {
298 h.insert(
299 axum::http::HeaderName::from_bytes(k.as_bytes()).unwrap(),
300 v.parse().unwrap(),
301 );
302 }
303 parse(&h).unwrap().1
304 }
305
306 const MD: &str = "# T\n\nSee [docs](https://a.test/docs) and [docs again](https://a.test/docs).\n\n [clip](https://youtu.be/x1)";
307
308 #[test]
309 fn retain_modes_and_summaries() {
310 let (out, ..) = reshape(
311 MD,
312 ¶ms(&[
313 ("x-retain-links", "text"),
314 ("x-retain-images", "none"),
315 ("x-retain-media", "none"),
316 ]),
317 );
318 assert_eq!(out, "# T\n\nSee docs and docs again.\n\n ");
319 let p = params(&[("x-retain-images", "alt"), ("x-with-links-summary", "true")]);
320 let doc = Document {
321 markdown: Some(MD.into()),
322 ..Default::default()
323 };
324 let r = render(&doc, "https://a.test/", &p).unwrap();
325 assert!(
326 r.body.contains("\n\nlogo [clip](https://youtu.be/x1)"),
327 "{}",
328 r.body
329 );
330 assert_eq!(
331 r.body.matches("- [docs").count(),
332 1,
333 "deduplicated: {}",
334 r.body
335 );
336 }
337
338 fn chunk(text: &str) -> crate::types::DocChunk {
339 crate::types::DocChunk {
340 text: text.into(),
341 heading_path: Vec::new(),
342 start: 0,
343 end: 0,
344 tokens: 0,
345 }
346 }
347
348 fn chunked_doc() -> Document {
349 Document {
350 markdown: Some(
351 "## A\n\nalpha words\n\n## B\n\nbeta words [l](https://a.test/x)".into(),
352 ),
353 chunks: Some(vec"),
356 ]),
357 ..Default::default()
358 }
359 }
360
361 #[test]
362 fn chunks_keep_frontmatter_summary_and_limit() {
363 let p = params(&[
364 ("x-respond-with", "frontmatter"),
365 ("x-markdown-chunking", "h2"),
366 ("x-with-links-summary", "true"),
367 ]);
368 let r = render(&chunked_doc(), "https://a.test/", &p).unwrap();
369 assert!(r.text.starts_with("---\ntitle:"), "{}", r.text);
370 assert!(r.text.contains("alpha words\n\n---\n\n## B"), "{}", r.text);
371 assert!(r.text.contains("Links/Buttons"), "{}", r.text);
372 assert_eq!(r.chunks.as_ref().unwrap().len(), 2);
373 let p = params(&[("x-markdown-chunking", "h2"), ("x-max-tokens", "4")]);
374 let r = render(&chunked_doc(), "u", &p).unwrap();
375 assert!(
376 r.warnings[0].starts_with("output_truncated"),
377 "{:?}",
378 r.warnings
379 );
380 let c = r.chunks.unwrap();
381 assert_eq!(c.len(), 1, "{c:?}");
382 assert!(token_count(&r.text) <= 4, "{}", r.text);
383 }
384
385 #[test]
386 fn asserted_status_and_preset_notes() {
387 let mut doc = chunked_doc();
388 doc.metadata.status_code = 404;
389 let e = render(&doc, "u", ¶ms(&[("x-assert-status-code", "200")])).unwrap_err();
390 assert_eq!(e.code, ErrorCode::AssertionFailed);
391 assert!(render(&doc, "u", ¶ms(&[("x-assert-status-code", "404")])).is_ok());
392 let r = render(&doc, "u", ¶ms(&[("x-preset", "reader")])).unwrap();
393 assert!(
394 r.warnings[0].starts_with("preset_partial"),
395 "{:?}",
396 r.warnings
397 );
398 }
399
400 #[test]
401 fn max_tokens_truncates_and_budget_refuses() {
402 let doc = Document {
403 markdown: Some("one two three four five six".into()),
404 ..Default::default()
405 };
406 let r = render(&doc, "u", ¶ms(&[("x-max-tokens", "3")])).unwrap();
407 assert_eq!(r.body, "one two three");
408 assert!(r.warnings[0].starts_with("output_truncated"));
409 let e = render(&doc, "u", ¶ms(&[("x-token-budget", "5")])).unwrap_err();
410 assert_eq!(e.code, ErrorCode::TooLarge);
411 let ok = render(&doc, "u", ¶ms(&[("x-respond-with", "frontmatter")])).unwrap();
412 assert!(ok.body.starts_with("---\ntitle: \"\"\n"));
413 }
414}