An HTML-only Bluesky frontend

go initial commit

+771 -840
-11
.build.yml
··· 1 - image: alpine/edge 2 - secrets: 3 - - f366526b-fdc5-489e-927d-8b26ff8aad4a 4 - environment: 5 - REPO: htmlsky 6 - GH_USER: jordanreger 7 - tasks: 8 - - push-to-github: | 9 - cd ~/"${REPO}" 10 - git config --global credential.helper store 11 - git push --mirror "https://github.com/${GH_USER}/${REPO}"
-2
.gitignore
··· 1 - *.swp 2 - .DS_Store
+16
Dockerfile
··· 1 + ARG GO_VERSION=1 2 + FROM golang:${GO_VERSION}-alpine as builder 3 + 4 + # fix x509 cert error 5 + RUN apk update && apk add ca-certificates 6 + 7 + WORKDIR /usr/src/app 8 + COPY go.mod go.sum ./ 9 + RUN go mod download && go mod verify 10 + COPY . . 11 + RUN go build -v -o /run-app . 12 + 13 + FROM alpine:latest 14 + 15 + COPY --from=builder /run-app /usr/local/bin/ 16 + CMD ["run-app"]
+11 -3
README.md
··· 1 1 # htmlsky 2 2 3 - An HTML-only [Bluesky](https://bsky.social) frontend. 3 + An HTML-only Bluesky frontend. 4 + 5 + Just replace [bsky.app](https://bsky.app) with [htmlsky.app](https://htmlsky.app)! 6 + 7 + Want JSON? `/raw/` 8 + Want embeds? `/embed/` 9 + 10 + ## Self-hosting 11 + 12 + Edit `fly.toml` to fit your needs. 4 13 5 14 ## Contributing 6 15 7 - Send patches/bug reports to <~jordanreger/htmlsky@lists.sr.ht> with the 8 - subject `[PATCH] {what you're fixing}` 16 + Send patches/bug reports to <~jordanreger/htmlsky-devel@lists.sr.ht>
+19
actor.go
··· 1 + package main 2 + 3 + import ( 4 + "bytes" 5 + "fmt" 6 + "html/template" 7 + 8 + "git.sr.ht/~jordanreger/bsky" 9 + ) 10 + 11 + func GetActorPage(actor bsky.Actor) string { 12 + t := template.Must(template.ParseFS(publicFiles, "public/*")) 13 + var actor_page bytes.Buffer 14 + err := t.ExecuteTemplate(&actor_page, "actor.html", actor) 15 + if err != nil { 16 + fmt.Println(err) 17 + } 18 + return actor_page.String() 19 + }
-281
actor.js
··· 1 - import { agent, getRelativeDate } from "./main.js"; 2 - import { getFacets } from "./facets.js"; 3 - 4 - const DateTimeFormat = new Intl.DateTimeFormat("en-US", { 5 - dateStyle: "short", 6 - timeStyle: "long", 7 - timeZone: "UTC", 8 - hour12: false, 9 - }); 10 - 11 - export default class Actor { 12 - actor; 13 - 14 - constructor(actor) { 15 - this.actor = actor; 16 - } 17 - 18 - async Profile(prevCursor) { 19 - const actor = this.actor; 20 - 21 - actor.displayName = actor.displayName ? actor.displayName : actor.handle; 22 - actor.handle = actor.handle !== "handle.invalid" ? actor.handle : actor.did; 23 - actor.avatar = actor.avatar ? actor.avatar : "/static/avatar.jpg"; 24 - 25 - actor.description = actor.description ? `<tr><td colspan="2"><p>${getFacets(actor.description)}</p></td></tr><tr><td colspan="2">&nbsp;</td></tr>` : ""; 26 - 27 - return ` 28 - <head> 29 - <meta name="color-scheme" content="light dark"> 30 - <meta name="viewport" content="width=device-width, initial-scale=1"> 31 - <title>${actor.displayName} (@${actor.handle}) &#8212; HTMLsky</title> 32 - </head> 33 - <table> 34 - <tr> 35 - <td valign="top" height="60" width="60"> 36 - <img src="${actor.avatar}" alt="${actor.displayName}'s avatar" height="60" width="60"> 37 - </td> 38 - <td> 39 - <h1> 40 - <span>${actor.displayName}</span><br> 41 - <small><small><small><a href="/profile/${actor.handle}/">@${actor.handle}</a></small></small></small> 42 - </h1> 43 - </td> 44 - </tr> 45 - ${actor.description} 46 - <tr> 47 - <td colspan="2"> 48 - <a href="/profile/${actor.handle}/followers/"><b>${actor.followersCount}</b> followers</a> 49 - <a href="/profile/${actor.handle}/follows/"><b>${actor.followsCount}</b> following</a> 50 - <a href="/profile/${actor.handle}/"><b>${actor.postsCount}</b> posts</a> 51 - </td> 52 - </tr> 53 - </table> 54 - <hr> 55 - `; 56 - } 57 - 58 - async Feed(prevCursor) { 59 - const actor = await this.actor; 60 - let options = { actor: actor.did }; 61 - if (prevCursor) { 62 - options = { actor: actor.did, cursor: prevCursor }; 63 - } 64 - const { data } = await agent.api.app.bsky.feed.getAuthorFeed(options); 65 - const { feed, cursor } = data; 66 - 67 - const feedList = []; 68 - 69 - for (const post of feed) { 70 - // add reply above post 71 - if (post.reply) { 72 - const reply = post.reply.parent ? post.reply.parent : post.reply.root; 73 - 74 - const rkey = reply.uri.split("/").at(-1); 75 - 76 - if (reply.notFound || reply.blocked) { 77 - feedList.push(` 78 - <tr><td> 79 - <table> 80 - <tr><td> 81 - Post not found. 82 - </td></tr> 83 - </table> 84 - </td></tr> 85 - `); 86 - } else { 87 - reply.author.displayName = reply.author.displayName ? reply.author.displayName : reply.author.handle; 88 - reply.author.handle = reply.author.handle !== "handle.invalid" ? reply.author.handle : reply.author.did; 89 - 90 - const text = reply.record.text ? `<p>${getFacets(reply.record.text, reply.record.facets)}</p>` : ""; 91 - feedList.push(` 92 - <tr> 93 - <td> 94 - <table> 95 - <tr><td> 96 - <b>${reply.author.displayName}</b><br><a href="/profile/${reply.author.handle}/">@${reply.author.handle}</a> 97 - / <a href="/profile/${reply.author.handle}/post/${rkey}/">${rkey}</a> 98 - </td></tr> 99 - <tr><td> 100 - ${text} 101 - </td></tr> 102 - <tr><td> 103 - <b>${reply.replyCount}</b> replies &middot; 104 - <b>${reply.repostCount}</b> reposts &middot; 105 - <b>${reply.likeCount}</b> likes 106 - &mdash; 107 - <time title="${DateTimeFormat.format(new Date(reply.record.createdAt))}"><i>${getRelativeDate(new Date(reply.record.createdAt))}</i></time> 108 - </td></tr> 109 - </table> 110 - </td> 111 - </tr> 112 - `); 113 - } 114 - } 115 - 116 - const record = post.post.record; 117 - const author = post.post.author; 118 - 119 - const rkey = post.post.uri.split("/").at(-1); 120 - 121 - // Embeds 122 - const embeds = []; 123 - if (record.embed) { 124 - const embedType = record.embed["$type"]; 125 - 126 - switch(embedType) { 127 - case "app.bsky.embed.images": 128 - embeds.push(`<ul>`); 129 - for (const image of record.embed.images) { 130 - // TODO: have a separate page for images with alt text and stuff? 131 - const embedURL = `https://cdn.bsky.app/img/feed_fullsize/plain/${author.did}/${image.image.ref}@${image.image.mimeType.split("/")[1]}`; 132 - embeds.push(`<li><a href="${embedURL}">${image.alt ? image.alt : "image"}</a></li>`); 133 - } 134 - embeds.push(`</ul>`); 135 - break; 136 - case "app.bsky.embed.record": 137 - // TODO: record embed 138 - break; 139 - case "app.bsky.embed.recordWithMedia": 140 - // TODO: recordWithMedia embed 141 - break; 142 - case "app.bsky.embed.external": 143 - // TODO: external embed 144 - default: 145 - embeds.push(`<p>Missing embed type ${embedType}; <a href="https://todo.sr.ht/~jordanreger/htmlsky">please make an issue</a>.</p>`); 146 - break; 147 - } 148 - } 149 - 150 - 151 - author.displayName = author.displayName ? author.displayName : author.handle; 152 - author.handle = author.handle !== "handle.invalid" ? author.handle : author.did; 153 - 154 - const text = record.text ? `<p>${getFacets(record.text, record.facets)}</p>` : ""; 155 - 156 - feedList.push(` 157 - <tr><td> 158 - ${post.reply ? `<blockquote>` : ``} 159 - <table> 160 - ${actor.did !== author.did ? `<tr><td><i>Reposted by ${actor.displayName}</i></td></tr>` : ``} 161 - <tr><td> 162 - <b>${author.displayName}</b><br><a href="/profile/${author.handle}/">@${author.handle}</a> 163 - / <a href="/profile/${author.handle}/post/${rkey}/">${rkey}</a> 164 - </td></tr> 165 - <tr><td> 166 - </td></tr> 167 - <tr><td> 168 - ${text} 169 - </td></tr> 170 - <tr><td> 171 - ${record.embed ? embeds.join("\n") : ``} 172 - </td></tr> 173 - <tr><td> 174 - <b>${post.post.replyCount}</b> replies &middot; 175 - <b>${post.post.repostCount}</b> reposts &middot; 176 - <b>${post.post.likeCount}</b> likes 177 - &mdash; 178 - <time title="${DateTimeFormat.format(new Date(record.createdAt))}"><i>${getRelativeDate(new Date(record.createdAt))}</i></time> 179 - </td></tr> 180 - </table> 181 - ${post.reply ? `</blockquote><hr>` : `<hr>`} 182 - </td></tr> 183 - `); 184 - } 185 - 186 - if (cursor) { 187 - feedList.push(` 188 - <tr><td> 189 - <br> 190 - <a href="?cursor=${cursor}">Next page</a> 191 - </td></tr> 192 - `); 193 - } 194 - 195 - 196 - return ` 197 - <table width="100%"> 198 - ${feedList.join("")} 199 - </table> 200 - `; 201 - } 202 - 203 - async Followers(prevCursor) { 204 - const actor = await this.actor; 205 - let options = { actor: actor.did }; 206 - if (prevCursor) { 207 - options = { actor: actor.did, cursor: prevCursor }; 208 - } 209 - 210 - const { data } = await agent.api.app.bsky.graph.getFollowers(options); 211 - const { followers, cursor } = data; 212 - 213 - const followersList = []; 214 - for (const follower of followers) { 215 - followersList.push(` 216 - <tr><td> 217 - <b>${follower.displayName ? follower.displayName : follower.handle}</b> (<a href="/profile/${follower.handle !== "handle.invalid" ? follower.handle : follower.did}/">@${follower.handle}</a>) 218 - </td></tr>`); 219 - } 220 - 221 - if (cursor) { 222 - followersList.push(` 223 - <tr><td> 224 - <br> 225 - <a href="?cursor=${cursor}">Next page</a> 226 - </td></tr> 227 - `); 228 - } 229 - 230 - return ` 231 - <head> 232 - <meta name="color-scheme" content="light dark"> 233 - <meta name="viewport" content="width=device-width, initial-scale=1"> 234 - <title>People following @${actor.handle} &#8212; HTMLsky</title> 235 - </head> 236 - <table> 237 - ${followersList.join("")} 238 - </table> 239 - `; 240 - } 241 - 242 - async Follows(prevCursor) { 243 - const actor = await this.actor; 244 - let options = { actor: actor.did }; 245 - if (prevCursor) { 246 - options = { actor: actor.did, cursor: prevCursor }; 247 - } 248 - 249 - const { data } = await agent.api.app.bsky.graph.getFollows(options); 250 - const { follows, cursor } = data; 251 - 252 - const followsList = []; 253 - for (const follow of follows) { 254 - followsList.push(` 255 - <tr><td> 256 - <b>${follow.displayName ? follow.displayName : follow.handle}</b> (<a href="/profile/${follow.handle !== "handle.invalid" ? follow.handle : follow.did}/">@${follow.handle}</a>) 257 - </td> 258 - </tr>`); 259 - } 260 - 261 - if (cursor) { 262 - followsList.push(` 263 - <tr><td> 264 - <br> 265 - <a href="?cursor=${cursor}">Next page</a> 266 - </td></tr> 267 - `); 268 - } 269 - 270 - return ` 271 - <head> 272 - <meta name="color-scheme" content="light dark"> 273 - <meta name="viewport" content="width=device-width, initial-scale=1"> 274 - <title>People followed by @${actor.handle} &#8212; HTMLsky</title> 275 - </head> 276 - <table> 277 - ${followsList.join("")} 278 - </table> 279 - `; 280 - } 281 - }
-6
deno.json
··· 1 - { 2 - "tasks": { 3 - "dev": "deno run -A --watch --quiet main.js", 4 - "clean": "deno fmt && deno lint" 5 - } 6 - }
-127
deno.lock
··· 1 - { 2 - "version": "3", 3 - "packages": { 4 - "specifiers": { 5 - "jsr:@std/cli@^0.224.7": "jsr:@std/cli@0.224.7", 6 - "jsr:@std/encoding@1.0.0-rc.2": "jsr:@std/encoding@1.0.0-rc.2", 7 - "jsr:@std/fmt@^0.225.4": "jsr:@std/fmt@0.225.4", 8 - "jsr:@std/http": "jsr:@std/http@0.224.5", 9 - "jsr:@std/media-types@^1.0.0-rc.1": "jsr:@std/media-types@1.0.0", 10 - "jsr:@std/net@^0.224.3": "jsr:@std/net@0.224.3", 11 - "jsr:@std/path@1.0.0-rc.2": "jsr:@std/path@1.0.0-rc.2", 12 - "jsr:@std/streams@^0.224.5": "jsr:@std/streams@0.224.5", 13 - "npm:@atproto/api": "npm:@atproto/api@0.12.8" 14 - }, 15 - "jsr": { 16 - "@std/cli@0.224.7": { 17 - "integrity": "654ca6477518e5e3a0d3fabafb2789e92b8c0febf1a1d24ba4b567aba94b5977" 18 - }, 19 - "@std/encoding@1.0.0-rc.2": { 20 - "integrity": "160d7674a20ebfbccdf610b3801fee91cf6e42d1c106dd46bbaf46e395cd35ef" 21 - }, 22 - "@std/fmt@0.225.4": { 23 - "integrity": "584c681cf422b70e28959b57e59012823609c087384cbf12d05f67814797fda3" 24 - }, 25 - "@std/http@0.224.5": { 26 - "integrity": "b03b5d1529f6c423badfb82f6640f9f2557b4034cd7c30655ba5bb447ff750a4", 27 - "dependencies": [ 28 - "jsr:@std/cli@^0.224.7", 29 - "jsr:@std/encoding@1.0.0-rc.2", 30 - "jsr:@std/fmt@^0.225.4", 31 - "jsr:@std/media-types@^1.0.0-rc.1", 32 - "jsr:@std/net@^0.224.3", 33 - "jsr:@std/path@1.0.0-rc.2", 34 - "jsr:@std/streams@^0.224.5" 35 - ] 36 - }, 37 - "@std/media-types@1.0.0": { 38 - "integrity": "58ff81ce1f7af8c46304a52482db804c42936492daad19c0bd5d887832737d2b" 39 - }, 40 - "@std/net@0.224.3": { 41 - "integrity": "a6257b9a35a4f299a0a9d4a4b76aef1f90ad05572374c5267c6c51a2ec41dfba" 42 - }, 43 - "@std/path@1.0.0-rc.2": { 44 - "integrity": "39f20d37a44d1867abac8d91c169359ea6e942237a45a99ee1e091b32b921c7d" 45 - }, 46 - "@std/streams@0.224.5": { 47 - "integrity": "bcde7818dd5460d474cdbd674b15f6638b9cd73cd64e52bd852fba2bd4d8ec91" 48 - } 49 - }, 50 - "npm": { 51 - "@atproto/api@0.12.8": { 52 - "integrity": "sha512-aNbiDuaslCxS3XyMRK40/ERerqAmk5HjQc7ivTBuPQy1Svmphl5ccnsUVxJ81xjpxjv9Fli2iPgomvRFdusuNQ==", 53 - "dependencies": { 54 - "@atproto/common-web": "@atproto/common-web@0.3.0", 55 - "@atproto/lexicon": "@atproto/lexicon@0.4.0", 56 - "@atproto/syntax": "@atproto/syntax@0.3.0", 57 - "@atproto/xrpc": "@atproto/xrpc@0.5.0", 58 - "multiformats": "multiformats@9.9.0", 59 - "tlds": "tlds@1.252.0" 60 - } 61 - }, 62 - "@atproto/common-web@0.3.0": { 63 - "integrity": "sha512-67VnV6JJyX+ZWyjV7xFQMypAgDmjVaR9ZCuU/QW+mqlqI7fex2uL4Fv+7/jHadgzhuJHVd6OHOvNn0wR5WZYtA==", 64 - "dependencies": { 65 - "graphemer": "graphemer@1.4.0", 66 - "multiformats": "multiformats@9.9.0", 67 - "uint8arrays": "uint8arrays@3.0.0", 68 - "zod": "zod@3.23.7" 69 - } 70 - }, 71 - "@atproto/lexicon@0.4.0": { 72 - "integrity": "sha512-RvCBKdSI4M8qWm5uTNz1z3R2yIvIhmOsMuleOj8YR6BwRD+QbtUBy3l+xQ7iXf4M5fdfJFxaUNa6Ty0iRwdKqQ==", 73 - "dependencies": { 74 - "@atproto/common-web": "@atproto/common-web@0.3.0", 75 - "@atproto/syntax": "@atproto/syntax@0.3.0", 76 - "iso-datestring-validator": "iso-datestring-validator@2.2.2", 77 - "multiformats": "multiformats@9.9.0", 78 - "zod": "zod@3.23.7" 79 - } 80 - }, 81 - "@atproto/syntax@0.3.0": { 82 - "integrity": "sha512-Weq0ZBxffGHDXHl9U7BQc2BFJi/e23AL+k+i5+D9hUq/bzT4yjGsrCejkjq0xt82xXDjmhhvQSZ0LqxyZ5woxA==", 83 - "dependencies": {} 84 - }, 85 - "@atproto/xrpc@0.5.0": { 86 - "integrity": "sha512-swu+wyOLvYW4l3n+VAuJbHcPcES+tin2Lsrp8Bw5aIXIICiuFn1YMFlwK9JwVUzTH21Py1s1nHEjr4CJeElJog==", 87 - "dependencies": { 88 - "@atproto/lexicon": "@atproto/lexicon@0.4.0", 89 - "zod": "zod@3.23.7" 90 - } 91 - }, 92 - "graphemer@1.4.0": { 93 - "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", 94 - "dependencies": {} 95 - }, 96 - "iso-datestring-validator@2.2.2": { 97 - "integrity": "sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA==", 98 - "dependencies": {} 99 - }, 100 - "multiformats@9.9.0": { 101 - "integrity": "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==", 102 - "dependencies": {} 103 - }, 104 - "tlds@1.252.0": { 105 - "integrity": "sha512-GA16+8HXvqtfEnw/DTcwB0UU354QE1n3+wh08oFjr6Znl7ZLAeUgYzCcK+/CCrOyE0vnHR8/pu3XXG3vDijXpQ==", 106 - "dependencies": {} 107 - }, 108 - "uint8arrays@3.0.0": { 109 - "integrity": "sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA==", 110 - "dependencies": { 111 - "multiformats": "multiformats@9.9.0" 112 - } 113 - }, 114 - "zod@3.23.7": { 115 - "integrity": "sha512-NBeIoqbtOiUMomACV/y+V3Qfs9+Okr18vR5c/5pHClPpufWOrsx8TENboDPe265lFdfewX2yBtNTLPvnmCxwog==", 116 - "dependencies": {} 117 - } 118 - } 119 - }, 120 - "remote": { 121 - "https://deno.land/x/ammonia@0.3.1/mod.ts": "170075af1b2e2922b2f1229bac4acba5cb824b10e97de4604da55ea492db2e26", 122 - "https://deno.land/x/ammonia@0.3.1/pkg/ammonia_wasm.js": "75a90cc78b52f1f2e4e998c1b574f97097de2d2ee7f3a55dca562c4f93a618e0", 123 - "https://deno.land/x/ammonia@0.3.1/wasm.js": "60a03b400d2ff529d2d3a0a804f10abe564d5ffaa1bf8344c2c27799088f514e", 124 - "https://deno.land/x/lz4@v0.1.2/mod.ts": "4decfc1a3569d03fd1813bd39128b71c8f082850fe98ecfdde20025772916582", 125 - "https://deno.land/x/lz4@v0.1.2/wasm.js": "b9c65605327ba273f0c76a6dc596ec534d4cda0f0225d7a94ebc606782319e46" 126 - } 127 - }
+29
embed.go
··· 1 + package main 2 + 3 + import ( 4 + "bytes" 5 + "html/template" 6 + 7 + "git.sr.ht/~jordanreger/bsky" 8 + ) 9 + 10 + func GetActorPageEmbed(actor bsky.Actor) string { 11 + t := template.Must(template.ParseFS(publicFiles, "public/*")) 12 + var actor_page bytes.Buffer 13 + t.ExecuteTemplate(&actor_page, "actor.embed.html", actor) 14 + return actor_page.String() 15 + } 16 + 17 + func GetThreadPageEmbed(thread bsky.Thread) string { 18 + t := template.Must(template.ParseFS(publicFiles, "public/*")) 19 + var thread_page bytes.Buffer 20 + t.ExecuteTemplate(&thread_page, "thread.embed.html", thread) 21 + return thread_page.String() 22 + } 23 + 24 + func GetListPageEmbed(list bsky.List) string { 25 + t := template.Must(template.ParseFS(publicFiles, "public/*")) 26 + var list_page bytes.Buffer 27 + t.ExecuteTemplate(&list_page, "list.embed.html", list) 28 + return list_page.String() 29 + }
-23
facets.js
··· 1 - import { RichText } from "npm:@atproto/api"; 2 - import * as ammonia from "https://deno.land/x/ammonia@0.3.1/mod.ts"; 3 - import { agent } from "./main.js"; 4 - 5 - await ammonia.init(); 6 - 7 - export function getFacets(text) { 8 - const rt = new RichText({ text: text }); 9 - rt.detectFacetsWithoutResolution(agent); 10 - 11 - let res = ""; 12 - for (const segment of rt.segments()) { 13 - if (segment.isLink()) res += `<a href="${segment.link.uri}">${segment.text}</a>`; 14 - else if (segment.isMention()) res += `<a href="/profile/${segment.mention.did}/">${segment.text}</a>`; 15 - else if (segment.isTag()) res += `<a href="https://bsky.app/hashtag/${segment.tag.tag}">${segment.text}</a>`; 16 - else res += segment.text; 17 - } 18 - 19 - res = ammonia.clean(res); 20 - res = res.replaceAll("\n", "<br>"); 21 - 22 - return res; 23 - }
+26
fly.toml
··· 1 + # fly.toml app configuration file generated for htmlsky on 2024-06-08T16:01:00Z 2 + # 3 + # See https://fly.io/docs/reference/configuration/ for information about how to use this file. 4 + # 5 + 6 + app = 'htmlsky' 7 + primary_region = 'iad' 8 + 9 + [build] 10 + [build.args] 11 + GO_VERSION = '1.22.1' 12 + 13 + [env] 14 + PORT = '8080' 15 + 16 + [http_service] 17 + internal_port = 8080 18 + auto_stop_machines = true 19 + auto_start_machines = true 20 + min_machines_running = 0 21 + processes = ['app'] 22 + 23 + [[vm]] 24 + memory = '256mb' 25 + cpu_kind = 'shared' 26 + cpus = 1
+8
go.mod
··· 1 + module git.sr.ht/~jordanreger/htmlsky 2 + 3 + go 1.22.1 4 + 5 + require ( 6 + git.sr.ht/~jordanreger/bsky v0.0.0-20240531012515-2b9e82c7e6de 7 + git.sr.ht/~jordanreger/bsky/util v0.0.0-20240531012515-2b9e82c7e6de 8 + )
+4
go.sum
··· 1 + git.sr.ht/~jordanreger/bsky v0.0.0-20240531012515-2b9e82c7e6de h1:5r6ugoyLOXkU+HHNnh54e4roP68rGiz+jhe9UT51eb8= 2 + git.sr.ht/~jordanreger/bsky v0.0.0-20240531012515-2b9e82c7e6de/go.mod h1:J/wrtw5XGVMT3+9Pm6FKrRjLP17qOihtY56wChr2LMs= 3 + git.sr.ht/~jordanreger/bsky/util v0.0.0-20240531012515-2b9e82c7e6de h1:2wOo/o1/adY7P4Z0ychuFY3esnhyZQQT0jwXzIqBLVU= 4 + git.sr.ht/~jordanreger/bsky/util v0.0.0-20240531012515-2b9e82c7e6de/go.mod h1:dFIWBF2o6TH0V3jKU+F4Zbve24lXSk0Yu6WfdEtaBLQ=
+19
list.go
··· 1 + package main 2 + 3 + import ( 4 + "bytes" 5 + "fmt" 6 + "html/template" 7 + 8 + "git.sr.ht/~jordanreger/bsky" 9 + ) 10 + 11 + func GetListPage(list bsky.List) string { 12 + t := template.Must(template.ParseFS(publicFiles, "public/*")) 13 + var list_page bytes.Buffer 14 + err := t.ExecuteTemplate(&list_page, "list.html", list) 15 + if err != nil { 16 + fmt.Println(err) 17 + } 18 + return list_page.String() 19 + }
+195
main.go
··· 1 + package main 2 + 3 + import ( 4 + "embed" 5 + "encoding/json" 6 + "fmt" 7 + "io/fs" 8 + "log" 9 + "net/http" 10 + 11 + "git.sr.ht/~jordanreger/bsky" 12 + "git.sr.ht/~jordanreger/bsky/util" 13 + ) 14 + 15 + var host = "htmlsky.app" 16 + var handle = "htmlsky.app" 17 + var did = "did:plc:sxouh4kxso3dufvnafa2zggn" 18 + 19 + //go:embed all:public 20 + var publicFiles embed.FS 21 + var publicFS = fs.FS(publicFiles) 22 + var public, _ = fs.Sub(publicFS, "public") 23 + 24 + func main() { 25 + mux := http.NewServeMux() 26 + 27 + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 28 + if r.URL.Path != "/" { 29 + // serve DID 30 + if r.URL.Path == "/.well-known/atproto-did" { 31 + fmt.Fprint(w, did) 32 + return 33 + } 34 + // otherwise serve static 35 + http.ServeFileFS(w, r, public, r.URL.Path) 36 + return 37 + } 38 + 39 + // serve homepage 40 + did := util.GetDID(handle) 41 + actor := bsky.GetActorProfile(did) 42 + page := GetActorPage(actor) 43 + 44 + fmt.Fprint(w, page) 45 + }) 46 + 47 + /* REDIRECTS */ 48 + mux.HandleFunc("/raw/", func(w http.ResponseWriter, r *http.Request) { 49 + fmt.Fprint(w, "Usage: /raw/profile/{handle}[/post/{rkey}]") 50 + }) 51 + mux.HandleFunc("/raw/{handle}/", func(w http.ResponseWriter, r *http.Request) { 52 + http.Redirect(w, r, "/raw/", http.StatusSeeOther) 53 + }) 54 + mux.HandleFunc("/raw/profile/{handle}/{rkey}/", func(w http.ResponseWriter, r *http.Request) { 55 + http.Redirect(w, r, "/raw/", http.StatusSeeOther) 56 + }) 57 + mux.HandleFunc("/embed/", func(w http.ResponseWriter, r *http.Request) { 58 + fmt.Fprint(w, "Usage: /embed/profile/{handle}[/post/{rkey}]") 59 + }) 60 + mux.HandleFunc("/embed/{handle}/", func(w http.ResponseWriter, r *http.Request) { 61 + http.Redirect(w, r, "/embed/", http.StatusSeeOther) 62 + }) 63 + mux.HandleFunc("/profile/", func(w http.ResponseWriter, r *http.Request) { 64 + http.Redirect(w, r, "/", http.StatusSeeOther) 65 + }) 66 + mux.HandleFunc("/profile/{handle}/{rkey}/", func(w http.ResponseWriter, r *http.Request) { 67 + http.Redirect(w, r, "/", http.StatusSeeOther) 68 + }) 69 + 70 + /* ROUTES */ 71 + 72 + // actor 73 + mux.HandleFunc("/profile/{handle}/", func(w http.ResponseWriter, r *http.Request) { 74 + handle := r.PathValue("handle") 75 + 76 + did := util.GetDID(handle) 77 + actor := bsky.GetActorProfile(did) 78 + page := GetActorPage(actor) 79 + 80 + fmt.Fprint(w, page) 81 + }) 82 + mux.HandleFunc("/raw/profile/{handle}/", func(w http.ResponseWriter, r *http.Request) { 83 + handle := r.PathValue("handle") 84 + 85 + did := util.GetDID(handle) 86 + actor := bsky.GetActorProfile(did) 87 + type raw struct { 88 + *bsky.Actor 89 + *bsky.Feed `json:"feed,omitempty"` 90 + } 91 + feed := actor.Feed() 92 + actor_feed := raw{ 93 + &actor, 94 + &feed, 95 + } 96 + res, _ := json.MarshalIndent(actor_feed, "", " ") 97 + 98 + w.Header().Add("Content-Type", "application/json") 99 + fmt.Fprint(w, string(res)) 100 + }) 101 + mux.HandleFunc("/embed/profile/{handle}/", func(w http.ResponseWriter, r *http.Request) { 102 + handle := r.PathValue("handle") 103 + 104 + did := util.GetDID(handle) 105 + actor := bsky.GetActorProfile(did) 106 + page := GetActorPageEmbed(actor) 107 + 108 + fmt.Fprint(w, page) 109 + }) 110 + 111 + // thread 112 + mux.HandleFunc("/profile/{handle}/post/{rkey}/", func(w http.ResponseWriter, r *http.Request) { 113 + handle := r.PathValue("handle") 114 + rkey := r.PathValue("rkey") 115 + 116 + did := util.GetDID(handle) 117 + at_uri := util.GetPostURI(did, rkey) 118 + thread := bsky.GetThread(at_uri) 119 + page := GetThreadPage(thread) 120 + 121 + fmt.Fprint(w, page) 122 + }) 123 + 124 + mux.HandleFunc("/raw/profile/{handle}/post/{rkey}/", func(w http.ResponseWriter, r *http.Request) { 125 + handle := r.PathValue("handle") 126 + rkey := r.PathValue("rkey") 127 + 128 + did := util.GetDID(handle) 129 + at_uri := util.GetPostURI(did, rkey) 130 + res, _ := json.MarshalIndent(bsky.GetThread(at_uri), "", " ") 131 + 132 + w.Header().Add("Content-Type", "application/json") 133 + fmt.Fprint(w, string(res)) 134 + }) 135 + 136 + mux.HandleFunc("/embed/profile/{handle}/post/{rkey}/", func(w http.ResponseWriter, r *http.Request) { 137 + handle := r.PathValue("handle") 138 + rkey := r.PathValue("rkey") 139 + 140 + did := util.GetDID(handle) 141 + at_uri := util.GetPostURI(did, rkey) 142 + thread := bsky.GetThread(at_uri) 143 + page := GetThreadPageEmbed(thread) 144 + 145 + fmt.Fprint(w, page) 146 + }) 147 + 148 + // list 149 + mux.HandleFunc("/profile/{handle}/lists/{rkey}/", func(w http.ResponseWriter, r *http.Request) { 150 + handle := r.PathValue("handle") 151 + rkey := r.PathValue("rkey") 152 + 153 + did := util.GetDID(handle) 154 + at_uri := util.GetListURI(did, rkey) 155 + list := bsky.GetList(at_uri) 156 + page := GetListPage(list) 157 + 158 + fmt.Fprint(w, page) 159 + }) 160 + mux.HandleFunc("/raw/profile/{handle}/lists/{rkey}/", func(w http.ResponseWriter, r *http.Request) { 161 + handle := r.PathValue("handle") 162 + rkey := r.PathValue("rkey") 163 + 164 + did := util.GetDID(handle) 165 + at_uri := util.GetListURI(did, rkey) 166 + list := bsky.GetList(at_uri) 167 + type raw struct { 168 + *bsky.List 169 + *bsky.Feed `json:"feed,omitempty"` 170 + } 171 + feed := list.Feed() 172 + actor_feed := raw{ 173 + &list, 174 + &feed, 175 + } 176 + res, _ := json.MarshalIndent(actor_feed, "", " ") 177 + 178 + w.Header().Add("Content-Type", "application/json") 179 + fmt.Fprint(w, string(res)) 180 + 181 + }) 182 + mux.HandleFunc("/embed/profile/{handle}/lists/{rkey}/", func(w http.ResponseWriter, r *http.Request) { 183 + handle := r.PathValue("handle") 184 + rkey := r.PathValue("rkey") 185 + 186 + did := util.GetDID(handle) 187 + at_uri := util.GetListURI(did, rkey) 188 + list := bsky.GetList(at_uri) 189 + page := GetListPageEmbed(list) 190 + 191 + fmt.Fprint(w, page) 192 + }) 193 + 194 + log.Fatal(http.ListenAndServe(":8080", mux)) 195 + }
-149
main.js
··· 1 - import { AtpAgent } from "npm:@atproto/api"; 2 - import { serveDir, serveFile } from "jsr:@std/http/file-server"; 3 - 4 - import Actor from "./actor.js"; 5 - import Thread from "./thread.js"; 6 - 7 - export const agent = new AtpAgent({ service: "https://public.api.bsky.app" }); 8 - 9 - const controller = new AbortController(), signal = controller.signal; 10 - Deno.serve({ signal }, async (req, info) => { 11 - const headers = new Headers(req.headers); 12 - const ua = headers.get("User-Agent"); 13 - 14 - console.log(ua); 15 - 16 - // Bots should receive empty reply 17 - if ( 18 - ua.includes("amazon") || 19 - ua.includes("facebook") || 20 - ua.includes("Bytespider") || 21 - ua.includes("bot") 22 - ) { 23 - controller.abort(); 24 - } 25 - 26 - const url = new URL(req.url); 27 - const path = url.pathname; 28 - const redir = `${url.protocol}${url.host}`; 29 - const query = new URLSearchParams(url.search); 30 - 31 - // wrangle bots 32 - if (path === "/robots.txt") { 33 - return serveFile(req, "./static/robots.txt"); 34 - } 35 - // "serve favicon" 36 - if (path === "/favicon.ico") { 37 - return new Response({ status: 404 }); 38 - } 39 - // serve static files 40 - if (path.startsWith("/static")) { 41 - return serveDir(req, { quiet: true }); 42 - } 43 - // handle trailing slashes 44 - if (path.at(-1) !== "/") { 45 - return Response.redirect(`${redir}${url.pathname}/`); 46 - } 47 - 48 - try { 49 - 50 - // PROFILE 51 - const profilePattern = new URLPattern({ 52 - pathname: "/profile/:identifier/:page?/", 53 - }); 54 - if (profilePattern.test(url)) { 55 - const identifier = profilePattern.exec(url)?.pathname.groups.identifier; 56 - const page = profilePattern.exec(url)?.pathname.groups.page; 57 - 58 - const { data } = await agent.api.app.bsky.actor.getProfile({ actor: identifier }); 59 - const actor = new Actor(data); 60 - const cursor = query.get("cursor"); 61 - 62 - let res = await actor.Profile(); 63 - switch (page) { 64 - // Empty case is for /profile/ 65 - case "": 66 - res += await actor.Feed(cursor); 67 - break; 68 - case "follows": 69 - res += await actor.Follows(cursor); 70 - break; 71 - case "followers": 72 - res += await actor.Followers(cursor); 73 - break; 74 - default: 75 - throw new Error("not a page"); 76 - } 77 - return new Response(res, html_headers); 78 - } 79 - 80 - // THREADS 81 - const postPattern = new URLPattern({ 82 - pathname: "/profile/:identifier/post/:rkey/:page?/", 83 - }); 84 - if (postPattern.test(url)) { 85 - const identifier = postPattern.exec(url)?.pathname.groups.identifier; 86 - const rkey = postPattern.exec(url)?.pathname.groups.rkey; 87 - const page = postPattern.exec(url)?.pathname.groups.page; 88 - 89 - const { data } = await agent.api.app.bsky.feed.getPostThread({ uri: `at://${identifier}/app.bsky.feed.post/${rkey}` }); 90 - const thread = new Thread(data.thread); 91 - const cursor = query.get("cursor"); 92 - 93 - let res = await thread.Post(); 94 - switch (page) { 95 - case "": 96 - res += await thread.Replies(cursor); 97 - break; 98 - default: 99 - throw new Error("not a page"); 100 - } 101 - 102 - return new Response(res, html_headers); 103 - } 104 - 105 - } catch (error) { 106 - return new Response( 107 - `<head><meta name="color-scheme" content="light dark"></head>\n${error}`, 108 - html_headers, 109 - ); 110 - } 111 - 112 - return Response.redirect( 113 - `${url.protocol}${url.host}/profile/did:plc:sxouh4kxso3dufvnafa2zggn`, 114 - 303, 115 - ); 116 - }); 117 - 118 - const html_headers = { 119 - headers: { 120 - "Content-Type": "text/html;charset=utf-8", 121 - }, 122 - }; 123 - const json_headers = { 124 - headers: { 125 - "Content-Type": "application/json;charset=utf-8", 126 - }, 127 - }; 128 - 129 - export const DateTimeFormat = new Intl.DateTimeFormat("en-US", { 130 - dateStyle: "short", 131 - timeStyle: "long", 132 - timeZone: "UTC", 133 - hour12: false, 134 - }); 135 - 136 - const RelativeTimeFormat = new Intl.RelativeTimeFormat('en', { 137 - numeric: 'auto', 138 - }); 139 - export function getRelativeDate(date) { 140 - const ms = date.getTime(); 141 - const deltaSeconds = Math.round((ms - Date.now()) / 1000); 142 - const cutoffs = [60, 3600, 86400, 86400 * 7, 86400 * 30, 86400 * 365, Infinity]; 143 - const units = ["second", "minute", "hour", "day", "week", "month", "year"]; 144 - const unitIndex = cutoffs.findIndex(cutoff => cutoff > Math.abs(deltaSeconds)); 145 - const divisor = unitIndex ? cutoffs[unitIndex - 1] : 1; 146 - const divided = deltaSeconds / divisor; 147 - const value = divided >= 0 ? Math.floor(divided) : Math.ceil(divided); 148 - return RelativeTimeFormat.format(value, units[unitIndex]); 149 - }
+4
public/actor.embed.html
··· 1 + {{template "embed_head" .}} 2 + <style>header{display:none;}</style> 3 + {{template "actor" .}} 4 + {{template "footer" .}}
+44
public/actor.html
··· 1 + {{define "actor"}} 2 + <!DOCTYPE html> 3 + <html> 4 + {{template "actor_head" .}} 5 + 6 + <body> 7 + {{template "actor_header" .}} 8 + 9 + <!-- user profile --> 10 + <table> 11 + <tr> 12 + <td> 13 + <img src="{{.Avatar}}" alt="{{.DisplayName}}'s avatar" width="65" height="65"> 14 + </td> 15 + <td> 16 + <h1 style="margin:0;">{{.DisplayName}}</h1> 17 + <span>@{{.Handle}}</span> 18 + </td> 19 + </tr> 20 + </table> 21 + 22 + <p> 23 + <b>{{.FollowersCount}}</b> followers 24 + <b>{{.FollowsCount}}</b> following 25 + <b>{{.PostsCount}}</b> posts 26 + </p> 27 + <p>{{.DescriptionHTML}}</p> 28 + 29 + <hr> 30 + 31 + <div id="feed"> 32 + {{range .Feed}} 33 + {{if ne .Post.Author.DID $.DID}} 34 + <span><b>Reposted by <a href="/profile/{{$.Handle}}">{{$.DisplayName}}</a></b></p> 35 + {{end}} 36 + {{template "feed_post" .}} 37 + {{end}} 38 + </div> 39 + </body> 40 + 41 + </html> 42 + {{end}} 43 + 44 + {{template "actor" .}}
public/avatar.jpeg

This is a binary file and will not be displayed.

public/banner.jpeg

This is a binary file and will not be displayed.

+18
public/external_embed.html
··· 1 + {{define "external_embed"}} 2 + {{if .Post.Embed}} 3 + {{if eq .Post.Embed.Type "app.bsky.embed.external#view"}} 4 + <article> 5 + {{if .Post.Embed.External.Title}} 6 + <h3> 7 + <a href="{{.Post.Embed.External.URI}}">{{.Post.Embed.External.Title}}</a> 8 + </h3> 9 + <p style="word-wrap:break-word;text-overflow:ellipsis;">{{.Post.Embed.External.Description}}</p> 10 + {{else}} 11 + <h3> 12 + <a href="{{.Post.Embed.External.URI}}">{{.Post.Embed.External.URI}}</a> 13 + </h3> 14 + {{end}} 15 + </article> 16 + {{end}} 17 + {{end}} 18 + {{end}}
+5
public/footer.html
··· 1 + {{define "footer"}} 2 + <footer style="margin:20px 0;text-align:center;"> 3 + <p>Powered by <a href="https://sr.ht/~jordanreger/bsky" title="HTMLsky">&#129419;</a></p> 4 + </footer> 5 + {{end}}
+53
public/head.html
··· 1 + {{define "actor_head"}} 2 + 3 + <head> 4 + <meta charset="utf-8"> 5 + <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=5, shrink-to-fit=no" /> 6 + <meta name="color-scheme" content="light dark"> 7 + <title>{{.DisplayName}} (@{{.Handle}})</title> 8 + <meta property="og:title" content="{{.DisplayName}} (@{{.Handle}})" /> 9 + <meta property="og:type" content="website" /> 10 + <meta property="og:url" content="/profile/{{.Handle}}" /> 11 + <meta property="og:image" content="{{.Avatar}}" /> 12 + <meta property="og:description" content="{{.Description}}" /> 13 + </head> 14 + {{end}} 15 + 16 + {{define "thread_head"}} 17 + 18 + <head> 19 + <meta charset="utf-8"> 20 + <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=5, shrink-to-fit=no" /> 21 + <meta name="color-scheme" content="light dark"> 22 + <title>{{.Post.Author.DisplayName}}{{if .Post.Record.Text}}: "{{.Post.Record.Text}}"{{end}}</title> 23 + <meta property="og:type" content="website" /> 24 + <meta property="og:url" content="/profile/{{.Post.Author.Handle}}/post/{{.Post.RKey}}" /> 25 + <meta property="og:description" content="{{.Post.Record.Text}}" /> 26 + </head> 27 + {{end}} 28 + 29 + {{define "list_head"}} 30 + 31 + <head> 32 + <meta charset="utf-8"> 33 + <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=5, shrink-to-fit=no" /> 34 + <meta name="color-scheme" content="light dark"> 35 + <title>{{.Name}}</title> 36 + <meta property="og:type" content="website" /> 37 + <meta property="og:url" content="/profile/{{.Creator.Handle}}/lists/{{.RKey}}" /> 38 + <meta property="og:description" content="{{.Description}}" /> 39 + </head> 40 + {{end}} 41 + 42 + {{define "embed_head"}} 43 + <!-- you can change this if you want --> 44 + 45 + <head> 46 + <base href="https://htmlsky.app" target="_blank"> 47 + </head> 48 + <style> 49 + header { 50 + display: none; 51 + } 52 + </style> 53 + {{end}}
+35
public/header.html
··· 1 + {{define "actor_header"}} 2 + <header> 3 + <nav> 4 + <b><i>HTMLsky</i></b> 5 + [ <a href="/">Home</a> ] 6 + [ <a href="https://sr.ht/~jordanreger/htmlsky">Source</a> ] 7 + [ <a href="https://bsky.app/profile/{{.DID}}">View on Bluesky</a> ] 8 + </nav> 9 + <hr> 10 + </header> 11 + {{end}} 12 + 13 + {{define "thread_header"}} 14 + <header> 15 + <nav> 16 + <b><i>HTMLsky</i></b> 17 + [ <a href="/">Home</a> ] 18 + [ <a href="https://sr.ht/~jordanreger/htmlsky">Source</a> ] 19 + [ <a href="https://bsky.app/profile/{{.Post.Author.DID}}/post/{{.Post.RKey}}/">View on Bluesky</a> ] 20 + </nav> 21 + <hr> 22 + </header> 23 + {{end}} 24 + 25 + {{define "list_header"}} 26 + <header> 27 + <nav> 28 + <b><i>HTMLsky</i></b> 29 + [ <a href="/">Home</a> ] 30 + [ <a href="https://sr.ht/~jordanreger/htmlsky">Source</a> ] 31 + [ <a href="https://bsky.app/profile/{{.Creator.DID}}/lists/{{.RKey}}/">View on Bluesky</a> ] 32 + </nav> 33 + <hr> 34 + </header> 35 + {{end}}
+15
public/image_embed.html
··· 1 + {{define "image_embed"}} 2 + {{if .Post.Embed}} 3 + {{if eq .Post.Embed.Type "app.bsky.embed.recordWithMedia#view"}} 4 + {{range .Post.Embed.Media.Images}} 5 + <img src="{{.Thumb}}" alt="{{.Alt}}" class="recordWithMedia-view" 6 + style="position:relative;max-height:50%;max-width:100%;"> 7 + {{end}} 8 + {{end}} 9 + {{if eq .Post.Embed.Type "app.bsky.embed.images#view"}} 10 + {{range .Post.Embed.Images}} 11 + <img src="{{.Thumb}}" alt="{{.Alt}}" class="images-view" style="position:relative;max-height:50%;max-width:100%;"> 12 + {{end}} 13 + {{end}} 14 + {{end}} 15 + {{end}}
+8
public/list.embed.html
··· 1 + {{template "embed_head" .}} 2 + <style> 3 + header { 4 + display: none; 5 + } 6 + </style> 7 + {{template "list" .}} 8 + {{template "footer" .}}
+24
public/list.html
··· 1 + {{define "list"}} 2 + <!DOCTYPE html> 3 + <html> 4 + {{template "list_head" .}} 5 + 6 + <body> 7 + {{template "list_header" .}} 8 + 9 + <!-- list description --> 10 + <h1 style="margin-bottom:0;">{{.Name}}</h1> 11 + <span>by <a href="/profile/{{.Creator.Handle}}">@{{.Creator.Handle}}</a></span> 12 + <p>{{.DescriptionHTML}}</p> 13 + <hr> 14 + 15 + <span id="feed"></span> 16 + {{range .Feed}} 17 + {{template "feed_post" .}} 18 + {{end}} 19 + </body> 20 + 21 + </html> 22 + {{end}} 23 + 24 + {{template "list" .}}
+77
public/post.html
··· 1 + {{define "post"}} 2 + <article> 3 + <table> 4 + <tr> 5 + <td> 6 + <img src="{{.Post.Author.Avatar}}" alt="{{.Post.Author.DisplayName}}'s avatar" width="50" height="50"> 7 + </td> 8 + <td> 9 + <h1 style="margin:0;">{{.Post.Author.DisplayName}}</h1> 10 + <span><a href="/profile/{{.Post.Author.Handle}}">@{{.Post.Author.Handle}}</a></span> 11 + </td> 12 + </tr> 13 + </table> 14 + 15 + <p>{{.Post.Record.HTML}}</p> 16 + 17 + <!--<section> 18 + {{template "image_embed" .}} 19 + {{template "post_embed" .}} 20 + {{template "external_embed" .}} 21 + </section>--> 22 + 23 + <time datetime="{{.Post.Record.CreatedAt}}" class="date"> 24 + <i>{{.Post.Record.CreatedAt.Format "Jan 02, 2006 at 15:04 UTC"}}</i> 25 + </time> 26 + 27 + <p class="counts"> 28 + <b>{{.Post.ReplyCount}}</b> replies 29 + <b>{{.Post.RepostCount}}</b> reposts 30 + <b>{{.Post.LikeCount}}</b> likes 31 + </p> 32 + 33 + <hr> 34 + </article> 35 + {{end}} 36 + 37 + {{define "post_reply"}} 38 + <article> 39 + <table> 40 + <tr> 41 + <td> 42 + <img src="{{.Post.Author.Avatar}}" alt="{{.Post.Author.DisplayName}}'s avatar" width="40" height="40"> 43 + </td> 44 + <td> 45 + <span> 46 + <b>{{.Post.Author.DisplayName}}</b> 47 + <a href="/profile/{{.Post.Author.Handle}}">@{{.Post.Author.Handle}}</a> 48 + </span> 49 + <br> 50 + [ <a href="/profile/{{.Post.Author.Handle}}/post/{{.Post.RKey}}">View</a> ] 51 + <time datetime="{{.Post.Record.CreatedAt}}" class="date"> 52 + <i>{{.Post.Record.CreatedAt.Format "Jan 02, 2006 at 15:04 UTC"}}</i> 53 + </time> 54 + </td> 55 + </tr> 56 + </table> 57 + 58 + <p>{{.Post.Record.HTML}}</p> 59 + <!--<section> 60 + {{template "image_embed" .}} 61 + {{template "post_embed" .}} 62 + {{template "external_embed" .}} 63 + </section>--> 64 + 65 + <p class="counts"> 66 + <b>{{.Post.ReplyCount}}</b> replies 67 + <b>{{.Post.RepostCount}}</b> reposts 68 + <b>{{.Post.LikeCount}}</b> likes 69 + </p> 70 + 71 + <hr> 72 + </article> 73 + {{end}} 74 + 75 + {{define "feed_post"}} 76 + {{template "post_reply" .}} 77 + {{end}}
+52
public/post_embed.html
··· 1 + {{define "post_embed"}} 2 + {{if .Post.Embed}} 3 + {{if eq .Post.Embed.Type "app.bsky.embed.recordWithMedia#view"}} 4 + <article style="max-width:600px;padding:10px; border: 1px solid;" class="recordWithMedia-view"> 5 + <div> 6 + <a href="/profile/{{.Post.Embed.Record.Record.Author.Handle}}/post/{{.Post.Embed.Record.Record.RKey}}" 7 + style="color:inherit;text-decoration:none;"> 8 + <img src="{{.Post.Embed.Record.Record.Author.Avatar}}" 9 + alt="{{.Post.Embed.Record.Record.Author.DisplayName}}'s avatar" 10 + style="width:50px;border-radius:50%;float:left;margin-right:10px;padding:0;"> 11 + <p style="margin:0;"> 12 + <b>{{.Post.Embed.Record.Record.Author.DisplayName}}</b> 13 + <span class="handle">@{{.Post.Embed.Record.Record.Author.Handle}}</span> 14 + &middot; 15 + <time datetime="{{.Post.Embed.Record.Record.Value.CreatedAt}}" style="margin-top: 10px;" 16 + class="date">{{.Post.Embed.Record.Record.Value.CreatedAt.Format "1/2/2006 15:04 UTC" }}</time> 17 + </p> 18 + </div> 19 + <div style="margin-left:60px;"> 20 + <p style="margin-top:5px;">{{.Post.Embed.Record.Record.Value.HTML}}</p> 21 + </div> 22 + </a> 23 + </article> 24 + {{else if eq .Post.Embed.Type "app.bsky.embed.record#view"}} 25 + {{if eq .Post.Embed.Record.Type "app.bsky.embed.record#viewNotFound"}} 26 + <article style="max-width:600px;border: 1px solid;" class="record-viewNotFound"> 27 + <p>Not found</p> 28 + </article> 29 + {{else}} 30 + <article style="max-width:600px;padding:10px; border: 1px solid;"> 31 + <div> 32 + <a href="/profile/{{.Post.Embed.Record.Author.Handle}}/post/{{.Post.Embed.Record.RKey}}" 33 + style="color:inherit;text-decoration:none;"> 34 + <img src="{{.Post.Embed.Record.Author.Avatar}}" alt="{{.Post.Embed.Record.Author.DisplayName}}'s avatar" 35 + style="width:50px;border-radius:50%;float:left;margin-right:10px;padding:0;"> 36 + <p style="margin:0;"> 37 + <b>{{.Post.Embed.Record.Author.DisplayName}}</b> 38 + <span class="handle">@{{.Post.Embed.Record.Author.Handle}}</span> 39 + &middot; 40 + <time datetime="{{.Post.Embed.Record.Value.CreatedAt}}" style="margin-top: 10px;" 41 + class="date">{{.Post.Embed.Record.Value.CreatedAt.Format "1/2/2006 15:04 UTC" }}</time> 42 + </p> 43 + </div> 44 + <div style="margin-left:60px;"> 45 + <p style="margin-top:5px;">{{.Post.Embed.Record.Value.HTML}}</p> 46 + </div> 47 + </a> 48 + </article> 49 + {{end}} 50 + {{end}} 51 + {{end}} 52 + {{end}}
+64
public/style.css
··· 1 + :root { 2 + color-scheme: light dark; 3 + } 4 + 5 + @media (prefers-color-scheme: light) { 6 + :root { 7 + --secondary: dimgray; 8 + --primary: black; 9 + --link: royalblue; 10 + --ui: lightgray; 11 + } 12 + } 13 + 14 + @media (prefers-color-scheme: dark) { 15 + :root { 16 + --secondary: darkgray; 17 + --primary: white; 18 + --link: dodgerblue; 19 + --ui: dimgray; 20 + } 21 + } 22 + 23 + html { 24 + font-family: system-ui, sans-serif; 25 + max-width: 800px; 26 + margin: 0 auto; 27 + line-height: 1.4em; 28 + } 29 + 30 + hr { 31 + border: 0; 32 + height: 1px; 33 + background: var(--ui); 34 + } 35 + 36 + code, 37 + pre { 38 + font-family: ui-monospace, monospace; 39 + } 40 + 41 + a, 42 + a:visited { 43 + color: var(--link); 44 + text-decoration: none; 45 + } 46 + 47 + a:hover { 48 + text-decoration: underline; 49 + } 50 + 51 + .handle, 52 + .counts, 53 + .date, 54 + .repost { 55 + color: var(--secondary); 56 + } 57 + 58 + .counts b { 59 + color: var(--primary); 60 + } 61 + 62 + img { 63 + max-width: 100%; 64 + }
+4
public/thread.embed.html
··· 1 + {{template "embed_head" .}} 2 + <style>header{display:none;}</style> 3 + {{template "thread" .}} 4 + {{template "footer" .}}
+21
public/thread.html
··· 1 + {{define "thread"}} 2 + <!DOCTYPE html> 3 + <html> 4 + {{template "thread_head" .}} 5 + 6 + <body> 7 + {{template "thread_header" .}} 8 + 9 + <!-- main post --> 10 + {{template "post" .}} 11 + 12 + <span id="replies"></span> 13 + {{range .Replies}} 14 + {{template "post_reply" .}} 15 + {{end}} 16 + </body> 17 + 18 + </html> 19 + {{end}} 20 + 21 + {{template "thread" .}}
static/avatar.jpg

This is a binary file and will not be displayed.

-2
static/robots.txt
··· 1 - User-agent: * 2 - Disallow: /
+20
thread.go
··· 1 + package main 2 + 3 + import ( 4 + "bytes" 5 + "fmt" 6 + "html/template" 7 + 8 + "git.sr.ht/~jordanreger/bsky" 9 + ) 10 + 11 + func GetThreadPage(thread bsky.Thread) string { 12 + t := template.Must(template.ParseFS(publicFiles, "public/*")) 13 + var thread_page bytes.Buffer 14 + 15 + err := t.ExecuteTemplate(&thread_page, "thread.html", thread) 16 + if err != nil { 17 + fmt.Println(err) 18 + } 19 + return thread_page.String() 20 + }
-236
thread.js
··· 1 - import { getRelativeDate, DateTimeFormat } from "./main.js"; 2 - import { getFacets } from "./facets.js"; 3 - 4 - export default class Thread { 5 - thread; 6 - 7 - constructor(thread) { 8 - this.thread = thread; 9 - } 10 - 11 - Post() { 12 - let res = ""; 13 - 14 - const thread = this.thread; 15 - const post = thread.post; 16 - const author = post.author; 17 - const record = post.record; 18 - 19 - author.displayName = author.displayName ? author.displayName : author.handle; 20 - author.handle = author.handle !== "handle.invalid" ? author.handle : author.did; 21 - author.avatar = author.avatar ? author.avatar : "/static/avatar.jpg"; 22 - 23 - res += ` 24 - <head> 25 - <meta name="color-scheme" content="light dark"> 26 - <meta name="viewport" content="width=device-width, initial-scale=1"> 27 - <title>${author.displayName}: ${post.record.text} &#8212; HTMLsky</title> 28 - </head> 29 - `; 30 - 31 - if (thread.parent) { 32 - const parent = thread.parent; 33 - const post = parent.post; 34 - 35 - const rkey = post.uri.split("/").at(-1); 36 - if (post.notFound || post.blocked) { 37 - res +=` 38 - <tr><td> 39 - <table> 40 - <tr><td> 41 - Post not found. 42 - </td></tr> 43 - </table> 44 - </td></tr> 45 - `; 46 - } else { 47 - post.author.displayName = post.author.displayName ? post.author.displayName : post.author.handle; 48 - post.author.handle = post.author.handle !== "handle.invalid" ? post.author.handle : post.author.did; 49 - 50 - const text = post.record.text ? `<p>${getFacets(post.record.text, post.record.facets)}</p>` : ""; 51 - 52 - res += ` 53 - <tr> 54 - <td> 55 - <table> 56 - <tr><td> 57 - <b>${post.author.displayName}</b><br><a href="/profile/${post.author.handle}/">@${post.author.handle}</a> 58 - / <a href="/profile/${post.author.handle}/post/${rkey}/">${rkey}</a> 59 - </td></tr> 60 - <tr><td> 61 - ${text} 62 - </td></tr> 63 - <tr><td> 64 - <b>${post.replyCount}</b> replies &middot; 65 - <b>${post.repostCount}</b> reposts &middot; 66 - <b>${post.likeCount}</b> likes 67 - &mdash; 68 - <time title="${DateTimeFormat.format(new Date(post.record.createdAt))}"><i>${getRelativeDate(new Date(post.record.createdAt))}</i></time> 69 - </td></tr> 70 - </table> 71 - </td> 72 - </tr> 73 - <hr> 74 - `; 75 - } 76 - } 77 - 78 - const text = record.text ? `<p>${getFacets(record.text, record.facets)}</p>` : ""; 79 - 80 - const embeds = []; 81 - if (record.embed) { 82 - const embedType = record.embed["$type"]; 83 - 84 - switch(embedType) { 85 - case "app.bsky.embed.images": 86 - embeds.push(`<ul>`); 87 - for (const image of record.embed.images) { 88 - // TODO: have a separate page for images with alt text and stuff? 89 - const embedURL = `https://cdn.bsky.app/img/feed_fullsize/plain/${author.did}/${image.image.ref}@${image.image.mimeType.split("/")[1]}`; 90 - embeds.push(`<li><a href="${embedURL}">${image.alt ? image.alt : "image"}</a></li>`); 91 - } 92 - embeds.push(`</ul>`); 93 - break; 94 - case "app.bsky.embed.record": 95 - // TODO: record embed 96 - break; 97 - case "app.bsky.embed.recordWithMedia": 98 - // TODO: recordWithMedia embed 99 - break; 100 - case "app.bsky.embed.external": 101 - // TODO: external embed 102 - break; 103 - default: 104 - embeds.push(`<p>Missing embed type ${embedType}; <a href="https://todo.sr.ht/~jordanreger/htmlsky">please make an issue</a>.</p>`); 105 - break; 106 - } 107 - } 108 - 109 - res += ` 110 - <table> 111 - <tr> 112 - <td valign="top" height="45" width="45"> 113 - <img src="${author.avatar}" alt="${author.displayName}'s avatar" height="45" width="45"> 114 - </td> 115 - <td> 116 - <h2> 117 - <span>${author.displayName}</span><br> 118 - <small><small><a href="/profile/${author.handle}/">@${author.handle}</a></small></small> 119 - </h2> 120 - </td> 121 - </tr> 122 - <tr><td colspan="2"> 123 - ${text} 124 - </td></tr> 125 - <tr><td colspan="2"> 126 - ${post.embed ? embeds.join("\n") : ``} 127 - </td></tr> 128 - <tr><td colspan="2"> 129 - <b>${post.replyCount}</b> replies &middot; 130 - <b>${post.repostCount}</b> reposts &middot; 131 - <b>${post.likeCount}</b> likes 132 - &mdash; 133 - <time title="${DateTimeFormat.format(new Date(record.createdAt))}"><i>${getRelativeDate(new Date(record.createdAt))}</i></time> 134 - </td></tr> 135 - </table> 136 - <hr> 137 - `; 138 - 139 - return res; 140 - } 141 - 142 - async Replies(prevCursor) { 143 - const thread = this.thread; 144 - const replies = thread.replies; 145 - 146 - const feedList = []; 147 - 148 - for (const post of replies) { 149 - const record = post.post.record; 150 - const author = post.post.author; 151 - 152 - const rkey = post.post.uri.split("/").at(-1); 153 - 154 - // Embeds 155 - const embeds = []; 156 - if (record.embed) { 157 - const embedType = record.embed["$type"]; 158 - 159 - switch(embedType) { 160 - case "app.bsky.embed.images": 161 - embeds.push(`<ul>`); 162 - for (const image of record.embed.images) { 163 - // TODO: have a separate page for images with alt text and stuff? 164 - const embedURL = `https://cdn.bsky.app/img/feed_fullsize/plain/${author.did}/${image.image.ref}@${image.image.mimeType.split("/")[1]}`; 165 - embeds.push(`<li><a href="${embedURL}">${image.alt ? image.alt : "image"}</a></li>`); 166 - } 167 - embeds.push(`</ul>`); 168 - break; 169 - case "app.bsky.embed.record": 170 - // TODO: record embed 171 - break; 172 - case "app.bsky.embed.recordWithMedia": 173 - // TODO: recordWithMedia embed 174 - break; 175 - case "app.bsky.embed.external": 176 - // TODO: external embed 177 - break; 178 - default: 179 - embeds.push(`<p>Missing embed type ${embedType}; <a href="https://todo.sr.ht/~jordanreger/htmlsky">please make an issue</a>.</p>`); 180 - break; 181 - } 182 - } 183 - 184 - 185 - author.displayName = author.displayName ? author.displayName : author.handle; 186 - author.handle = author.handle !== "handle.invalid" ? author.handle : author.did; 187 - 188 - const text = record.text ? `<p>${getFacets(record.text, record.facets)}</p>` : ""; 189 - 190 - feedList.push(` 191 - <tr><td> 192 - ${post.reply ? `<blockquote>` : ``} 193 - <table> 194 - <tr><td> 195 - <b>${author.displayName}</b><br><a href="/profile/${author.handle}/">@${author.handle}</a> 196 - / <a href="/profile/${author.handle}/post/${rkey}/">${rkey}</a> 197 - </td></tr> 198 - <tr><td> 199 - </td></tr> 200 - <tr><td> 201 - ${text} 202 - </td></tr> 203 - <tr><td> 204 - ${record.embed ? embeds.join("\n") : ``} 205 - </td></tr> 206 - <tr><td> 207 - <b>${post.post.replyCount}</b> replies &middot; 208 - <b>${post.post.repostCount}</b> reposts &middot; 209 - <b>${post.post.likeCount}</b> likes 210 - &mdash; 211 - <time title="${DateTimeFormat.format(new Date(record.createdAt))}"><i>${getRelativeDate(new Date(record.createdAt))}</i></time> 212 - </td></tr> 213 - </table> 214 - ${post.reply ? `</blockquote><hr>` : `<hr>`} 215 - </td></tr> 216 - `); 217 - } 218 - 219 - /* 220 - if (cursor) { 221 - feedList.push(` 222 - <tr><td> 223 - <br> 224 - <a href="?cursor=${cursor}">Next page</a> 225 - </td></tr> 226 - `); 227 - } 228 - */ 229 - 230 - return ` 231 - <table width="100%"> 232 - ${feedList.join("")} 233 - </table> 234 - `; 235 - } 236 - }