search for standard sites pub-search.waow.tech
search zig blog atproto

feat: compute url server-side instead of in each client

backend buildDocUrl mirrors frontend logic (leaflet basePath+rkey,
basePath+path, leaflet.pub fallback, whtwnd.com fallback). MCP now
reads url from API response instead of computing it via a property.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

+55 -33
+52 -15
backend/src/server/search.zig
··· 34 34 source: []const u8 = "", 35 35 coverImage: []const u8 = "", 36 36 publicationName: []const u8 = "", 37 + url: []const u8 = "", 37 38 }; 38 39 39 40 /// Document search result (internal) ··· 85 86 }; 86 87 } 87 88 88 - fn toJson(self: Doc) SearchResultJson { 89 + fn toJson(self: Doc, alloc: Allocator) SearchResultJson { 90 + const doc_type: []const u8 = if (self.hasPublication) "article" else "looseleaf"; 89 91 return .{ 90 - .type = if (self.hasPublication) "article" else "looseleaf", 92 + .type = doc_type, 91 93 .uri = self.uri, 92 94 .did = self.did, 93 95 .title = self.title, ··· 99 101 .path = self.path, 100 102 .coverImage = self.coverImage, 101 103 .publicationName = self.publicationName, 104 + .url = buildDocUrl(alloc, doc_type, self.platform, self.basePath, self.path, self.rkey, self.did), 102 105 }; 103 106 } 104 107 }; 108 + 109 + /// Build canonical URL for a document/publication from its fields. 110 + /// Mirrors the frontend's buildDocUrl logic (site/index.html:1174). 111 + fn buildDocUrl(alloc: Allocator, doc_type: []const u8, platform: []const u8, base_path: []const u8, path: []const u8, rkey: []const u8, did: []const u8) []const u8 { 112 + // publication → https://{basePath} 113 + if (std.mem.eql(u8, doc_type, "publication") and base_path.len > 0) { 114 + return std.fmt.allocPrint(alloc, "https://{s}", .{base_path}) catch ""; 115 + } 116 + // leaflet + basePath + rkey → https://{basePath}/{rkey} 117 + if (std.mem.eql(u8, platform, "leaflet") and base_path.len > 0 and rkey.len > 0) { 118 + return std.fmt.allocPrint(alloc, "https://{s}/{s}", .{ base_path, rkey }) catch ""; 119 + } 120 + // basePath + path → https://{basePath}[/]{path} 121 + if (base_path.len > 0 and path.len > 0) { 122 + const sep: []const u8 = if (path[0] == '/') "" else "/"; 123 + return std.fmt.allocPrint(alloc, "https://{s}{s}{s}", .{ base_path, sep, path }) catch ""; 124 + } 125 + // leaflet fallback → https://leaflet.pub/p/{did}/{rkey} 126 + if (std.mem.eql(u8, platform, "leaflet") and did.len > 0 and rkey.len > 0) { 127 + return std.fmt.allocPrint(alloc, "https://leaflet.pub/p/{s}/{s}", .{ did, rkey }) catch ""; 128 + } 129 + // whitewind fallback → https://whtwnd.com/{did}/{rkey} 130 + if (std.mem.eql(u8, platform, "whitewind") and did.len > 0 and rkey.len > 0) { 131 + return std.fmt.allocPrint(alloc, "https://whtwnd.com/{s}/{s}", .{ did, rkey }) catch ""; 132 + } 133 + return ""; 134 + } 105 135 106 136 const DocsByTag = zql.Query( 107 137 \\SELECT d.uri, d.did, d.title, '' as snippet, ··· 348 378 }; 349 379 } 350 380 351 - fn toJson(self: Pub) SearchResultJson { 381 + fn toJson(self: Pub, alloc: Allocator) SearchResultJson { 352 382 return .{ 353 383 .type = "publication", 354 384 .uri = self.uri, ··· 358 388 .rkey = self.rkey, 359 389 .basePath = self.basePath, 360 390 .platform = self.platform, 391 + .url = buildDocUrl(alloc, "publication", self.platform, self.basePath, "", self.rkey, self.did), 361 392 }; 362 393 } 363 394 }; ··· 452 483 for (res.rows) |row| { 453 484 const doc = Doc.fromRow(row); 454 485 if (try isDuplicateAuthorTitle(&seen_authors, alloc, doc.did, doc.title)) continue; 455 - try jw.write(doc.toJson()); 486 + try jw.write(doc.toJson(alloc)); 456 487 } 457 488 } else { 458 489 var res = c.query(DocsByAuthor.positional, &.{author_filter.?}) catch { ··· 463 494 for (res.rows) |row| { 464 495 const doc = Doc.fromRow(row); 465 496 if (try isDuplicateAuthorTitle(&seen_authors, alloc, doc.did, doc.title)) continue; 466 - try jw.write(doc.toJson()); 497 + try jw.write(doc.toJson(alloc)); 467 498 } 468 499 } 469 500 try jw.endArray(); ··· 532 563 if (try isDuplicateAuthorTitle(&seen_authors, alloc, doc.did, doc.title)) continue; 533 564 const uri_dupe = try alloc.dupe(u8, doc.uri); 534 565 try seen_uris.put(uri_dupe, {}); 535 - try jw.write(doc.toJson()); 566 + try jw.write(doc.toJson(alloc)); 536 567 } 537 568 query_idx += 1; 538 569 } ··· 545 576 if (!std.mem.eql(u8, doc.did, af)) continue; 546 577 } 547 578 if (!seen_uris.contains(doc.uri) and !try isDuplicateAuthorTitle(&seen_authors, alloc, doc.did, doc.title)) { 548 - try jw.write(doc.toJson()); 579 + try jw.write(doc.toJson(alloc)); 549 580 } 550 581 } 551 582 query_idx += 1; ··· 558 589 if (author_filter) |af| { 559 590 if (!std.mem.eql(u8, pub_result.did, af)) continue; 560 591 } 561 - try jw.write(pub_result.toJson()); 592 + try jw.write(pub_result.toJson(alloc)); 562 593 } 563 594 } 564 595 ··· 622 653 if (try isDuplicateAuthorTitle(&seen_authors, alloc, doc.did, doc.title)) continue; 623 654 const uri_dupe = try alloc.dupe(u8, doc.uri); 624 655 try seen_uris.put(uri_dupe, {}); 625 - try jw.write(doc.toJson()); 656 + try jw.write(doc.toJson(alloc)); 626 657 } 627 658 628 659 // base_path search with platform ··· 649 680 if (doc.createdAt.len > 0 and std.mem.order(u8, doc.createdAt, since) == .lt) continue; 650 681 } 651 682 if (!seen_uris.contains(doc.uri) and !try isDuplicateAuthorTitle(&seen_authors, alloc, doc.did, doc.title)) { 652 - try jw.write(doc.toJson()); 683 + try jw.write(doc.toJson(alloc)); 653 684 } 654 685 } 655 686 } else { ··· 684 715 if (try isDuplicateAuthorTitle(&seen_authors, alloc, doc.did, doc.title)) continue; 685 716 const uri_dupe = try alloc.dupe(u8, doc.uri); 686 717 try seen_uris.put(uri_dupe, {}); 687 - try jw.write(doc.toJson()); 718 + try jw.write(doc.toJson(alloc)); 688 719 doc_count += 1; 689 720 } 690 721 logfire.info("search.iterate.docs_fts rows={d}", .{doc_count}); ··· 721 752 } 722 753 } 723 754 if (!seen_uris.contains(doc.uri) and !try isDuplicateAuthorTitle(&seen_authors, alloc, doc.did, doc.title)) { 724 - try jw.write(doc.toJson()); 755 + try jw.write(doc.toJson(alloc)); 725 756 } 726 757 bp_count += 1; 727 758 } ··· 749 780 if (author_filter) |af| { 750 781 if (!std.mem.eql(u8, pub_result.did, af)) { pub_count += 1; continue; } 751 782 } 752 - try jw.write(pub_result.toJson()); 783 + try jw.write(pub_result.toJson(alloc)); 753 784 pub_count += 1; 754 785 } 755 786 logfire.info("search.iterate.pubs_fts rows={d}", .{pub_count}); ··· 892 923 if (std.mem.eql(u8, r.uri, uri)) continue; 893 924 if (count >= limit) break; 894 925 if (try isDuplicateAuthorTitle(&seen_authors, alloc, r.did, r.title)) continue; 926 + const doc_type: []const u8 = if (r.has_publication) "article" else "looseleaf"; 895 927 try jw.write(SearchResultJson{ 896 - .type = if (r.has_publication) "article" else "looseleaf", 928 + .type = doc_type, 897 929 .uri = r.uri, 898 930 .did = r.did, 899 931 .title = r.title, ··· 905 937 .path = r.path, 906 938 .coverImage = extras.cover_images.get(r.uri) orelse "", 907 939 .publicationName = extras.pub_names.get(r.uri) orelse "", 940 + .url = buildDocUrl(alloc, doc_type, r.platform, r.base_path, r.path, r.rkey, r.did), 908 941 }); 909 942 count += 1; 910 943 } ··· 1130 1163 }; 1131 1164 try jw.objectField("publicationName"); 1132 1165 try jw.write(pub_name); 1166 + try jw.objectField("url"); 1167 + try jw.write(buildDocUrl(alloc, jsonStr(obj, "type"), jsonStr(obj, "platform"), jsonStr(obj, "basePath"), jsonStr(obj, "path"), jsonStr(obj, "rkey"), jsonStr(obj, "did"))); 1133 1168 try jw.objectField("createdAt"); 1134 1169 try jw.write(jsonStr(obj, "createdAt")); 1135 1170 try jw.objectField("source"); ··· 1228 1263 try jw.beginArray(); 1229 1264 for (filtered_indices[0..filtered_count]) |idx| { 1230 1265 const r = results[idx]; 1266 + const doc_type: []const u8 = if (r.has_publication) "article" else "looseleaf"; 1231 1267 try jw.write(SearchResultJson{ 1232 - .type = if (r.has_publication) "article" else "looseleaf", 1268 + .type = doc_type, 1233 1269 .uri = r.uri, 1234 1270 .did = r.did, 1235 1271 .title = r.title, ··· 1241 1277 .path = r.path, 1242 1278 .coverImage = extras.cover_images.get(r.uri) orelse "", 1243 1279 .publicationName = extras.pub_names.get(r.uri) orelse "", 1280 + .url = buildDocUrl(alloc, doc_type, r.platform, r.base_path, r.path, r.rkey, r.did), 1244 1281 }); 1245 1282 } 1246 1283 try jw.endArray();
+2 -18
mcp/src/pub_search/_types.py
··· 2 2 3 3 from typing import Literal 4 4 5 - from pydantic import BaseModel, computed_field 5 + from pydantic import BaseModel 6 6 7 7 8 8 class SearchResult(BaseModel): ··· 21 21 source: str = "" 22 22 score: float = 0.0 23 23 publicationName: str = "" 24 - 25 - @computed_field 26 - @property 27 - def url(self) -> str: 28 - """web URL for this document.""" 29 - if self.type == "publication" and self.basePath: 30 - return f"https://{self.basePath}" 31 - if self.platform == "leaflet" and self.basePath and self.rkey: 32 - return f"https://{self.basePath}/{self.rkey}" 33 - if self.basePath and self.path: 34 - sep = "" if self.path.startswith("/") else "/" 35 - return f"https://{self.basePath}{sep}{self.path}" 36 - if self.platform == "leaflet" and self.did and self.rkey: 37 - return f"https://leaflet.pub/p/{self.did}/{self.rkey}" 38 - if self.uri: 39 - return f"https://pdsls.dev/{self.uri}" 40 - return "" 24 + url: str = "" 41 25 42 26 43 27 class Tag(BaseModel):
+1
mcp/tests/test_mcp.py
··· 25 25 rkey="123", 26 26 basePath="gyst.leaflet.pub", 27 27 platform="leaflet", 28 + url="https://gyst.leaflet.pub/123", 28 29 ) 29 30 assert r.type == "article" 30 31 assert r.uri == "at://did:plc:abc/pub.leaflet.document/123"