+1
.gitignore
+1
.gitignore
···
1
+
tangled-rss
+21
LICENSE
+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
+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
+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
+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
+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
+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 "<";
72
+
case ">":
73
+
return ">";
74
+
case "&":
75
+
return "&";
76
+
case "'":
77
+
return "'";
78
+
case '"':
79
+
return """;
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);