this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

river

+237 -114
+19 -2
stack/river/cmd/river_cmd.ml
··· 448 448 ) links; 449 449 Fmt.pr "@."); 450 450 451 - (* Categories/Tags if verbose *) 451 + (* River Categories - always show if any assigned *) 452 + let river_category_ids = River.State.get_post_categories state ~post_id in 453 + (match river_category_ids with 454 + | [] -> () 455 + | cat_ids -> 456 + Fmt.pr "%a@." Fmt.(styled `Cyan string) "River Categories:"; 457 + List.iter (fun cat_id -> 458 + match River.State.get_category state ~id:cat_id with 459 + | Some cat -> 460 + Fmt.pr " - %a (%a)@." 461 + Fmt.(styled (`Fg `Green) string) (River.Category.name cat) 462 + Fmt.(styled `Faint string) cat_id 463 + | None -> 464 + Fmt.pr " - %a@." Fmt.(styled `Faint string) cat_id 465 + ) cat_ids; 466 + Fmt.pr "@."); 467 + 468 + (* Original blog tags if verbose *) 452 469 if verbose then begin 453 470 match entry.categories with 454 471 | [] -> () 455 472 | categories -> 456 - Fmt.pr "%a@." Fmt.(styled `Cyan string) "Tags:"; 473 + Fmt.pr "%a@." Fmt.(styled `Cyan string) "Original Blog Tags:"; 457 474 List.iter (fun cat -> 458 475 Fmt.pr " - %s@." cat.Syndic.Atom.term 459 476 ) categories;
+65 -19
stack/river/lib/format.ml
··· 201 201 202 202 .author-thumbnail { 203 203 float: right; 204 - width: 48px; 205 - height: 48px; 204 + width: 64px; 205 + height: 64px; 206 206 border-radius: 50%; 207 207 object-fit: cover; 208 208 margin-left: 15px; ··· 411 411 .read-more { 412 412 display: inline-block; 413 413 color: #0366d6; 414 - font-size: 13px; 414 + font-size: 11px; 415 415 cursor: pointer; 416 416 text-decoration: none; 417 - margin-top: 8px; 418 - padding: 4px 8px; 417 + padding: 2px 8px; 419 418 border: 1px solid #e1e4e8; 420 419 border-radius: 3px; 421 420 background: #f6f8fa; 422 421 transition: background 0.2s; 422 + margin-right: 6px; 423 + vertical-align: middle; 423 424 } 424 425 425 426 .read-more:hover { ··· 428 429 429 430 .read-more::after { 430 431 content: ' ▼'; 431 - font-size: 10px; 432 + font-size: 9px; 432 433 } 433 434 434 435 .read-more.active::after { ··· 439 440 margin-top: 8px; 440 441 font-size: 11px; 441 442 clear: both; 443 + display: inline-block; 442 444 } 443 445 444 446 .post-tags a { ··· 450 452 text-decoration: none; 451 453 margin-right: 4px; 452 454 margin-bottom: 4px; 455 + vertical-align: middle; 456 + font-size: 11px; 453 457 } 454 458 455 459 .post-tags a:hover { 456 460 background: #dbedff; 457 461 } 458 462 463 + .post-tags-and-actions { 464 + margin-top: 8px; 465 + display: flex; 466 + align-items: center; 467 + clear: both; 468 + } 469 + 459 470 .pagination { 460 471 margin-top: 30px; 461 472 padding-top: 15px; ··· 480 491 } 481 492 482 493 .link-item { 483 - margin-bottom: 15px; 484 - padding-bottom: 12px; 485 - border-bottom: 1px solid #e1e4e8; 494 + margin-bottom: 6px; 495 + padding: 4px 0; 496 + border-bottom: 1px solid #f0f0f0; 497 + display: flex; 498 + align-items: baseline; 499 + font-size: 13px; 500 + line-height: 1.4; 486 501 } 487 502 488 503 .link-item:last-child { ··· 490 505 } 491 506 492 507 .link-url { 493 - font-size: 14px; 494 - margin-bottom: 3px; 508 + flex: 0 0 auto; 509 + margin-right: 8px; 495 510 } 496 511 497 512 .link-url a { 498 513 color: #0366d6; 499 514 text-decoration: none; 500 - word-break: break-all; 515 + font-weight: 500; 501 516 } 502 517 503 518 .link-url a:hover { 504 519 text-decoration: underline; 505 520 } 506 521 507 - .link-meta { 522 + .link-domain { 523 + color: #24292e; 524 + font-weight: 500; 525 + } 526 + 527 + .link-path { 528 + color: #586069; 529 + font-weight: 400; 530 + } 531 + 532 + .link-backlinks { 533 + flex: 1 1 auto; 508 534 font-size: 11px; 509 535 color: #586069; 536 + display: flex; 537 + flex-wrap: wrap; 538 + gap: 6px; 510 539 } 511 540 512 - .link-meta a { 541 + .link-backlink { 542 + display: inline-flex; 543 + align-items: center; 544 + gap: 3px; 545 + } 546 + 547 + .link-backlink a { 513 548 color: #586069; 514 549 text-decoration: none; 515 550 } 516 551 517 - .link-meta a:hover { 552 + .link-backlink a:hover { 518 553 color: #0366d6; 554 + } 555 + 556 + .link-backlink-icon { 557 + color: #959da5; 558 + font-size: 10px; 519 559 } 520 560 521 561 .author-list { ··· 918 958 (* Convert markdown back to HTML using Cmarkit with custom renderer *) 919 959 let doc = Cmarkit.Doc.of_string excerpt_md in 920 960 921 - (* Custom renderer that makes headings smaller and inline *) 922 - let inline_headings = 961 + (* Custom renderer that makes headings smaller and strips images *) 962 + let excerpt_customizations = 923 963 let block c = function 924 964 | Cmarkit.Block.Heading (h, _) -> 925 965 let level = Cmarkit.Block.Heading.level h in ··· 936 976 true 937 977 | _ -> false 938 978 in 939 - Cmarkit_renderer.make ~block () 979 + let inline _c = function 980 + | Cmarkit.Inline.Image _ -> 981 + (* Skip images in excerpts *) 982 + true 983 + | _ -> false 984 + in 985 + Cmarkit_renderer.make ~block ~inline () 940 986 in 941 987 942 - let renderer = Cmarkit_renderer.compose (Cmarkit_html.renderer ~safe:true ()) inline_headings in 988 + let renderer = Cmarkit_renderer.compose (Cmarkit_html.renderer ~safe:true ()) excerpt_customizations in 943 989 Cmarkit_renderer.doc_to_string renderer doc 944 990 945 991 let render_post_html ~post ~author_username =
+153 -93
stack/river/lib/state.ml
··· 573 573 let export_merged_feed state ~title ~format ?limit () = 574 574 let all_posts = get_all_posts state ?limit () in 575 575 576 - (* Rewrite author metadata from Sortal user info *) 577 - let rewrite_entry_author username (entry : Syndic.Atom.entry) = 578 - match Storage.get_user state username with 576 + (* Rewrite author metadata from Sortal user info and replace tags with River categories *) 577 + let rewrite_entry_author_and_categories username (entry : Syndic.Atom.entry) = 578 + let entry = match Storage.get_user state username with 579 579 | None -> entry 580 580 | Some user -> 581 581 (* Get user's full name and email from Sortal *) ··· 595 595 (* Update entry with new author, keeping existing contributors *) 596 596 let _, other_authors = entry.authors in 597 597 { entry with authors = (new_author, other_authors) } 598 + in 599 + 600 + (* Replace original blog tags with River categories *) 601 + let post_id = Uri.to_string entry.id in 602 + let river_category_ids = get_post_categories state ~post_id in 603 + (* Deduplicate category IDs and create Atom categories *) 604 + let unique_category_ids = List.sort_uniq String.compare river_category_ids in 605 + let river_categories = List.filter_map (fun cat_id -> 606 + match get_category state ~id:cat_id with 607 + | Some cat -> Some (Syndic.Atom.category ~label:(Category.name cat) cat_id) 608 + | None -> None 609 + ) unique_category_ids in 610 + 611 + { entry with categories = river_categories } 598 612 in 599 613 600 614 let entries = List.map (fun (username, entry) -> 601 - rewrite_entry_author username entry 615 + rewrite_entry_author_and_categories username entry 602 616 ) all_posts in 603 617 604 618 match format with ··· 693 707 String.concat "" (List.map Syndic.XML.to_string nodes) 694 708 | Some (Syndic.Atom.Mime _) | Some (Syndic.Atom.Src _) | None -> "" 695 709 in 696 - let author, _ = entry.authors in 697 - let tags = List.map (fun (c : Syndic.Atom.category) -> c.term) entry.categories in 710 + (* Get author name from Sortal, fallback to entry author *) 711 + let author_name = match Sortal.lookup state.sortal username with 712 + | Some contact -> Sortal.Contact.name contact 713 + | None -> 714 + let author, _ = entry.authors in 715 + author.name 716 + in 717 + (* Don't use original blog tags - River categories will be fetched separately *) 698 718 let post_id = Uri.to_string entry.id in 699 - (username, title, author.name, entry.updated, link_uri, content_html, tags, post_id) 719 + (username, title, author_name, entry.updated, link_uri, content_html, [], post_id) 700 720 in 701 721 702 722 (* Get all posts *) ··· 722 742 i >= start_idx && i < start_idx + posts_per_page 723 743 ) html_data in 724 744 725 - let post_htmls = List.map (fun (username, title, _feed_author, date, link, content, tags, post_id) -> 745 + let post_htmls = List.map (fun (username, title, _feed_author, date, link, content, _tags, post_id) -> 726 746 Log.debug (fun m -> m " Processing post: %s by @%s" title username); 727 747 728 748 (* Get author name from Sortal, fallback to username *) ··· 743 763 let excerpt = Format.Html.post_excerpt_from_html content ~max_length:300 in 744 764 let full_content = Format.Html.full_content_from_html content in 745 765 746 - (* Get custom categories for this post *) 747 - let custom_category_ids = get_post_categories state ~post_id in 748 - let custom_categories = List.filter_map (fun cat_id -> 766 + (* Get River categories for this post *) 767 + let river_category_ids = get_post_categories state ~post_id in 768 + let river_categories = List.filter_map (fun cat_id -> 749 769 match get_category state ~id:cat_id with 750 770 | Some cat -> Some (Category.id cat, Category.name cat) 751 771 | None -> None 752 - ) custom_category_ids in 772 + ) river_category_ids in 753 773 754 - (* Combine feed tags and custom categories *) 755 - let all_tags = tags @ List.map fst custom_categories in 774 + (* Display only River categories *) 756 775 let tags_html = 757 - match all_tags with 776 + match river_categories with 758 777 | [] -> "" 759 778 | _ -> 760 - (* Display feed tags *) 761 - let tag_links = List.map (fun tag -> 762 - Printf.sprintf {|<a href="categories/%s.html" class="tag-feed">%s</a>|} 763 - (Format.Html.html_escape (sanitize_filename tag)) (Format.Html.html_escape tag) 764 - ) tags in 765 - (* Display custom categories with different styling *) 766 779 let category_links = List.map (fun (cat_id, cat_name) -> 767 - Printf.sprintf {|<a href="categories/%s.html" class="tag-custom">%s</a>|} 780 + Printf.sprintf {|<a href="categories/%s.html">%s</a>|} 768 781 (Format.Html.html_escape (sanitize_filename cat_id)) (Format.Html.html_escape cat_name) 769 - ) custom_categories in 770 - Printf.sprintf {|<div class="post-tags">%s%s</div>|} 771 - (String.concat "" tag_links) 782 + ) river_categories in 783 + Printf.sprintf {|<div class="post-tags">%s</div>|} 772 784 (String.concat "" category_links) 773 785 in 786 + let tags_and_actions = 787 + if tags_html = "" then 788 + {|<a href="#" class="read-more">Read more</a>|} 789 + else 790 + Printf.sprintf {|<div class="post-tags-and-actions"><a href="#" class="read-more">Read more</a>%s</div>|} 791 + tags_html 792 + in 774 793 let thumbnail_html = match get_author_thumbnail username with 775 794 | Some thumb_path -> 776 795 Printf.sprintf {|<a href="authors/%s.html"><img src="%s" alt="%s" class="author-thumbnail"></a>|} ··· 792 811 <div class="post-full-content"> 793 812 %s 794 813 </div> 795 - <a href="#" class="read-more">Read more</a> 796 814 %s 797 815 </article>|} 798 816 thumbnail_html ··· 802 820 date_str 803 821 excerpt 804 822 full_content 805 - tags_html 823 + tags_and_actions 806 824 in 807 825 post_html 808 826 ) page_posts in ··· 1051 1069 i >= start_idx && i < start_idx + posts_per_page 1052 1070 ) author_posts in 1053 1071 1054 - let post_htmls = List.map (fun (_username, title, author, date, link, content, tags, post_id) -> 1072 + let post_htmls = List.map (fun (_username, title, author, date, link, content, _tags, post_id) -> 1055 1073 let date_str = Format.Html.format_date date in 1056 1074 let link_html = match link with 1057 1075 | Some uri -> ··· 1063 1081 let excerpt = Format.Html.post_excerpt_from_html content ~max_length:300 in 1064 1082 let full_content = Format.Html.full_content_from_html content in 1065 1083 1066 - (* Get custom categories for this post *) 1067 - let custom_category_ids = get_post_categories state ~post_id in 1068 - let custom_categories = List.filter_map (fun cat_id -> 1084 + (* Get River categories for this post *) 1085 + let river_category_ids = get_post_categories state ~post_id in 1086 + let river_categories = List.filter_map (fun cat_id -> 1069 1087 match get_category state ~id:cat_id with 1070 1088 | Some cat -> Some (Category.id cat, Category.name cat) 1071 1089 | None -> None 1072 - ) custom_category_ids in 1090 + ) river_category_ids in 1073 1091 1092 + (* Display only River categories *) 1074 1093 let tags_html = 1075 - let all_tags_exist = tags <> [] || custom_categories <> [] in 1076 - if not all_tags_exist then "" 1094 + match river_categories with 1095 + | [] -> "" 1096 + | _ -> 1097 + let category_links = List.map (fun (cat_id, cat_name) -> 1098 + Printf.sprintf {|<a href="../categories/%s.html">%s</a>|} 1099 + (Format.Html.html_escape (sanitize_filename cat_id)) (Format.Html.html_escape cat_name) 1100 + ) river_categories in 1101 + Printf.sprintf {|<div class="post-tags">%s</div>|} 1102 + (String.concat "" category_links) 1103 + in 1104 + let tags_and_actions = 1105 + if tags_html = "" then 1106 + {|<a href="#" class="read-more">Read more</a>|} 1077 1107 else 1078 - (* Display feed tags *) 1079 - let tag_links = List.map (fun tag -> 1080 - Printf.sprintf {|<a href="../categories/%s.html" class="tag-feed">%s</a>|} 1081 - (Format.Html.html_escape (sanitize_filename tag)) (Format.Html.html_escape tag) 1082 - ) tags in 1083 - (* Display custom categories with different styling *) 1084 - let category_links = List.map (fun (cat_id, cat_name) -> 1085 - Printf.sprintf {|<a href="../categories/%s.html" class="tag-custom">%s</a>|} 1086 - (Format.Html.html_escape (sanitize_filename cat_id)) (Format.Html.html_escape cat_name) 1087 - ) custom_categories in 1088 - Printf.sprintf {|<div class="post-tags">%s%s</div>|} 1089 - (String.concat "" tag_links) 1090 - (String.concat "" category_links) 1108 + Printf.sprintf {|<div class="post-tags-and-actions"><a href="#" class="read-more">Read more</a>%s</div>|} 1109 + tags_html 1091 1110 in 1092 1111 Printf.sprintf {|<article class="post"> 1093 1112 <h2 class="post-title">%s</h2> ··· 1100 1119 <div class="post-full-content"> 1101 1120 %s 1102 1121 </div> 1103 - <a href="#" class="read-more">Read more</a> 1104 1122 %s 1105 1123 </article>|} 1106 1124 link_html ··· 1108 1126 date_str 1109 1127 excerpt 1110 1128 full_content 1111 - tags_html 1129 + tags_and_actions 1112 1130 ) page_posts in 1113 1131 1114 1132 let posts_with_header = author_header ^ "\n" ^ String.concat "\n" post_htmls in ··· 1204 1222 i >= start_idx && i < start_idx + posts_per_page 1205 1223 ) tag_posts in 1206 1224 1207 - let post_htmls = List.map (fun (username, title, author, date, link, content, tags, post_id) -> 1225 + let post_htmls = List.map (fun (username, title, author, date, link, content, _tags, post_id) -> 1208 1226 let date_str = Format.Html.format_date date in 1209 1227 let link_html = match link with 1210 1228 | Some uri -> ··· 1216 1234 let excerpt = Format.Html.post_excerpt_from_html content ~max_length:300 in 1217 1235 let full_content = Format.Html.full_content_from_html content in 1218 1236 1219 - (* Get custom categories for this post *) 1220 - let custom_category_ids = get_post_categories state ~post_id in 1221 - let custom_categories = List.filter_map (fun cat_id -> 1237 + (* Get River categories for this post *) 1238 + let river_category_ids = get_post_categories state ~post_id in 1239 + let river_categories = List.filter_map (fun cat_id -> 1222 1240 match get_category state ~id:cat_id with 1223 1241 | Some cat -> Some (Category.id cat, Category.name cat) 1224 1242 | None -> None 1225 - ) custom_category_ids in 1243 + ) river_category_ids in 1226 1244 1245 + (* Display only River categories *) 1227 1246 let tags_html = 1228 - let all_tags_exist = tags <> [] || custom_categories <> [] in 1229 - if not all_tags_exist then "" 1247 + match river_categories with 1248 + | [] -> "" 1249 + | _ -> 1250 + let category_links = List.map (fun (cat_id, cat_name) -> 1251 + Printf.sprintf {|<a href="%s.html">%s</a>|} 1252 + (Format.Html.html_escape (sanitize_filename cat_id)) (Format.Html.html_escape cat_name) 1253 + ) river_categories in 1254 + Printf.sprintf {|<div class="post-tags">%s</div>|} 1255 + (String.concat "" category_links) 1256 + in 1257 + let tags_and_actions = 1258 + if tags_html = "" then 1259 + {|<a href="#" class="read-more">Read more</a>|} 1230 1260 else 1231 - (* Display feed tags *) 1232 - let tag_links = List.map (fun t -> 1233 - Printf.sprintf {|<a href="%s.html" class="tag-feed">%s</a>|} 1234 - (Format.Html.html_escape (sanitize_filename t)) (Format.Html.html_escape t) 1235 - ) tags in 1236 - (* Display custom categories with different styling *) 1237 - let category_links = List.map (fun (cat_id, cat_name) -> 1238 - Printf.sprintf {|<a href="%s.html" class="tag-custom">%s</a>|} 1239 - (Format.Html.html_escape (sanitize_filename cat_id)) (Format.Html.html_escape cat_name) 1240 - ) custom_categories in 1241 - Printf.sprintf {|<div class="post-tags">%s%s</div>|} 1242 - (String.concat "" tag_links) 1243 - (String.concat "" category_links) 1261 + Printf.sprintf {|<div class="post-tags-and-actions"><a href="#" class="read-more">Read more</a>%s</div>|} 1262 + tags_html 1263 + in 1264 + (* Get thumbnail *) 1265 + let thumbnail_html = match get_author_thumbnail username with 1266 + | Some thumb_path -> 1267 + Printf.sprintf {|<a href="../authors/%s.html"><img src="../%s" alt="%s" class="author-thumbnail"></a>|} 1268 + (Format.Html.html_escape (sanitize_filename username)) 1269 + (Format.Html.html_escape thumb_path) 1270 + (Format.Html.html_escape author) 1271 + | None -> 1272 + Printf.sprintf {|<a href="../authors/%s.html"><div class="author-thumbnail" style="background: linear-gradient(135deg, #667eea 0%%, #764ba2 100%%); color: white; display: flex; align-items: center; justify-content: center; font-size: 20px; font-weight: 700;">%s</div></a>|} 1273 + (Format.Html.html_escape (sanitize_filename username)) 1274 + (String.uppercase_ascii (String.sub author 0 (min 1 (String.length author)))) 1244 1275 in 1245 1276 Printf.sprintf {|<article class="post"> 1277 + %s 1246 1278 <h2 class="post-title">%s</h2> 1247 - <div class="post-meta"> 1248 - By <a href="../authors/%s.html">%s</a> on %s 1249 - </div> 1279 + <div class="post-meta-line">By <a href="../authors/%s.html">%s</a> · %s</div> 1250 1280 <div class="post-excerpt"> 1251 1281 %s 1252 1282 </div> 1253 1283 <div class="post-full-content"> 1254 1284 %s 1255 1285 </div> 1256 - <a href="#" class="read-more">Read more</a> 1257 1286 %s 1258 1287 </article>|} 1288 + thumbnail_html 1259 1289 link_html 1260 1290 (Format.Html.html_escape (sanitize_filename username)) 1261 1291 (Format.Html.html_escape author) 1262 1292 date_str 1263 1293 excerpt 1264 1294 full_content 1265 - tags_html 1295 + tags_and_actions 1266 1296 ) page_posts in 1267 1297 1268 1298 let page_html = Format.Html.render_posts_page ··· 1321 1351 Log.info (fun m -> m " Deduplicated to %d unique links" (List.length sorted_links)); 1322 1352 1323 1353 let links_content = 1324 - let items = List.map (fun (href, (link_text, username, author, post_title, post_link, date), all_entries) -> 1325 - let date_str = Format.Html.format_date date in 1326 - let display_text = if link_text = "" || link_text = href then href else link_text in 1327 - let post_link_html = match post_link with 1328 - | Some uri -> 1329 - Printf.sprintf {|<a href="%s">%s</a>|} 1330 - (Format.Html.html_escape (Uri.to_string uri)) 1331 - (Format.Html.html_escape post_title) 1332 - | None -> Format.Html.html_escape post_title 1354 + let items = List.map (fun (href, (_link_text, _username, _author, _post_title, _post_link, _date), all_entries) -> 1355 + (* Parse URL to extract domain and path *) 1356 + let uri = Uri.of_string href in 1357 + let domain = match Uri.host uri with 1358 + | Some h -> h 1359 + | None -> "unknown" 1360 + in 1361 + let path = Uri.path uri in 1362 + let fragment = Uri.fragment uri in 1363 + 1364 + (* Shorten path if too long *) 1365 + let shortened_path = 1366 + let full_path = path ^ (match fragment with Some f -> "#" ^ f | None -> "") in 1367 + if String.length full_path > 40 then 1368 + let start = String.sub full_path 0 20 in 1369 + let ending = String.sub full_path (String.length full_path - 17) 17 in 1370 + start ^ "..." ^ ending 1371 + else 1372 + full_path 1333 1373 in 1334 - let count_str = if List.length all_entries > 1 then 1335 - Printf.sprintf " (mentioned in %d posts)" (List.length all_entries) 1336 - else "" 1374 + 1375 + let display_text = 1376 + if shortened_path = "" || shortened_path = "/" then 1377 + Printf.sprintf {|<span class="link-domain">%s</span>|} 1378 + (Format.Html.html_escape domain) 1379 + else 1380 + Printf.sprintf {|<span class="link-domain">%s</span><span class="link-path">%s</span>|} 1381 + (Format.Html.html_escape domain) 1382 + (Format.Html.html_escape shortened_path) 1337 1383 in 1384 + 1385 + (* Group all backlinks *) 1386 + let backlinks_html = List.map (fun (_, _username, author, post_title, post_link, date) -> 1387 + let date_str = Format.Html.format_date date in 1388 + let post_link_html = match post_link with 1389 + | Some uri -> 1390 + Printf.sprintf {|<a href="%s" title="%s by %s on %s">%s</a>|} 1391 + (Format.Html.html_escape (Uri.to_string uri)) 1392 + (Format.Html.html_escape post_title) 1393 + (Format.Html.html_escape author) 1394 + date_str 1395 + (Format.Html.html_escape post_title) 1396 + | None -> Format.Html.html_escape post_title 1397 + in 1398 + Printf.sprintf {|<span class="link-backlink"><span class="link-backlink-icon">↩</span>%s</span>|} 1399 + post_link_html 1400 + ) all_entries |> String.concat "" in 1401 + 1338 1402 Printf.sprintf {|<div class="link-item"> 1339 1403 <div class="link-url"><a href="%s">%s</a></div> 1340 - <div class="link-meta">From %s by <a href="authors/%s.html">%s</a> on %s%s</div> 1404 + <div class="link-backlinks">%s</div> 1341 1405 </div>|} 1342 1406 (Format.Html.html_escape href) 1343 - (Format.Html.html_escape display_text) 1344 - post_link_html 1345 - (Format.Html.html_escape (sanitize_filename username)) 1346 - (Format.Html.html_escape author) 1347 - date_str 1348 - count_str 1407 + display_text 1408 + backlinks_html 1349 1409 ) sorted_links in 1350 1410 String.concat "\n" items 1351 1411 in