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

Configure Feed

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

fix: use publication base_path for correct leaflet urls

- store publication_uri on documents, base_path on publications
- join in search query to return base_path with results
- frontend builds urls as https://{basePath}/{rkey}
- add shareable search urls via ?q= query param
- add source link to tangled repo

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

zzstoatzz 6e7c7293 53751166

+48 -54
+22 -10
backend/src/db.zig
··· 36 36 \\ rkey TEXT NOT NULL, 37 37 \\ title TEXT NOT NULL, 38 38 \\ content TEXT NOT NULL, 39 - \\ created_at TEXT 39 + \\ created_at TEXT, 40 + \\ publication_uri TEXT 40 41 \\) 41 42 ); 42 43 ··· 54 55 \\ did TEXT NOT NULL, 55 56 \\ rkey TEXT NOT NULL, 56 57 \\ name TEXT NOT NULL, 57 - \\ description TEXT 58 + \\ description TEXT, 59 + \\ base_path TEXT 58 60 \\) 59 61 ); 60 62 63 + // migrate: add columns if missing 64 + _ = execSqlNoArgs("ALTER TABLE documents ADD COLUMN publication_uri TEXT") catch {}; 65 + _ = execSqlNoArgs("ALTER TABLE publications ADD COLUMN base_path TEXT") catch {}; 66 + 61 67 std.debug.print("turso schema initialized with FTS5\n", .{}); 62 68 } 63 69 64 - pub fn insertDocument(uri: []const u8, did: []const u8, rkey: []const u8, title: []const u8, content: []const u8, created_at: ?[]const u8) !void { 70 + pub fn insertDocument(uri: []const u8, did: []const u8, rkey: []const u8, title: []const u8, content: []const u8, created_at: ?[]const u8, publication_uri: ?[]const u8) !void { 65 71 _ = try execSqlWithArgs( 66 - "INSERT OR REPLACE INTO documents (uri, did, rkey, title, content, created_at) VALUES (?, ?, ?, ?, ?, ?)", 67 - &[_][]const u8{ uri, did, rkey, title, content, created_at orelse "" }, 72 + "INSERT OR REPLACE INTO documents (uri, did, rkey, title, content, created_at, publication_uri) VALUES (?, ?, ?, ?, ?, ?, ?)", 73 + &[_][]const u8{ uri, did, rkey, title, content, created_at orelse "", publication_uri orelse "" }, 68 74 ); 69 75 70 76 // delete from fts first (ignore errors) ··· 78 84 }; 79 85 } 80 86 81 - pub fn insertPublication(uri: []const u8, did: []const u8, rkey: []const u8, name: []const u8, description: ?[]const u8) !void { 87 + pub fn insertPublication(uri: []const u8, did: []const u8, rkey: []const u8, name: []const u8, description: ?[]const u8, base_path: ?[]const u8) !void { 82 88 _ = try execSqlWithArgs( 83 - "INSERT OR REPLACE INTO publications (uri, did, rkey, name, description) VALUES (?, ?, ?, ?, ?)", 84 - &[_][]const u8{ uri, did, rkey, name, description orelse "" }, 89 + "INSERT OR REPLACE INTO publications (uri, did, rkey, name, description, base_path) VALUES (?, ?, ?, ?, ?, ?)", 90 + &[_][]const u8{ uri, did, rkey, name, description orelse "", base_path orelse "" }, 85 91 ); 86 92 } 87 93 ··· 101 107 const temp_alloc = gpa.allocator(); 102 108 103 109 const result = execSqlWithArgs( 104 - "SELECT f.uri, d.did, d.title, snippet(documents_fts, 2, '<mark>', '</mark>', '...', 32) as snippet, d.created_at FROM documents_fts f JOIN documents d ON f.uri = d.uri WHERE documents_fts MATCH ? ORDER BY rank LIMIT 50", 110 + "SELECT f.uri, d.did, d.title, snippet(documents_fts, 2, '<mark>', '</mark>', '...', 32) as snippet, d.created_at, d.rkey, p.base_path FROM documents_fts f JOIN documents d ON f.uri = d.uri LEFT JOIN publications p ON d.publication_uri = p.uri WHERE documents_fts MATCH ? ORDER BY rank LIMIT 50", 105 111 &[_][]const u8{query}, 106 112 ) catch { 107 113 try response.appendSlice(alloc, "]"); ··· 165 171 for (rows.array.items) |row| { 166 172 if (row != .array) continue; 167 173 const cols = row.array.items; 168 - if (cols.len < 5) continue; 174 + if (cols.len < 7) continue; 169 175 170 176 if (!first) try response.appendSlice(alloc, ","); 171 177 first = false; ··· 175 181 const title = getTextValue(cols[2]); 176 182 const snippet = getTextValue(cols[3]); 177 183 const created_at = getTextValue(cols[4]); 184 + const rkey = getTextValue(cols[5]); 185 + const base_path = getTextValue(cols[6]); 178 186 179 187 try response.appendSlice(alloc, "{\"uri\":\""); 180 188 try appendEscaped(alloc, &response, uri); ··· 186 194 try appendEscaped(alloc, &response, snippet); 187 195 try response.appendSlice(alloc, "\",\"createdAt\":\""); 188 196 try appendEscaped(alloc, &response, created_at); 197 + try response.appendSlice(alloc, "\",\"rkey\":\""); 198 + try appendEscaped(alloc, &response, rkey); 199 + try response.appendSlice(alloc, "\",\"basePath\":\""); 200 + try appendEscaped(alloc, &response, base_path); 189 201 try response.appendSlice(alloc, "\"}"); 190 202 } 191 203
+18 -3
backend/src/tap.zig
··· 157 157 if (title_val != .string) return; 158 158 const title = title_val.string; 159 159 160 + // get publication URI 161 + const publication_uri: ?[]const u8 = blk: { 162 + if (record.get("publication")) |v| { 163 + if (v == .string) break :blk v.string; 164 + } 165 + break :blk null; 166 + }; 167 + 160 168 // get createdAt (optional, might be publishedAt) 161 169 const created_at: ?[]const u8 = blk: { 162 170 if (record.get("publishedAt")) |v| { ··· 193 201 return; 194 202 } 195 203 196 - try db.insertDocument(uri, did, rkey, title, content_buf.items, created_at); 204 + try db.insertDocument(uri, did, rkey, title, content_buf.items, created_at, publication_uri); 197 205 std.debug.print("indexed document: {s} ({} chars)\n", .{ uri, content_buf.items.len }); 198 206 } 199 207 ··· 290 298 break :blk null; 291 299 }; 292 300 293 - try db.insertPublication(uri, did, rkey, name, description); 294 - std.debug.print("indexed publication: {s}\n", .{uri}); 301 + const base_path: ?[]const u8 = blk: { 302 + if (record.get("base_path")) |v| { 303 + if (v == .string) break :blk v.string; 304 + } 305 + break :blk null; 306 + }; 307 + 308 + try db.insertPublication(uri, did, rkey, name, description, base_path); 309 + std.debug.print("indexed publication: {s} (base_path: {s})\n", .{ uri, base_path orelse "none" }); 295 310 }
+8 -41
site/index.html
··· 202 202 } 203 203 204 204 let html = `<div style="font-size:0.7rem;color:#404040;margin-bottom:1rem;padding:0.5rem;background:#111;border-radius:4px"> 205 - query: <code>${escapeHtml(query)}</code> | url: <code>${searchUrl}</code> | ${results.length} results 205 + query: <code>${escapeHtml(query)}</code> | ${results.length} results 206 206 </div>`; 207 207 208 - // resolve all unique DIDs in parallel 209 - const uniqueDids = [...new Set(results.map(d => parseUri(d.uri).did))]; 210 - await Promise.all(uniqueDids.map(did => resolveHandle(did))); 211 - 212 208 for (const doc of results) { 213 - const { did, rkey } = parseUri(doc.uri); 214 - const handle = handleCache.get(did); 215 - const leafletUrl = handle ? buildLeafletUrl(handle, rkey) : `https://leaflet.pub/${rkey}`; 209 + const leafletUrl = doc.basePath && doc.rkey 210 + ? `https://${doc.basePath}/${doc.rkey}` 211 + : null; 216 212 const date = doc.createdAt ? new Date(doc.createdAt).toLocaleDateString() : ''; 217 213 html += ` 218 214 <div class="result"> 219 215 <div class="result-title"> 220 - <a href="${leafletUrl}" target="_blank">${escapeHtml(doc.title || 'Untitled')}</a> 216 + ${leafletUrl 217 + ? `<a href="${leafletUrl}" target="_blank">${escapeHtml(doc.title || 'Untitled')}</a>` 218 + : escapeHtml(doc.title || 'Untitled')} 221 219 </div> 222 220 <div class="result-snippet">${doc.snippet || ''}</div> 223 221 <div class="result-meta"> 224 - ${date ? `${date} | ` : ''}${handle ? `@${handle} | ` : ''}uri: ${escapeHtml(doc.uri)} 222 + ${date ? `${date} | ` : ''}${doc.basePath ? doc.basePath : 'unpublished'} 225 223 </div> 226 224 </div> 227 225 `; ··· 235 233 } finally { 236 234 searchBtn.disabled = false; 237 235 } 238 - } 239 - 240 - // cache DID -> handle resolutions 241 - const handleCache = new Map(); 242 - 243 - async function resolveHandle(did) { 244 - if (handleCache.has(did)) return handleCache.get(did); 245 - try { 246 - const res = await fetch(`https://plc.directory/${did}`); 247 - const doc = await res.json(); 248 - // alsoKnownAs contains at://handle entries 249 - const aka = doc.alsoKnownAs?.find(u => u.startsWith('at://')); 250 - const handle = aka ? aka.replace('at://', '') : null; 251 - handleCache.set(did, handle); 252 - return handle; 253 - } catch { 254 - handleCache.set(did, null); 255 - return null; 256 - } 257 - } 258 - 259 - function parseUri(uri) { 260 - // at://did:plc:xxx/pub.leaflet.document/rkey 261 - const parts = uri.replace('at://', '').split('/'); 262 - return { did: parts[0], collection: parts[1], rkey: parts[2] }; 263 - } 264 - 265 - function buildLeafletUrl(handle, rkey) { 266 - // handle like "nate.io" -> nate (subdomain is first part before .) 267 - const subdomain = handle.split('.')[0]; 268 - return `https://${subdomain}.leaflet.pub/${rkey}`; 269 236 } 270 237 271 238 function escapeHtml(str) {