An HTML-only Bluesky frontend

add profiles, add build

+370 -814
.DS_Store

This is a binary file and will not be displayed.

+12 -14
.build.yml
··· 1 - image: alpine/edge 1 + image: alpine/latest 2 2 packages: 3 - - curl 4 - secrets: 5 - - 156cbb78-ab98-46a6-8b2a-6c74714ec4b9 3 + - unzip 6 4 sources: 7 - - https://git.sr.ht/~jordanreger/htmlsky 5 + - https://git.sr.ht/~jordanreger/htmlsky#ts 6 + environment: 7 + DENO_INSTALL: /home/build/.deno 8 + PATH: $DENO_INSTALL/bin:$PATH 8 9 tasks: 9 - - install-flyctl: | 10 - curl -L https://fly.io/install.sh | sh 10 + - install-deno: | 11 + curl -fsSL https://deno.land/x/install/install.sh | sh 12 + - install-deployctl: | 13 + deno install -Arf jsr:@deno/deployctl 11 14 - deploy: | 12 - set +x 13 - export FLY_API_TOKEN=$(cat ~/.fly_token) 14 - set -x 15 - 16 - export FLYCTL_INSTALL="/home/build/.fly" 17 - export PATH="$FLYCTL_INSTALL/bin:$PATH" 18 - flyctl deploy --remote-only ./htmlsky 15 + cd htmlsky 16 + deployctl deploy
-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"]
-24
LICENSE
··· 1 - This is free and unencumbered software released into the public domain. 2 - 3 - Anyone is free to copy, modify, publish, use, compile, sell, or 4 - distribute this software, either in source code form or as a compiled 5 - binary, for any purpose, commercial or non-commercial, and by any 6 - means. 7 - 8 - In jurisdictions that recognize copyright laws, the author or authors 9 - of this software dedicate any and all copyright interest in the 10 - software to the public domain. We make this dedication for the benefit 11 - of the public at large and to the detriment of our heirs and 12 - successors. We intend this dedication to be an overt act of 13 - relinquishment in perpetuity of all present and future rights to this 14 - software under copyright law. 15 - 16 - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 - EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 - MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 - IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 - OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 - ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 - OTHER DEALINGS IN THE SOFTWARE. 23 - 24 - For more information, please refer to <https://unlicense.org>
-16
README.md
··· 1 - # htmlsky 2 - 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. 13 - 14 - ## Contributing 15 - 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 - }
+9
deno.json
··· 1 + { 2 + "tasks": { 3 + "dev": "deno run -A --watch main.ts" 4 + }, 5 + "compilerOptions": { 6 + "jsx": "react-jsx", 7 + "jsxImportSource": "https://esm.sh/preact@10.22.0" 8 + } 9 + }
+184
deno.lock
··· 1 + { 2 + "version": "3", 3 + "packages": { 4 + "specifiers": { 5 + "npm:@atproto/api": "npm:@atproto/api@0.12.8", 6 + "npm:preact": "npm:preact@10.22.0", 7 + "npm:preact-render-to-string": "npm:preact-render-to-string@6.5.5_preact@10.22.0", 8 + "npm:sanitize-html": "npm:sanitize-html@2.13.0" 9 + }, 10 + "npm": { 11 + "@atproto/api@0.12.8": { 12 + "integrity": "sha512-aNbiDuaslCxS3XyMRK40/ERerqAmk5HjQc7ivTBuPQy1Svmphl5ccnsUVxJ81xjpxjv9Fli2iPgomvRFdusuNQ==", 13 + "dependencies": { 14 + "@atproto/common-web": "@atproto/common-web@0.3.0", 15 + "@atproto/lexicon": "@atproto/lexicon@0.4.0", 16 + "@atproto/syntax": "@atproto/syntax@0.3.0", 17 + "@atproto/xrpc": "@atproto/xrpc@0.5.0", 18 + "multiformats": "multiformats@9.9.0", 19 + "tlds": "tlds@1.252.0" 20 + } 21 + }, 22 + "@atproto/common-web@0.3.0": { 23 + "integrity": "sha512-67VnV6JJyX+ZWyjV7xFQMypAgDmjVaR9ZCuU/QW+mqlqI7fex2uL4Fv+7/jHadgzhuJHVd6OHOvNn0wR5WZYtA==", 24 + "dependencies": { 25 + "graphemer": "graphemer@1.4.0", 26 + "multiformats": "multiformats@9.9.0", 27 + "uint8arrays": "uint8arrays@3.0.0", 28 + "zod": "zod@3.23.7" 29 + } 30 + }, 31 + "@atproto/lexicon@0.4.0": { 32 + "integrity": "sha512-RvCBKdSI4M8qWm5uTNz1z3R2yIvIhmOsMuleOj8YR6BwRD+QbtUBy3l+xQ7iXf4M5fdfJFxaUNa6Ty0iRwdKqQ==", 33 + "dependencies": { 34 + "@atproto/common-web": "@atproto/common-web@0.3.0", 35 + "@atproto/syntax": "@atproto/syntax@0.3.0", 36 + "iso-datestring-validator": "iso-datestring-validator@2.2.2", 37 + "multiformats": "multiformats@9.9.0", 38 + "zod": "zod@3.23.7" 39 + } 40 + }, 41 + "@atproto/syntax@0.3.0": { 42 + "integrity": "sha512-Weq0ZBxffGHDXHl9U7BQc2BFJi/e23AL+k+i5+D9hUq/bzT4yjGsrCejkjq0xt82xXDjmhhvQSZ0LqxyZ5woxA==", 43 + "dependencies": {} 44 + }, 45 + "@atproto/xrpc@0.5.0": { 46 + "integrity": "sha512-swu+wyOLvYW4l3n+VAuJbHcPcES+tin2Lsrp8Bw5aIXIICiuFn1YMFlwK9JwVUzTH21Py1s1nHEjr4CJeElJog==", 47 + "dependencies": { 48 + "@atproto/lexicon": "@atproto/lexicon@0.4.0", 49 + "zod": "zod@3.23.7" 50 + } 51 + }, 52 + "deepmerge@4.3.1": { 53 + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", 54 + "dependencies": {} 55 + }, 56 + "dom-serializer@2.0.0": { 57 + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", 58 + "dependencies": { 59 + "domelementtype": "domelementtype@2.3.0", 60 + "domhandler": "domhandler@5.0.3", 61 + "entities": "entities@4.5.0" 62 + } 63 + }, 64 + "domelementtype@2.3.0": { 65 + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", 66 + "dependencies": {} 67 + }, 68 + "domhandler@5.0.3": { 69 + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", 70 + "dependencies": { 71 + "domelementtype": "domelementtype@2.3.0" 72 + } 73 + }, 74 + "domutils@3.1.0": { 75 + "integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==", 76 + "dependencies": { 77 + "dom-serializer": "dom-serializer@2.0.0", 78 + "domelementtype": "domelementtype@2.3.0", 79 + "domhandler": "domhandler@5.0.3" 80 + } 81 + }, 82 + "entities@4.5.0": { 83 + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", 84 + "dependencies": {} 85 + }, 86 + "escape-string-regexp@4.0.0": { 87 + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", 88 + "dependencies": {} 89 + }, 90 + "graphemer@1.4.0": { 91 + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", 92 + "dependencies": {} 93 + }, 94 + "htmlparser2@8.0.2": { 95 + "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", 96 + "dependencies": { 97 + "domelementtype": "domelementtype@2.3.0", 98 + "domhandler": "domhandler@5.0.3", 99 + "domutils": "domutils@3.1.0", 100 + "entities": "entities@4.5.0" 101 + } 102 + }, 103 + "is-plain-object@5.0.0": { 104 + "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", 105 + "dependencies": {} 106 + }, 107 + "iso-datestring-validator@2.2.2": { 108 + "integrity": "sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA==", 109 + "dependencies": {} 110 + }, 111 + "multiformats@9.9.0": { 112 + "integrity": "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==", 113 + "dependencies": {} 114 + }, 115 + "nanoid@3.3.7": { 116 + "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", 117 + "dependencies": {} 118 + }, 119 + "parse-srcset@1.0.2": { 120 + "integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==", 121 + "dependencies": {} 122 + }, 123 + "picocolors@1.0.1": { 124 + "integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==", 125 + "dependencies": {} 126 + }, 127 + "postcss@8.4.38": { 128 + "integrity": "sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==", 129 + "dependencies": { 130 + "nanoid": "nanoid@3.3.7", 131 + "picocolors": "picocolors@1.0.1", 132 + "source-map-js": "source-map-js@1.2.0" 133 + } 134 + }, 135 + "preact-render-to-string@6.5.5_preact@10.22.0": { 136 + "integrity": "sha512-KiMFTKNTmT/ccE79BURR/r6XRc2I2TCTZ0MpeWqHW2XnllbeghXvwGsdAfF/MzMilUcTfODtSmMxgoRFL9TM5g==", 137 + "dependencies": { 138 + "preact": "preact@10.22.0" 139 + } 140 + }, 141 + "preact@10.22.0": { 142 + "integrity": "sha512-RRurnSjJPj4rp5K6XoP45Ui33ncb7e4H7WiOHVpjbkvqvA3U+N8Z6Qbo0AE6leGYBV66n8EhEaFixvIu3SkxFw==", 143 + "dependencies": {} 144 + }, 145 + "sanitize-html@2.13.0": { 146 + "integrity": "sha512-Xff91Z+4Mz5QiNSLdLWwjgBDm5b1RU6xBT0+12rapjiaR7SwfRdjw8f+6Rir2MXKLrDicRFHdb51hGOAxmsUIA==", 147 + "dependencies": { 148 + "deepmerge": "deepmerge@4.3.1", 149 + "escape-string-regexp": "escape-string-regexp@4.0.0", 150 + "htmlparser2": "htmlparser2@8.0.2", 151 + "is-plain-object": "is-plain-object@5.0.0", 152 + "parse-srcset": "parse-srcset@1.0.2", 153 + "postcss": "postcss@8.4.38" 154 + } 155 + }, 156 + "source-map-js@1.2.0": { 157 + "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==", 158 + "dependencies": {} 159 + }, 160 + "tlds@1.252.0": { 161 + "integrity": "sha512-GA16+8HXvqtfEnw/DTcwB0UU354QE1n3+wh08oFjr6Znl7ZLAeUgYzCcK+/CCrOyE0vnHR8/pu3XXG3vDijXpQ==", 162 + "dependencies": {} 163 + }, 164 + "uint8arrays@3.0.0": { 165 + "integrity": "sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA==", 166 + "dependencies": { 167 + "multiformats": "multiformats@9.9.0" 168 + } 169 + }, 170 + "zod@3.23.7": { 171 + "integrity": "sha512-NBeIoqbtOiUMomACV/y+V3Qfs9+Okr18vR5c/5pHClPpufWOrsx8TENboDPe265lFdfewX2yBtNTLPvnmCxwog==", 172 + "dependencies": {} 173 + } 174 + } 175 + }, 176 + "redirects": { 177 + "https://esm.sh/preact/jsx-runtime": "https://esm.sh/preact@10.22.0/jsx-runtime" 178 + }, 179 + "remote": { 180 + "https://esm.sh/preact@10.22.0/jsx-runtime": "c6185b52c1673f9d309dd6046051e9b6c034f14858bef62ea2a9fe9ef1ea2201", 181 + "https://esm.sh/stable/preact@10.22.0/denonext/jsx-runtime.js": "de60943799b1cbe6066c4f83f4ca71ef37011d7f5be7bef58ed980e8ff3f996a", 182 + "https://esm.sh/stable/preact@10.22.0/denonext/preact.mjs": "20c9563e051dd66e053d3afb450f61b48f2fa0d0ce4f69f8f0a2f23c1ef090da" 183 + } 184 + }
-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 - }
+26
facets.ts
··· 1 + import { AtpAgent, RichText } from "npm:@atproto/api"; 2 + 3 + const agent = new AtpAgent({ service: "https://public.api.bsky.app" }); 4 + 5 + export async function GetDescriptionFacets( 6 + description: string, 7 + ): Promise<string> { 8 + const rt = new RichText({ text: description }); 9 + await rt.detectFacets(agent); 10 + 11 + let descriptionWithFacets = ""; 12 + 13 + for (const segment of rt.segments()) { 14 + if (segment.isLink()) { 15 + descriptionWithFacets += 16 + `<a href="${segment.link?.uri}">${segment.text}</a>`; 17 + } else if (segment.isMention()) { 18 + descriptionWithFacets += 19 + `<a href="/profile/${segment.mention?.did}">${segment.text}</a>`; 20 + } else { 21 + descriptionWithFacets += segment.text; 22 + } 23 + } 24 + 25 + return descriptionWithFacets; 26 + }
-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 - }
+60
main.ts
··· 1 + import { renderToStringAsync } from "npm:preact-render-to-string"; 2 + import { AtpAgent } from "npm:@atproto/api"; 3 + 4 + // page imports 5 + import { Actor } from "./pages/mod.ts"; 6 + 7 + export const agent = new AtpAgent({ service: "https://public.api.bsky.app" }); 8 + 9 + Deno.serve({ 10 + port: 8080, 11 + }, async (req) => { 12 + const url = new URL(req.url); 13 + const path = url.pathname; 14 + 15 + // handle trailing slashes 16 + if (path !== "/" && url.pathname.match(/\/+$/)) { 17 + return Response.redirect( 18 + `${url.protocol}${url.host}${url.pathname.replace(/\/+$/, "")}`, 19 + ); 20 + } 21 + 22 + // paths 23 + if (path === "/") { 24 + const htmlsky = await agent.api.app.bsky.actor.getProfile({ 25 + actor: "htmlsky.app", 26 + }).then((res) => res.data); 27 + 28 + return new Response( 29 + await renderToStringAsync(await Actor(htmlsky)), 30 + headers, 31 + ); 32 + } 33 + 34 + const profilePattern = new URLPattern({ pathname: "/profile/:actor" }); 35 + if (profilePattern.test(url)) { 36 + const actorName = profilePattern.exec(url)?.pathname.groups.actor; 37 + 38 + try { 39 + const actor = await agent.api.app.bsky.actor.getProfile({ 40 + actor: actorName!, 41 + }).then((res) => res.data); 42 + 43 + return new Response( 44 + await renderToStringAsync(await Actor(actor)), 45 + headers, 46 + ); 47 + } catch (e) { 48 + // TODO: add error page 49 + return new Response(e.message, headers); 50 + } 51 + } 52 + 53 + return Response.redirect(`${url.protocol}${url.host}`, 303); 54 + }); 55 + 56 + const headers: ResponseInit = { 57 + "headers": { 58 + "Content-Type": "text/html;charset=utf-8", 59 + }, 60 + };
+55
pages/actor.tsx
··· 1 + import { AppBskyActorDefs, AppBskyFeedDefs } from "npm:@atproto/api"; 2 + import sanitizeHtml from "npm:sanitize-html"; 3 + 4 + import { GetDescriptionFacets } from "../facets.ts"; 5 + 6 + // Components 7 + import { ActorHeader } from "./header.tsx"; 8 + 9 + export async function Actor( 10 + actor: AppBskyActorDefs.ProfileViewDetailed, 11 + // TODO: add posts from feed 12 + _feed: AppBskyFeedDefs.FeedViewPost, 13 + ) { 14 + return ( 15 + <> 16 + <ActorHeader {...actor} /> 17 + <table> 18 + <tr> 19 + <td> 20 + <img 21 + src={actor.avatar} 22 + alt={`${sanitizeHtml(actor.displayName)}'s avatar`} 23 + width="65" 24 + height="65" 25 + /> 26 + </td> 27 + <td> 28 + <h1 style="margin:0;">{sanitizeHtml(actor.displayName)}</h1> 29 + <span>@{actor.handle}</span> 30 + </td> 31 + </tr> 32 + </table> 33 + 34 + <p> 35 + <b>{actor.followersCount}</b> followers&nbsp; 36 + <b>{actor.followsCount}</b> following&nbsp; 37 + <b>{actor.postsCount}</b> posts&nbsp; 38 + </p> 39 + 40 + <p 41 + style="white-space:pre-line;" 42 + dangerouslySetInnerHTML={{ 43 + __html: await GetDescriptionFacets( 44 + sanitizeHtml(actor.description), 45 + ), 46 + }} 47 + > 48 + </p> 49 + 50 + <hr /> 51 + 52 + <p>A list of posts will be here eventually.</p> 53 + </> 54 + ); 55 + }
pages/head.tsx

This is a binary file and will not be displayed.

+21
pages/header.tsx
··· 1 + import { AppBskyActorDefs } from "npm:@atproto/api"; 2 + 3 + export function ActorHeader(actor: AppBskyActorDefs.ProfileViewDetailed) { 4 + return ( 5 + <header> 6 + <nav> 7 + <span> 8 + <b> 9 + <i>HTMLsky</i> 10 + </b>&nbsp; 11 + </span> 12 + 13 + [ <a href="/">Home</a> ] [{" "} 14 + <a href="https://sr.ht/~jordanreger/htmlsky">Source</a> ] [{" "} 15 + <a href={`https://bsky.app/profile/${actor.did}`}>View on Bluesky</a> ] 16 + </nav> 17 + 18 + <hr /> 19 + </header> 20 + ); 21 + }
+3
pages/mod.ts
··· 1 + export * from "./actor.tsx"; 2 + export * from "./head.tsx"; 3 + export * from "./header.tsx";
-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" .}}
-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 - }