Follow Tangled repos/knots In RSS

chore: initial commit

hrbrmstr c37409fc

+1
.gitignore
··· 1 + tangled-rss
+21
LICENSE
··· 1 + The MIT License (MIT) 2 + 3 + Copyright (c) 2025 boB Rudis 4 + 5 + Permission is hereby granted, free of charge, to any person obtaining a copy 6 + of this software and associated documentation files (the "Software"), to deal 7 + in the Software without restriction, including without limitation the rights 8 + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 + copies of the Software, and to permit persons to whom the Software is 10 + furnished to do so, subject to the following conditions: 11 + 12 + The above copyright notice and this permission notice shall be included in all 13 + copies or substantial portions of the Software. 14 + 15 + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 + SOFTWARE.
+73
README.md
··· 1 + # Tangled RSS 2 + 3 + ## Overview 4 + 5 + Tangled RSS is a simple application that generates RSS feeds for [Tangled.sh](https://tangled.sh) repositories, making it easy to follow updates from specific users on the Bluesky network. It works by resolving Bluesky handles to DIDs, fetching all records from a user's Tangled repository, and generating an RSS feed. 6 + 7 + ## Features 8 + 9 + - Convert Bluesky handles to RSS feeds for Tangled repositories 10 + - Sort entries by date (most recent first) 11 + - Properly escaped XML output 12 + - Simple API interface 13 + 14 + ## Installation 15 + 16 + You'll need [Deno](https://deno.land) installed on your system. 17 + 18 + 1. Clone this repository 19 + 2. Navigate to the project directory 20 + 21 + ## Usage 22 + 23 + ### Running the application 24 + 25 + ```bash 26 + # Start the application 27 + just start 28 + 29 + # Run in development mode with auto-reload 30 + just dev 31 + 32 + # Build a standalone executable 33 + just build 34 + ``` 35 + 36 + ### API Endpoints 37 + 38 + Access RSS feeds by making a GET request to: 39 + 40 + ``` 41 + http://localhost:8000/{bluesky_handle} 42 + ``` 43 + 44 + For example: 45 + ``` 46 + http://localhost:8000/alice.bsky.social 47 + ``` 48 + 49 + ### Environment Variables 50 + 51 + - `PORT`: The port on which the server will run (default: 8000) 52 + 53 + ## How It Works 54 + 55 + 1. The application takes a Bluesky handle as input 56 + 2. Resolves the handle to a DID using Bluesky's API 57 + 3. Fetches the DID document from PLC directory 58 + 4. Uses the service endpoint to retrieve all Tangled repositories 59 + 5. Generates an RSS feed with the repositories sorted by date 60 + 61 + ## Development 62 + 63 + This application is built with: 64 + - [Deno](https://deno.land) - A secure JavaScript and TypeScript runtime 65 + - [Hono](https://hono.dev) - A lightweight web framework 66 + 67 + ## License 68 + 69 + MIT 70 + 71 + ## Contributing 72 + 73 + Contributions are welcome! Please feel free to submit a Pull Request.
+14
deno.json
··· 1 + { 2 + "imports": { 3 + "hono": "jsr:@hono/hono@^4.7.5" 4 + }, 5 + "tasks": { 6 + "start": "deno run --allow-net main.ts", 7 + "dev": "deno run --watch --allopw-env --allow-net main.ts", 8 + "build": " deno compile --allow-net --allow-env --output tangled-rss main.ts" 9 + }, 10 + "compilerOptions": { 11 + "jsx": "precompile", 12 + "jsxImportSource": "hono/jsx" 13 + } 14 + }
+16
deno.lock
··· 1 + { 2 + "version": "4", 3 + "specifiers": { 4 + "jsr:@hono/hono@^4.7.5": "4.7.5" 5 + }, 6 + "jsr": { 7 + "@hono/hono@4.7.5": { 8 + "integrity": "36a7e1b3db8a58e5dc2bd36a76be53346f0966e04c24c635c4d6f58875575b0a" 9 + } 10 + }, 11 + "workspace": { 12 + "dependencies": [ 13 + "jsr:@hono/hono@^4.7.5" 14 + ] 15 + } 16 + }
+15
justfile
··· 1 + # Default task: list all available tasks 2 + default: 3 + @just --list 4 + 5 + # Start the application 6 + start: 7 + deno run --allow-net main.ts 8 + 9 + # Run the application in development mode with watching 10 + dev: 11 + deno run --watch --allow-env --allow-net main.ts 12 + 13 + # Build the application into an executable 14 + build: 15 + deno compile --allow-net --allow-env --output tangled-rss main.ts
+119
main.ts
··· 1 + import { Hono } from "hono"; 2 + 3 + const app = new Hono(); 4 + 5 + app.get("/:handle?", async (c) => { 6 + const handle = c.req.param("handle"); 7 + 8 + if (!handle) { 9 + return c.text("Please provide a Bluesky handle", 400); 10 + } 11 + 12 + try { 13 + // Resolve handle to DID 14 + const handleResolveResponse = await fetch( 15 + `https://bsky.social/xrpc/com.atproto.identity.resolveHandle?handle=${handle}`, 16 + ); 17 + const { did } = await handleResolveResponse.json(); 18 + 19 + // Fetch DID document 20 + const didDocResponse = await fetch(`https://plc.directory/${did}`); 21 + const didDoc = await didDocResponse.json(); 22 + const serviceEndpoint = didDoc.service[0].serviceEndpoint; 23 + 24 + // Fetch all records 25 + const allRecords = await fetchAllRecords(serviceEndpoint, did); 26 + 27 + // Generate RSS feed 28 + const selfUrl = new URL(c.req.url).toString(); 29 + const rssFeed = generateRSSFeed(handle, allRecords, selfUrl); 30 + 31 + return new Response(rssFeed, { 32 + headers: { 33 + "Content-Type": "application/xml; charset=utf-8", 34 + }, 35 + }); 36 + } catch (error: any) { 37 + const msg = (error as Error).message; 38 + return c.text(`Error processing request: ${msg}`, 500); 39 + } 40 + }); 41 + 42 + async function fetchAllRecords(serviceEndpoint: string, did: string): Promise<any[]> { 43 + const allRecords: any[] = []; 44 + let cursor = ""; 45 + 46 + while (true) { 47 + const url = new URL(`${serviceEndpoint}/xrpc/com.atproto.repo.listRecords`); 48 + url.searchParams.set("repo", did); 49 + url.searchParams.set("collection", "sh.tangled.repo"); 50 + url.searchParams.set("limit", "100"); 51 + if (cursor) url.searchParams.set("cursor", cursor); 52 + 53 + const response = await fetch(url.toString()); 54 + const data = await response.json(); 55 + 56 + if (!data.records || data.records.length === 0) break; 57 + 58 + allRecords.push(...data.records); 59 + cursor = data.cursor || ""; 60 + 61 + if (!cursor) break; 62 + } 63 + 64 + return allRecords.sort((a, b) => new Date(b.value.addedAt).getTime() - new Date(a.value.addedAt).getTime()); 65 + } 66 + 67 + function escapeXml(unsafe: string): string { 68 + return unsafe.replace(/[<>&'"]/g, function (c) { 69 + switch (c) { 70 + case "<": 71 + return "&lt;"; 72 + case ">": 73 + return "&gt;"; 74 + case "&": 75 + return "&amp;"; 76 + case "'": 77 + return "&apos;"; 78 + case '"': 79 + return "&quot;"; 80 + default: 81 + return c; 82 + } 83 + }); 84 + } 85 + 86 + function generateRSSFeed(handle: string, records: any[], selfUrl: string): string { 87 + const rssItems = records 88 + .map((record) => { 89 + const { name, description, addedAt } = record.value; 90 + const itemLink = `https://tangled.sh/@${handle}/${name}`; 91 + return ` 92 + <item> 93 + <title>${escapeXml(name)}</title> 94 + <description>${escapeXml(description)}</description> 95 + <link>${itemLink}</link> 96 + <pubDate>${new Date(addedAt).toUTCString()}</pubDate> 97 + <guid isPermaLink="true">${itemLink}</guid> 98 + </item> 99 + `; 100 + }) 101 + .join("\n"); 102 + 103 + return `<?xml version="1.0" encoding="UTF-8" ?> 104 + <rss version="2.0" 105 + xmlns:atom="http://www.w3.org/2005/Atom" 106 + xmlns:content="http://purl.org/rss/1.0/modules/content/"> 107 + <channel> 108 + <title>${escapeXml(`${handle}'s Tangled Repos`)}</title> 109 + <link>https://tangled.sh/@${handle}</link> 110 + <description>Repositories for ${escapeXml(handle)}</description> 111 + <atom:link href="${escapeXml(selfUrl)}" rel="self" type="application/rss+xml" /> 112 + ${rssItems} 113 + </channel> 114 + </rss>`; 115 + } 116 + 117 + const port = parseInt(Deno.env.get("PORT") || "8000"); 118 + 119 + Deno.serve({ port }, app.fetch);