diff --git a/client/src/refresh.gleam b/client/src/refresh.gleam index 824ae32..b18aa58 100644 --- a/client/src/refresh.gleam +++ b/client/src/refresh.gleam @@ -3,12 +3,12 @@ //// Fetches fresh data and re-renders all dynamic sections on page load. //// Plays are then polled every 30s while the tab is visible. -import api import browser import dynamic import fetch import gen/alpha/feed/play.{type AlphaFeedPlay} import gen/repo.{type Repo} +import gleam/list import gleam/string import plays as plays_view import profile as profile_view @@ -56,21 +56,29 @@ fn on_profile(text: String) -> Nil { fn fetch_pinned_dids_and_repos() -> Nil { browser.fetch_text(fetch.pinned_dids_url(), fn(pinned_text) { - case fetch.decode_pinned_dids(pinned_text) { - Ok(dids) -> + case fetch.decode_actor_profiles(pinned_text) { + Ok(profiles) -> { + let pinned_dids = fetch.pinned_dids_from_profiles(profiles) browser.fetch_text(fetch.repos_url(), fn(repos_text) { case fetch.decode_repos(repos_text) { - Ok(all_repos) -> - commit_repos(api.filter_pinned_repos(all_repos, dids)) + Ok(records) -> { + let repos = + records + |> fetch.filter_repos_by_did(pinned_dids) + |> list.map(fetch.resolve_repo_name) + |> list.map(fn(record) { record.value }) + commit_repos(repos) + } Error(reason) -> browser.log_error( "decode_repos failed: " <> string.inspect(reason), ) } }) + } Error(reason) -> browser.log_error( - "decode_pinned_dids failed: " <> string.inspect(reason), + "decode_actor_profiles failed: " <> string.inspect(reason), ) } }) diff --git a/gleam.toml b/gleam.toml index c6b5f99..12dfa72 100644 --- a/gleam.toml +++ b/gleam.toml @@ -12,7 +12,6 @@ filepath = ">= 1.0.0 and < 2.0.0" mork = ">= 1.12.1 and < 2.0.0" argv = ">= 1.0.0 and < 2.0.0" shared = { path = "shared" } -atproto_client = ">= 0.1.0 and < 1.0.0" gleam_time = ">= 1.0.0 and < 2.0.0" yamleam = ">= 1.0.0 and < 2.0.0" diff --git a/justfile b/justfile index 18f08c7..dc38921 100644 --- a/justfile +++ b/justfile @@ -6,9 +6,11 @@ # Build the static site from scratch. build: codegen client ssg -# Regenerate types/decoders from vendored Lexicon schemas. +# Regenerate types/decoders from vendored Lexicon schemas. We don't +# ask for the typed XRPC client — both SSG and browser go through +# `shared/src/fetch.gleam`'s raw HTTP + decoders instead. codegen: - gleam run -m atproto_codegen -- ./lexicons ./shared/src/gen gen app.bsky.,com.atproto.,sh.tangled.,fm.teal. client + gleam run -m atproto_codegen -- ./lexicons ./shared/src/gen gen app.bsky.,com.atproto.,sh.tangled.,fm.teal. # Build the JS bundle. client: diff --git a/lexicons/com/atproto/repo/listRecords.json b/lexicons/com/atproto/repo/listRecords.json index 20b5db3..c3950be 100644 --- a/lexicons/com/atproto/repo/listRecords.json +++ b/lexicons/com/atproto/repo/listRecords.json @@ -4,19 +4,13 @@ "defs": { "main": { "type": "query", - "description": "List a range of records in a repository, matching a specific collection. Does not require auth.", "parameters": { "type": "params", "required": ["repo", "collection"], "properties": { "repo": { "type": "string", "format": "at-identifier" }, "collection": { "type": "string", "format": "nsid" }, - "limit": { - "type": "integer", - "minimum": 1, - "maximum": 100, - "default": 50 - }, + "limit": { "type": "integer", "minimum": 1, "maximum": 100 }, "cursor": { "type": "string" }, "reverse": { "type": "boolean" } } diff --git a/lexicons/com/atproto/repo/strongRef.json b/lexicons/com/atproto/repo/strongRef.json deleted file mode 100644 index cb79625..0000000 --- a/lexicons/com/atproto/repo/strongRef.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "lexicon": 1, - "id": "com.atproto.repo.strongRef", - "description": "A URI with a content-hash fingerprint.", - "defs": { - "main": { - "type": "object", - "required": ["uri", "cid"], - "properties": { - "uri": { "type": "string", "format": "at-uri" }, - "cid": { "type": "string", "format": "cid" } - } - } - } -} diff --git a/lexicons/sh/tangled/repo.json b/lexicons/sh/tangled/repo.json index 89c1e16..5489155 100644 --- a/lexicons/sh/tangled/repo.json +++ b/lexicons/sh/tangled/repo.json @@ -8,11 +8,12 @@ "key": "tid", "record": { "type": "object", - "required": ["name", "repoDid", "createdAt"], + "required": ["repoDid", "createdAt"], "properties": { "name": { "type": "string" }, "description": { "type": "string" }, "repoDid": { "type": "string", "format": "did" }, + "website": { "type": "string" }, "createdAt": { "type": "string", "format": "datetime" }, "topics": { "type": "array", "items": { "type": "string" } } } diff --git a/manifest.toml b/manifest.toml index da42bc7..21d08b8 100644 --- a/manifest.toml +++ b/manifest.toml @@ -11,17 +11,6 @@ packages = [ "gleam", ], requirements = [ ], otp_app = "argv", source = "hex", outer_checksum = "3277D100448BDB4A29B6D58C0F36F631CBC349E8BDD09766C6309DF202831140" }, - { name = "atproto_client", version = "0.1.1", build_tools = [ - "gleam", - ], requirements = [ - "gleam_crypto", - "gleam_http", - "gleam_json", - "gleam_stdlib", - "gleam_time", - "gose", - "kryptos", - ], otp_app = "atproto_client", source = "hex", outer_checksum = "97A6C830B0CF6F3EB9BA6ECF92C7F4E57366919F417DA35FD325431C03B95E19" }, { name = "atproto_codegen", version = "0.3.0", build_tools = [ "gleam", ], requirements = [ @@ -40,11 +29,6 @@ packages = [ "gleam_stdlib", "simplifile", ], otp_app = "atproto_lexicon", source = "hex", outer_checksum = "556D2AE353100AD0CFB279228346C23E8A733FE733250E5F3E9A4634AF574815" }, - { name = "bigi", version = "4.1.1", build_tools = [ - "gleam", - ], requirements = [ - "gleam_stdlib", - ], otp_app = "bigi", source = "hex", outer_checksum = "44FEDF1DF5C1F426A80B67C32B976D0828D66524E11BDC2DD22B78D37C883BA7" }, { name = "casefold", version = "2.1.0", build_tools = [ "gleam", ], requirements = [ @@ -60,11 +44,6 @@ packages = [ ], requirements = [ "gleam_stdlib", ], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" }, - { name = "gleam_crypto", version = "1.6.0", build_tools = [ - "gleam", - ], requirements = [ - "gleam_stdlib", - ], otp_app = "gleam_crypto", source = "hex", outer_checksum = "2DE9E4EF53CF6FEE049D4F765731F7178F7A11AEFAE00EEE63BF7536B354AD3F" }, { name = "gleam_erlang", version = "1.3.0", build_tools = [ "gleam", ], requirements = [ @@ -112,14 +91,6 @@ packages = [ ], requirements = [ "gleam_stdlib", ], otp_app = "gleeunit", source = "hex", outer_checksum = "EC31ABA74256AEA531EDF8169931D775BBB384FED0A8A1BDC4DD9354E3E21826" }, - { name = "gose", version = "2.1.0", build_tools = [ - "gleam", - ], requirements = [ - "gleam_json", - "gleam_stdlib", - "gleam_time", - "kryptos", - ], otp_app = "gose", source = "hex", outer_checksum = "F1F172D428A07BC6F3229F8363B85E6E8C79A5E9E28F27B07C2EFBF1F6B08114" }, { name = "houdini", version = "1.2.1", build_tools = [ "gleam", ], requirements = [ @@ -129,13 +100,6 @@ packages = [ ], requirements = [ "gleam_stdlib", ], otp_app = "justin", source = "hex", outer_checksum = "8B1C62269E8607D0A0ED698B7903984CE0BF7B6B3C0DEA3C6C8302B39402837B" }, - { name = "kryptos", version = "1.4.0", build_tools = [ - "gleam", - ], requirements = [ - "bigi", - "gleam_stdlib", - "gleam_time", - ], otp_app = "kryptos", source = "hex", outer_checksum = "734DAB837962A4A45788F8E6350EF4A6DF20A1F54734736CD1FF3A03FFCE4C97" }, { name = "lustre", version = "5.7.1", build_tools = [ "gleam", ], requirements = [ @@ -157,7 +121,6 @@ packages = [ { name = "shared", version = "1.0.0", build_tools = [ "gleam", ], requirements = [ - "atproto_client", "gleam_json", "gleam_stdlib", "gleam_time", @@ -183,7 +146,6 @@ packages = [ [requirements] argv = { version = ">= 1.0.0 and < 2.0.0" } -atproto_client = { version = ">= 0.1.0 and < 1.0.0" } atproto_codegen = { version = ">= 0.3.0 and < 1.0.0" } filepath = { version = ">= 1.0.0 and < 2.0.0" } gleam_http = { version = ">= 4.0.0 and < 5.0.0" } diff --git a/shared/gleam.toml b/shared/gleam.toml index eaef371..5a6f244 100644 --- a/shared/gleam.toml +++ b/shared/gleam.toml @@ -5,7 +5,6 @@ version = "1.0.0" gleam_stdlib = ">= 1.0.0 and < 2.0.0" lustre = ">= 4.0.0 and < 6.0.0" gleam_json = ">= 3.1.0 and < 4.0.0" -atproto_client = ">= 0.1.0 and < 1.0.0" gleam_time = ">= 1.0.0 and < 2.0.0" [dev_dependencies] diff --git a/shared/manifest.toml b/shared/manifest.toml index b4baea8..0ed8f5f 100644 --- a/shared/manifest.toml +++ b/shared/manifest.toml @@ -7,42 +7,16 @@ # You should check this file into your source control repository. packages = [ - { name = "atproto_client", version = "0.1.1", build_tools = [ - "gleam", - ], requirements = [ - "gleam_crypto", - "gleam_http", - "gleam_json", - "gleam_stdlib", - "gleam_time", - "gose", - "kryptos", - ], otp_app = "atproto_client", source = "hex", outer_checksum = "97A6C830B0CF6F3EB9BA6ECF92C7F4E57366919F417DA35FD325431C03B95E19" }, - { name = "bigi", version = "4.1.1", build_tools = [ - "gleam", - ], requirements = [ - "gleam_stdlib", - ], otp_app = "bigi", source = "hex", outer_checksum = "44FEDF1DF5C1F426A80B67C32B976D0828D66524E11BDC2DD22B78D37C883BA7" }, { name = "exception", version = "2.1.1", build_tools = [ "gleam", ], requirements = [ "gleam_stdlib", ], otp_app = "exception", source = "hex", outer_checksum = "6BDEA95248093599391C3B5DF1835C5C6A86C353C2F99CE539B450E3432FE117" }, - { name = "gleam_crypto", version = "1.6.0", build_tools = [ - "gleam", - ], requirements = [ - "gleam_stdlib", - ], otp_app = "gleam_crypto", source = "hex", outer_checksum = "2DE9E4EF53CF6FEE049D4F765731F7178F7A11AEFAE00EEE63BF7536B354AD3F" }, { name = "gleam_erlang", version = "1.3.0", build_tools = [ "gleam", ], requirements = [ "gleam_stdlib", ], otp_app = "gleam_erlang", source = "hex", outer_checksum = "1124AD3AA21143E5AF0FC5CF3D9529F6DB8CA03E43A55711B60B6B7B3874375C" }, - { name = "gleam_http", version = "4.3.0", build_tools = [ - "gleam", - ], requirements = [ - "gleam_stdlib", - ], otp_app = "gleam_http", source = "hex", outer_checksum = "82EA6A717C842456188C190AFB372665EA56CE13D8559BF3B1DD9E40F619EE0C" }, { name = "gleam_json", version = "3.1.0", build_tools = [ "gleam", ], requirements = [ @@ -68,25 +42,10 @@ packages = [ ], requirements = [ "gleam_stdlib", ], otp_app = "gleeunit", source = "hex", outer_checksum = "EC31ABA74256AEA531EDF8169931D775BBB384FED0A8A1BDC4DD9354E3E21826" }, - { name = "gose", version = "2.1.0", build_tools = [ - "gleam", - ], requirements = [ - "gleam_json", - "gleam_stdlib", - "gleam_time", - "kryptos", - ], otp_app = "gose", source = "hex", outer_checksum = "F1F172D428A07BC6F3229F8363B85E6E8C79A5E9E28F27B07C2EFBF1F6B08114" }, { name = "houdini", version = "1.2.1", build_tools = [ "gleam", ], requirements = [ ], otp_app = "houdini", source = "hex", outer_checksum = "6F8AC2F12974567FB744BEA66AC93CEB76AAEA19AD28564623F76CDA9BC26A85" }, - { name = "kryptos", version = "1.4.0", build_tools = [ - "gleam", - ], requirements = [ - "bigi", - "gleam_stdlib", - "gleam_time", - ], otp_app = "kryptos", source = "hex", outer_checksum = "734DAB837962A4A45788F8E6350EF4A6DF20A1F54734736CD1FF3A03FFCE4C97" }, { name = "lustre", version = "5.7.1", build_tools = [ "gleam", ], requirements = [ @@ -100,7 +59,6 @@ packages = [ ] [requirements] -atproto_client = { version = ">= 0.1.0 and < 1.0.0" } gleam_json = { version = ">= 3.1.0 and < 4.0.0" } gleam_stdlib = { version = ">= 1.0.0 and < 2.0.0" } gleam_time = { version = ">= 1.0.0 and < 2.0.0" } diff --git a/shared/src/api.gleam b/shared/src/api.gleam index 956e2e5..3588ed0 100644 --- a/shared/src/api.gleam +++ b/shared/src/api.gleam @@ -1,5 +1,3 @@ -import gen/repo.{type Repo} -import gleam/list import gleam/string /// Returns the site URL, preferring the BLOG_URL environment variable @@ -22,10 +20,3 @@ pub const did = "did:plc:kcgwlowulc3rac43lregdawo" /// Shared by SSG and client so both render the same list length. pub const plays_limit = 10 - -pub fn filter_pinned_repos( - all_repos: List(Repo), - pinned_dids: List(String), -) -> List(Repo) { - list.filter(all_repos, fn(r) { list.contains(pinned_dids, r.repo_did) }) -} diff --git a/shared/src/decode.gleam b/shared/src/decode.gleam index 4fe063b..f7f54fe 100644 --- a/shared/src/decode.gleam +++ b/shared/src/decode.gleam @@ -1,6 +1,7 @@ +import fetch.{type DecodedRecord, DecodedRecord} import gen/actor/defs.{profile_view_detailed_decoder} import gen/alpha/feed/play.{alpha_feed_play_decoder} -import gen/repo.{repo_decoder} +import gen/repo.{type Repo, repo_decoder} import gleam/dynamic/decode import gleam/json import hydration.{type HydrationModel, HydrationModel} @@ -14,6 +15,13 @@ pub fn decode_hydration_model( fn hydration_model_decoder() -> decode.Decoder(HydrationModel) { use profile <- decode.field("profile", profile_view_detailed_decoder()) use plays <- decode.field("plays", decode.list(alpha_feed_play_decoder())) - use repos <- decode.field("repos", decode.list(repo_decoder())) + use repos <- decode.field("repos", decode.list(decoded_repo_decoder())) decode.success(HydrationModel(profile:, plays:, repos:)) } + +fn decoded_repo_decoder() -> decode.Decoder(DecodedRecord(Repo)) { + use uri <- decode.field("uri", decode.string) + use cid <- decode.field("cid", decode.string) + use value <- decode.field("value", repo_decoder()) + decode.success(DecodedRecord(uri:, cid:, value:)) +} diff --git a/shared/src/encode.gleam b/shared/src/encode.gleam index ca5aa7e..b433324 100644 --- a/shared/src/encode.gleam +++ b/shared/src/encode.gleam @@ -1,6 +1,7 @@ +import fetch.{type DecodedRecord} import gen/actor/defs.{profile_view_detailed_fields} import gen/alpha/feed/play.{alpha_feed_play_fields} -import gen/repo.{repo_fields} +import gen/repo.{type Repo, repo_fields} import gleam/json import hydration.{type HydrationModel} @@ -19,8 +20,16 @@ pub fn encode_hydration_model(data: HydrationModel) -> String { ), #( "repos", - json.array(from: data.repos, of: fn(r) { json.object(repo_fields(r)) }), + json.array(from: data.repos, of: fn(r) { encode_decoded_repo(r) }), ), ]) |> json.to_string } + +fn encode_decoded_repo(record: DecodedRecord(Repo)) -> json.Json { + json.object([ + #("uri", json.string(record.uri)), + #("cid", json.string(record.cid)), + #("value", json.object(repo_fields(record.value))), + ]) +} diff --git a/shared/src/fetch.gleam b/shared/src/fetch.gleam index fe57586..7b73873 100644 --- a/shared/src/fetch.gleam +++ b/shared/src/fetch.gleam @@ -1,27 +1,38 @@ -//// URLs and response decoders for AT Protocol data. +//// URL builders and response decoders for AT Protocol data. //// -//// URL builders use `uri.query_to_string` (same as the generated XRPC client) -//// rather than string concatenation. Response decoders accept raw JSON from -//// either `xrpc.Client` (Erlang) or `fetch_text` (browser). +//// URL builders use `uri.query_to_string`; response decoders accept +//// raw JSON from either the SSG's httpc transport or the browser's +//// `fetch_text`, so the same module serves both targets. +//// +//// `DecodedRecord` preserves a listRecords record's URI alongside +//// its decoded value, so callers can derive info (e.g. a repo's +//// name from its URI rkey) that isn't in the record body. import api import gen/actor/defs.{type ProfileViewDetailed, profile_view_detailed_decoder} -import gen/actor/profile.{actor_profile_decoder} +import gen/actor/profile.{type ActorProfile, actor_profile_decoder} import gen/alpha/feed/play.{type AlphaFeedPlay, alpha_feed_play_decoder} -import gen/repo.{type Repo, repo_decoder} +import gen/repo.{type Repo, Repo, repo_decoder} import gen/repo/list_records.{type Record, record_decoder} import gleam/dynamic/decode import gleam/int import gleam/json import gleam/list -import gleam/option +import gleam/option.{Some} import gleam/result +import gleam/string import gleam/uri +/// A listRecords record paired with its decoded value. +pub type DecodedRecord(a) { + DecodedRecord(uri: String, cid: String, value: a) +} + pub fn profile_url() -> String { let params = [#("actor", "karitham.dev")] - let query = uri.query_to_string(params) - api.public_api <> "/xrpc/app.bsky.actor.getProfile?" <> query + api.public_api + <> "/xrpc/app.bsky.actor.getProfile?" + <> uri.query_to_string(params) } pub fn plays_url() -> String { @@ -30,8 +41,9 @@ pub fn plays_url() -> String { #("collection", "fm.teal.alpha.feed.play"), #("limit", int.to_string(api.plays_limit)), ] - let query = uri.query_to_string(params) - api.pds_endpoint <> "/xrpc/com.atproto.repo.listRecords?" <> query + api.pds_endpoint + <> "/xrpc/com.atproto.repo.listRecords?" + <> uri.query_to_string(params) } pub fn pinned_dids_url() -> String { @@ -39,8 +51,9 @@ pub fn pinned_dids_url() -> String { #("repo", api.did), #("collection", "sh.tangled.actor.profile"), ] - let query = uri.query_to_string(params) - api.pds_endpoint <> "/xrpc/com.atproto.repo.listRecords?" <> query + api.pds_endpoint + <> "/xrpc/com.atproto.repo.listRecords?" + <> uri.query_to_string(params) } pub fn repos_url() -> String { @@ -48,61 +61,111 @@ pub fn repos_url() -> String { #("repo", api.did), #("collection", "sh.tangled.repo"), ] - let query = uri.query_to_string(params) - api.pds_endpoint <> "/xrpc/com.atproto.repo.listRecords?" <> query + api.pds_endpoint + <> "/xrpc/com.atproto.repo.listRecords?" + <> uri.query_to_string(params) } +/// Decode a `getProfile` JSON body into a typed profile. pub fn decode_profile( body: String, ) -> Result(ProfileViewDetailed, json.DecodeError) { json.parse(body, profile_view_detailed_decoder()) } +/// Decode the plays `listRecords` body. The wrapper is unwrapped +/// since the plays view doesn't need the URI. pub fn decode_plays( body: String, ) -> Result(List(AlphaFeedPlay), json.DecodeError) { - use records <- result.try(json.parse(body, records_list_decoder())) - Ok(decode_record_values(records, alpha_feed_play_decoder())) + decode_records(body, alpha_feed_play_decoder()) + |> result.map(list.map(_, fn(record) { record.value })) } -pub fn decode_repos(body: String) -> Result(List(Repo), json.DecodeError) { - use records <- result.try(json.parse(body, records_list_decoder())) - Ok(decode_record_values(records, repo_decoder())) +/// Decode the repos `listRecords` body, keeping each record's URI so +/// the data layer can derive the display name from the rkey. +pub fn decode_repos( + body: String, +) -> Result(List(DecodedRecord(Repo)), json.DecodeError) { + decode_records(body, repo_decoder()) } -pub fn decode_pinned_dids( +/// Decode the actor profile `listRecords` body. Pinned DIDs are +/// extracted separately via `pinned_dids_from_profiles`. +pub fn decode_actor_profiles( body: String, -) -> Result(List(String), json.DecodeError) { - use records <- result.try(json.parse(body, records_list_decoder())) - Ok(extract_pinned_dids(records)) +) -> Result(List(DecodedRecord(ActorProfile)), json.DecodeError) { + decode_records(body, actor_profile_decoder()) } -/// Records whose values fail to decode are silently dropped — this keeps +/// Parse a listRecords JSON body and decode each record's value. +/// Records whose value fails to decode are silently dropped — keeps /// us robust against schema drift in individual records. -pub fn decode_record_values( - records: List(Record), +pub fn decode_records( + body: String, decoder: decode.Decoder(a), -) -> List(a) { +) -> Result(List(DecodedRecord(a)), json.DecodeError) { + use records <- result.try(json.parse(body, list_of_records_decoder())) records - |> list.map(fn(record) { decode.run(record.value, decoder) }) - |> result.values + |> list.filter_map(fn(record) { + decode.run(record.value, decoder) + |> result.map(fn(value) { + DecodedRecord(uri: record.uri, cid: record.cid, value:) + }) + }) + |> Ok } -fn records_list_decoder() -> decode.Decoder(List(Record)) { +fn list_of_records_decoder() -> decode.Decoder(List(Record)) { use records <- decode.field("records", decode.list(record_decoder())) decode.success(records) } -fn extract_pinned_dids(records: List(Record)) -> List(String) { +/// Return the last path segment of an `at://` URI — the rkey. For +/// `at://did:plc:abc/sh.tangled.repo/blog` this returns +/// `Ok("blog")`, the repo's human-readable slug. +pub fn rkey_from_uri(uri: String) -> Result(String, Nil) { + list.last(string.split(uri, on: "/")) +} + +/// Fill in a Tangled repo's `name` from the URI rkey when the +/// original is missing or empty. Records without a real name usually +/// hold an auto-generated hash; the rkey is the slug Tangled uses +/// for the URL. Returns the wrapper unchanged if the URI can't be +/// parsed, so the caller can still pass it to hydration. +pub fn resolve_repo_name(record: DecodedRecord(Repo)) -> DecodedRecord(Repo) { + let repo = record.value + case repo.name { + Some(name) if name != "" -> record + _ -> + case rkey_from_uri(record.uri) { + Ok(rkey) -> + DecodedRecord(..record, value: Repo(..repo, name: Some(rkey))) + Error(_) -> record + } + } +} + +/// Drop repo records whose `repo_did` isn't in the pinned list. +pub fn filter_repos_by_did( + records: List(DecodedRecord(Repo)), + pinned_dids: List(String), +) -> List(DecodedRecord(Repo)) { + list.filter(records, fn(record) { + list.contains(pinned_dids, record.value.repo_did) + }) +} + +/// Extract non-empty pinned DIDs from one or more actor profile +/// records. Tangled pads the list with empty rkeys as placeholders; +/// those are dropped. +pub fn pinned_dids_from_profiles( + records: List(DecodedRecord(ActorProfile)), +) -> List(String) { records - |> list.map(fn(record) { - case decode.run(record.value, actor_profile_decoder()) { - Ok(profile) -> - profile.pinned_repositories - |> option.unwrap(or: []) - |> list.filter(fn(d) { d != "" }) - Error(_) -> [] - } + |> list.flat_map(fn(record) { + record.value.pinned_repositories + |> option.unwrap(or: []) + |> list.filter(fn(did) { did != "" }) }) - |> list.flatten } diff --git a/shared/src/gen/client.gleam b/shared/src/gen/client.gleam deleted file mode 100644 index a1368e8..0000000 --- a/shared/src/gen/client.gleam +++ /dev/null @@ -1,101 +0,0 @@ -//// Generated by codegen from lexicons/. Do not edit by hand. -//// Typed XRPC client functions for the generated lexicon methods. -//// Each function takes an `xrpc.Client`, a service base URL, and an auth -//// token, and returns a typed result or a per-method error union. - -import gleam/dynamic/decode -import gleam/int -import gleam/list -import gleam/option.{type Option} -import gleam/result -import gleam/uri -import atproto/xrpc -import gen/actor/defs as actor_defs -import gen/repo/list_records as repo_list_records - -pub type ActorGetProfileError { - ActorGetProfileTransport(xrpc.XrpcError) - ActorGetProfileUnexpected(status: Int, message: Option(String), body: String) -} - -fn map_actor_get_profile_error(err: xrpc.XrpcError) -> ActorGetProfileError { - case err { - xrpc.RequestFailed(_) -> ActorGetProfileTransport(err) - xrpc.DecodeFailed(_) -> ActorGetProfileTransport(err) - xrpc.BadStatus(status, _, message, body) -> ActorGetProfileUnexpected(status, message, body) - } -} - -pub type ActorGetProfileParams { - ActorGetProfileParams( - actor: String, - ) -} - -pub fn actor_get_profile( - client: xrpc.Client, - service: String, - params: ActorGetProfileParams, - token: Option(String), -) -> Result(actor_defs.ProfileViewDetailed, ActorGetProfileError) { - let query = uri.query_to_string(list.flatten([[#("actor", params.actor)]])) - let url = service <> "/xrpc/app.bsky.actor.getProfile?" <> query - xrpc.get(client, url, token) - |> result.map_error(map_actor_get_profile_error) - |> result.try(fn(resp) { - xrpc.parse(resp.body, actor_defs.profile_view_detailed_decoder()) - |> result.map_error(map_actor_get_profile_error) - }) -} - -pub type RepoListRecordsError { - RepoListRecordsTransport(xrpc.XrpcError) - RepoListRecordsUnexpected(status: Int, message: Option(String), body: String) -} - -fn map_repo_list_records_error(err: xrpc.XrpcError) -> RepoListRecordsError { - case err { - xrpc.RequestFailed(_) -> RepoListRecordsTransport(err) - xrpc.DecodeFailed(_) -> RepoListRecordsTransport(err) - xrpc.BadStatus(status, _, message, body) -> RepoListRecordsUnexpected(status, message, body) - } -} - -pub type RepoListRecordsParams { - RepoListRecordsParams( - collection: String, - cursor: Option(String), - limit: Option(Int), - repo: String, - reverse: Option(Bool), - ) -} - -pub type RepoListRecordsOutput { - RepoListRecordsOutput( - cursor: Option(String), - records: List(repo_list_records.Record), - ) -} - -pub fn repo_list_records_output_decoder() -> decode.Decoder(RepoListRecordsOutput) { - use cursor <- decode.optional_field("cursor", option.None, decode.optional(decode.string)) - use records <- decode.field("records", decode.list(repo_list_records.record_decoder())) - decode.success(RepoListRecordsOutput(cursor:, records:)) -} - -pub fn repo_list_records( - client: xrpc.Client, - service: String, - params: RepoListRecordsParams, - token: Option(String), -) -> Result(RepoListRecordsOutput, RepoListRecordsError) { - let query = uri.query_to_string(list.flatten([[#("collection", params.collection)], case params.cursor { option.Some(v) -> [#("cursor", v)] option.None -> [] }, case params.limit { option.Some(v) -> [#("limit", int.to_string(v))] option.None -> [] }, [#("repo", params.repo)], case params.reverse { option.Some(v) -> [#("reverse", case v { True -> "true" False -> "false" })] option.None -> [] }])) - let url = service <> "/xrpc/com.atproto.repo.listRecords?" <> query - xrpc.get(client, url, token) - |> result.map_error(map_repo_list_records_error) - |> result.try(fn(resp) { - xrpc.parse(resp.body, repo_list_records_output_decoder()) - |> result.map_error(map_repo_list_records_error) - }) -} diff --git a/shared/src/gen/repo.gleam b/shared/src/gen/repo.gleam index b358662..90edce3 100644 --- a/shared/src/gen/repo.gleam +++ b/shared/src/gen/repo.gleam @@ -12,17 +12,20 @@ pub type Repo { Repo( created_at: String, description: Option(String), - name: String, + name: Option(String), repo_did: String, topics: Option(List(String)), + website: Option(String), ) } pub fn repo_fields(value: Repo) -> List(#(String, json.Json)) { list.flatten([ - [#("createdAt", json.string(value.created_at)), #("name", json.string(value.name)), #("repoDid", json.string(value.repo_did))], + [#("createdAt", json.string(value.created_at)), #("repoDid", json.string(value.repo_did))], internal.opt("description", value.description, json.string), + internal.opt("name", value.name, json.string), internal.opt("topics", value.topics, fn(items) { json.array(items, json.string) }), + internal.opt("website", value.website, json.string), ]) } @@ -36,8 +39,9 @@ pub fn encode_repo(value: Repo) -> json.Json { pub fn repo_decoder() -> decode.Decoder(Repo) { use created_at <- decode.field("createdAt", decode.string) use description <- decode.optional_field("description", option.None, decode.optional(decode.string)) - use name <- decode.field("name", decode.string) + use name <- decode.optional_field("name", option.None, decode.optional(decode.string)) use repo_did <- decode.field("repoDid", decode.string) use topics <- decode.optional_field("topics", option.None, decode.optional(decode.list(decode.string))) - decode.success(Repo(created_at:, description:, name:, repo_did:, topics:)) + use website <- decode.optional_field("website", option.None, decode.optional(decode.string)) + decode.success(Repo(created_at:, description:, name:, repo_did:, topics:, website:)) } diff --git a/shared/src/hydration.gleam b/shared/src/hydration.gleam index 201054d..207b06f 100644 --- a/shared/src/hydration.gleam +++ b/shared/src/hydration.gleam @@ -1,3 +1,4 @@ +import fetch.{type DecodedRecord} import gen/actor/defs.{type ProfileViewDetailed} import gen/alpha/feed/play.{type AlphaFeedPlay} import gen/repo.{type Repo} @@ -6,6 +7,6 @@ pub type HydrationModel { HydrationModel( profile: ProfileViewDetailed, plays: List(AlphaFeedPlay), - repos: List(Repo), + repos: List(DecodedRecord(Repo)), ) } diff --git a/shared/src/repos.gleam b/shared/src/repos.gleam index be08ea9..5c1721f 100644 --- a/shared/src/repos.gleam +++ b/shared/src/repos.gleam @@ -11,9 +11,9 @@ import section const max_repos = 5 -/// Dedupe by repo_did, drop auto-generated hash names, sort newest -/// first, and take the top N. Sorting uses the parsed timestamp so -/// different source offsets compare correctly. +/// Dedupe by repo_did, sort newest first, take the top N. Sorting +/// uses the parsed timestamp so different source offsets compare +/// correctly. pub fn select_top_repos(repos: List(Repo)) -> List(Repo) { repos |> dedup_by_did @@ -47,32 +47,23 @@ pub fn repos_section(repos: List(Repo)) -> Element(msg) { } fn dedup_by_did(repos: List(Repo)) -> List(Repo) { - repos - |> list.fold([], fn(acc: List(Repo), repo: Repo) { - case list.any(acc, fn(r: Repo) { r.repo_did == repo.repo_did }) { - True -> acc - False -> list.append(acc, [repo]) - } - }) - |> list.filter(fn(r: Repo) { !is_hash_name(r.name) }) -} + use acc, repo <- list.fold(repos, []) -fn is_hash_name(name: String) -> Bool { - string.length(name) > 12 - && !string.contains(name, "-") - && !string.contains(name, "_") + case list.any(acc, fn(r: Repo) { r.repo_did == repo.repo_did }) { + True -> acc + False -> list.append(acc, [repo]) + } } fn render_repo_card(repo: Repo) -> Element(msg) { - let date_str = case timestamp.parse_rfc3339(repo.created_at) { - Ok(ts) -> date.format_month_day_year(ts) - Error(_) -> repo.created_at - } card.card( title_href: "https://tangled.org/" <> repo.repo_did, - title_text: repo.name, + title_text: unwrap(repo.name, ""), title_target: Some("_blank"), - date: date_str, + date: case timestamp.parse_rfc3339(repo.created_at) { + Ok(ts) -> date.format_month_day_year(ts) + Error(_) -> repo.created_at + }, description: unwrap(repo.description, ""), topics: unwrap(repo.topics, []), ) diff --git a/shared/test/api_test.gleam b/shared/test/api_test.gleam deleted file mode 100644 index 7b8e4a7..0000000 --- a/shared/test/api_test.gleam +++ /dev/null @@ -1,49 +0,0 @@ -import api -import gen/repo.{type Repo, Repo} -import gleam/list -import gleam/option.{Some} -import gleeunit/should - -// --- filter_pinned_repos --- - -fn repo(did: String, name: String) -> Repo { - Repo( - name: name, - description: Some(""), - repo_did: did, - created_at: "", - topics: Some([]), - ) -} - -pub fn filter_pinned_repos_keeps_only_pinned_test() { - let all = [ - repo("did:plc:aaa", "a"), - repo("did:plc:bbb", "b"), - repo("did:plc:ccc", "c"), - ] - let pinned = ["did:plc:bbb"] - - let result = api.filter_pinned_repos(all, pinned) - result |> list.map(fn(r) { r.name }) |> should.equal(["b"]) -} - -pub fn filter_pinned_repos_empty_pinned_test() { - let all = [repo("did:plc:aaa", "a")] - api.filter_pinned_repos(all, []) |> should.equal([]) -} - -pub fn filter_pinned_repos_empty_repos_test() { - api.filter_pinned_repos([], ["did:plc:aaa"]) |> should.equal([]) -} - -pub fn filter_pinned_repos_all_pinned_test() { - let all = [repo("did:plc:aaa", "a"), repo("did:plc:bbb", "b")] - let pinned = ["did:plc:aaa", "did:plc:bbb"] - api.filter_pinned_repos(all, pinned) |> list.length |> should.equal(2) -} - -pub fn filter_pinned_repos_unpinned_did_ignored_test() { - let all = [repo("did:plc:aaa", "a")] - api.filter_pinned_repos(all, ["did:plc:zzz"]) |> should.equal([]) -} diff --git a/shared/test/encode_test.gleam b/shared/test/encode_test.gleam index e6ebc9e..941ea77 100644 --- a/shared/test/encode_test.gleam +++ b/shared/test/encode_test.gleam @@ -1,5 +1,6 @@ import decode import encode +import fetch.{type DecodedRecord, DecodedRecord} import gen/actor/defs.{type ProfileViewDetailed, ProfileViewDetailed} import gen/alpha/feed/play.{type AlphaFeedPlay, AlphaFeedPlay, ArtistView} import gen/repo.{type Repo, Repo} @@ -48,21 +49,31 @@ fn sample_plays() -> List(AlphaFeedPlay) { ] } -fn sample_repos() -> List(Repo) { +fn sample_repos() -> List(DecodedRecord(Repo)) { [ - Repo( - name: "repo-one", - description: Some("first"), - repo_did: "did:plc:one", - created_at: "2026-01-01T00:00:00Z", - topics: Some(["gleam", "atproto"]), + DecodedRecord( + uri: "at://did:plc:one/sh.tangled.repo/repo-one", + cid: "bafy1", + value: Repo( + name: Some("repo-one"), + description: Some("first"), + repo_did: "did:plc:one", + created_at: "2026-01-01T00:00:00Z", + topics: Some(["gleam", "atproto"]), + website: None, + ), ), - Repo( - name: "repo-two", - description: Some(""), - repo_did: "did:plc:two", - created_at: "2026-02-01T00:00:00Z", - topics: Some([]), + DecodedRecord( + uri: "at://did:plc:two/sh.tangled.repo/repo-two", + cid: "bafy2", + value: Repo( + name: Some("repo-two"), + description: Some(""), + repo_did: "did:plc:two", + created_at: "2026-02-01T00:00:00Z", + topics: Some([]), + website: None, + ), ), ] } diff --git a/shared/test/fetch_test.gleam b/shared/test/fetch_test.gleam index 8b58fd8..cc680af 100644 --- a/shared/test/fetch_test.gleam +++ b/shared/test/fetch_test.gleam @@ -1,237 +1,163 @@ -import fetch -import gleam/list -import gleam/option.{Some} -import gleam/string +import fetch.{type DecodedRecord, DecodedRecord} +import gen/actor/profile.{type ActorProfile, ActorProfile} +import gen/repo.{type Repo, Repo} +import gleam/option.{type Option, None, Some} import gleeunit/should -// --- URL builders --- - -pub fn profile_url_uses_public_api_test() { - string.starts_with(fetch.profile_url(), "https://public.api.bsky.app") - |> should.equal(True) - string.contains(fetch.profile_url(), "karitham.dev") - |> should.equal(True) -} - -pub fn plays_url_includes_shared_limit_test() { - let url = fetch.plays_url() - string.contains(url, "fm.teal.alpha.feed.play") |> should.equal(True) - string.contains(url, "limit=") |> should.equal(True) - string.contains(url, "eurosky.social") |> should.equal(True) -} - -pub fn pinned_dids_url_test() { - let url = fetch.pinned_dids_url() - string.contains(url, "sh.tangled.actor.profile") |> should.equal(True) - string.contains(url, "eurosky.social") |> should.equal(True) -} - -pub fn repos_url_test() { - let url = fetch.repos_url() - string.contains(url, "sh.tangled.repo") |> should.equal(True) - string.contains(url, "eurosky.social") |> should.equal(True) -} - -// --- Profile --- - -pub fn decode_profile_test() { - let json = - "{ - \"did\": \"did:plc:test\", - \"handle\": \"test.bsky.social\", - \"displayName\": \"Test User\", - \"description\": \"A test profile\", - \"avatar\": \"https://example.com/avatar.jpg\", - \"banner\": \"https://example.com/banner.jpg\", - \"followersCount\": 100, - \"followsCount\": 50, - \"postsCount\": 25 - }" - - let result = fetch.decode_profile(json) - let assert Ok(profile) = result - profile.did |> should.equal("did:plc:test") - profile.handle |> should.equal("test.bsky.social") - profile.display_name |> should.equal(Some("Test User")) - profile.description |> should.equal(Some("A test profile")) - profile.avatar |> should.equal(Some("https://example.com/avatar.jpg")) - profile.banner |> should.equal(Some("https://example.com/banner.jpg")) - profile.followers_count |> should.equal(Some(100)) - profile.follows_count |> should.equal(Some(50)) - profile.posts_count |> should.equal(Some(25)) -} - -pub fn decode_profile_minimal_test() { - let json = - "{ - \"did\": \"did:plc:test\", - \"handle\": \"test.bsky.social\", - \"displayName\": \"\", - \"description\": \"\", - \"avatar\": \"\", - \"banner\": \"\", - \"followersCount\": 0, - \"followsCount\": 0, - \"postsCount\": 0 - }" - - let result = fetch.decode_profile(json) - let assert Ok(profile) = result - profile.did |> should.equal("did:plc:test") - profile.handle |> should.equal("test.bsky.social") - profile.display_name |> should.equal(Some("")) -} - -// --- Plays --- - -pub fn decode_plays_test() { - let json = - "{ - \"records\": [ - { - \"cid\": \"bafy1\", - \"uri\": \"at://did:plc:test/fm.teal.alpha.feed.play/1\", - \"value\": { - \"trackName\": \"Test Song\", - \"artists\": [{ \"artistName\": \"Test Artist\", \"artistMbId\": \"mbid-1\" }], - \"releaseName\": \"Test Album\", - \"duration\": 180, - \"playedTime\": \"2026-07-18T10:00:00Z\", - \"originUrl\": \"https://example.com/track\" - } - } - ] - }" - - let result = fetch.decode_plays(json) - let assert Ok(plays) = result - list.length(plays) |> should.equal(1) - - let assert [play] = plays - play.track_name |> should.equal("Test Song") - list.length(play.artists) |> should.equal(1) - play.release_name |> should.equal(Some("Test Album")) - play.duration |> should.equal(Some(180)) - play.played_time |> should.equal("2026-07-18T10:00:00Z") - play.origin_url |> should.equal(Some("https://example.com/track")) -} - -pub fn decode_plays_minimal_test() { - let json = - "{ - \"records\": [ - { - \"cid\": \"bafy2\", - \"uri\": \"at://did:plc:test/fm.teal.alpha.feed.play/1\", - \"value\": { - \"trackName\": \"Minimal\", - \"artists\": [{ \"artistName\": \"Solo\" }], - \"playedTime\": \"2026-07-18T11:00:00Z\" - } - } - ] - }" - - let result = fetch.decode_plays(json) - let assert Ok(plays) = result - let assert [play] = plays - play.track_name |> should.equal("Minimal") - let assert [artist] = play.artists - artist.artist_name |> should.equal("Solo") - artist.artist_mb_id |> should.equal(option.None) - play.release_name |> should.equal(option.None) - play.origin_url |> should.equal(option.None) - play.duration |> should.equal(option.None) -} - -// --- Pinned DIDs --- - -pub fn decode_pinned_dids_test() { - let json = - "{ - \"records\": [ - { - \"cid\": \"bafy1\", - \"uri\": \"at://did:plc:foo/sh.tangled.actor.profile/self\", - \"value\": { - \"$type\": \"sh.tangled.actor.profile\", - \"pinnedRepositories\": [ - \"did:plc:4b3gxcvelmxan674wojtufdt\", - \"\", - \"\", - \"\", - \"\", - \"\" - ] - } - } - ] - }" - - let assert Ok(dids) = fetch.decode_pinned_dids(json) - dids |> should.equal(["did:plc:4b3gxcvelmxan674wojtufdt"]) -} - -pub fn decode_pinned_dids_empty_test() { - let json = "{\"records\":[]}" - let assert Ok(dids) = fetch.decode_pinned_dids(json) - dids |> should.equal([]) -} - -// --- Repos --- - -pub fn decode_repos_test() { - let json = - "{ - \"records\": [ - { - \"cid\": \"bafy1\", - \"uri\": \"at://did:plc:test/sh.tangled.repo/abc\", - \"value\": { - \"name\": \"my-project\", - \"description\": \"A test repo\", - \"repoDid\": \"did:plc:repotest\", - \"createdAt\": \"2026-01-15T10:00:00Z\", - \"topics\": [\"gleam\", \"testing\"] - } - } - ] - }" - - let result = fetch.decode_repos(json) - let assert Ok(repos) = result - list.length(repos) |> should.equal(1) - - let assert [repo] = repos - repo.name |> should.equal("my-project") - repo.description |> should.equal(Some("A test repo")) - repo.repo_did |> should.equal("did:plc:repotest") - repo.created_at |> should.equal("2026-01-15T10:00:00Z") - repo.topics |> should.equal(Some(["gleam", "testing"])) -} - -pub fn decode_repos_no_topics_test() { - let json = - "{ - \"records\": [ - { - \"cid\": \"bafy2\", - \"uri\": \"at://did:plc:test/sh.tangled.repo/def\", - \"value\": { - \"name\": \"unnamed\", - \"description\": \"\", - \"repoDid\": \"did:plc:repotest2\", - \"createdAt\": \"2026-02-20T00:00:00Z\" - } - } - ] - }" +fn record( + uri: String, + did: String, + name: Option(String), +) -> DecodedRecord(Repo) { + DecodedRecord( + uri: uri, + cid: "bafy", + value: Repo( + name: name, + description: Some(""), + repo_did: did, + created_at: "2026-01-01T00:00:00Z", + topics: Some([]), + website: None, + ), + ) +} + +fn named(uri: String, did: String, name: String) -> DecodedRecord(Repo) { + record(uri, did, Some(name)) +} + +fn profile_with_pins(dids: List(String)) -> DecodedRecord(ActorProfile) { + DecodedRecord( + uri: "at://did:plc:self/sh.tangled.actor.profile/self", + cid: "bafy", + value: ActorProfile(pinned_repositories: Some(dids)), + ) +} + +// --- rkey_from_uri --- + +pub fn rkey_from_uri_extracts_rkey_test() { + fetch.rkey_from_uri("at://did:plc:abc/sh.tangled.repo/blog") + |> should.equal(Ok("blog")) +} + +pub fn rkey_from_uri_handles_handle_authority_test() { + fetch.rkey_from_uri("at://karitham.dev/sh.tangled.repo/karitham_blog") + |> should.equal(Ok("karitham_blog")) +} + +// --- resolve_repo_name --- + +pub fn resolve_repo_name_keeps_existing_name_test() { + let rec = + named( + "at://did:plc:abc/sh.tangled.repo/whatever", + "did:plc:abc", + "real-name", + ) + let resolved = fetch.resolve_repo_name(rec) + resolved.value.name |> should.equal(Some("real-name")) +} + +pub fn resolve_repo_name_fills_missing_name_from_rkey_test() { + let rec = + record("at://did:plc:abc/sh.tangled.repo/my-cool-repo", "did:plc:abc", None) + let resolved = fetch.resolve_repo_name(rec) + resolved.value.name |> should.equal(Some("my-cool-repo")) +} - let result = fetch.decode_repos(json) - let assert Ok(repos) = result - list.length(repos) |> should.equal(1) +pub fn resolve_repo_name_fills_empty_name_from_rkey_test() { + let rec = + record( + "at://did:plc:abc/sh.tangled.repo/auto-slug", + "did:plc:abc", + Some(""), + ) + let resolved = fetch.resolve_repo_name(rec) + resolved.value.name |> should.equal(Some("auto-slug")) +} + +// --- filter_repos_by_did --- + +pub fn filter_repos_by_did_keeps_matching_test() { + let keep = named("at://x/y/a", "did:plc:keep", "a") + let drop = named("at://x/y/b", "did:plc:drop", "b") + fetch.filter_repos_by_did([keep, drop], ["did:plc:keep"]) + |> should.equal([keep]) +} + +pub fn filter_repos_by_did_drops_all_when_none_match_test() { + let rec = named("at://x/y/a", "did:plc:nope", "a") + fetch.filter_repos_by_did([rec], ["did:plc:other"]) + |> should.equal([]) +} + +// --- pinned_dids_from_profiles --- + +pub fn pinned_dids_from_profiles_extracts_test() { + fetch.pinned_dids_from_profiles([ + profile_with_pins(["did:plc:one", "did:plc:two"]), + ]) + |> should.equal(["did:plc:one", "did:plc:two"]) +} + +pub fn pinned_dids_from_profiles_drops_empty_rkeys_test() { + // Tangled pads the list with empty rkeys as placeholders. + fetch.pinned_dids_from_profiles([ + profile_with_pins(["did:plc:keep", "", "", "", "", ""]), + ]) + |> should.equal(["did:plc:keep"]) +} + +pub fn pinned_dids_from_profiles_flattens_multiple_test() { + fetch.pinned_dids_from_profiles([ + profile_with_pins(["did:plc:one"]), + profile_with_pins(["did:plc:two", "did:plc:three"]), + ]) + |> should.equal(["did:plc:one", "did:plc:two", "did:plc:three"]) +} + +pub fn pinned_dids_from_profiles_handles_none_test() { + let none_profile = + DecodedRecord( + uri: "at://x/y/z", + cid: "bafy", + value: ActorProfile(pinned_repositories: None), + ) + fetch.pinned_dids_from_profiles([none_profile]) + |> should.equal([]) +} + +// --- decode_repos --- + +pub fn decode_repos_preserves_uri_test() { + let body = + "{\"records\":[{ + \"cid\": \"bafy1\", + \"uri\": \"at://did:plc:abc/sh.tangled.repo/blog\", + \"value\": { + \"name\": \"\", + \"repoDid\": \"did:plc:abc\", + \"createdAt\": \"2026-01-15T10:00:00Z\" + } + }]}" + let assert Ok(records) = fetch.decode_repos(body) + let assert [rec] = records + rec.uri |> should.equal("at://did:plc:abc/sh.tangled.repo/blog") + rec.value.repo_did |> should.equal("did:plc:abc") +} - let assert [repo] = repos - repo.description |> should.equal(Some("")) - repo.topics |> should.equal(option.None) +pub fn decode_repos_drops_records_with_invalid_value_test() { + let body = + "{\"records\":[ + {\"cid\": \"bafy1\", \"uri\": \"at://x/y/a\", \"value\": { + \"name\": \"good\", \"repoDid\": \"did:plc:a\", \"createdAt\": \"2026-01-01T00:00:00Z\" + }}, + {\"cid\": \"bafy2\", \"uri\": \"at://x/y/b\", \"value\": \"not an object\"} + ]}" + let assert Ok(records) = fetch.decode_repos(body) + let assert [kept] = records + kept.uri |> should.equal("at://x/y/a") + kept.value.name |> should.equal(Some("good")) } diff --git a/shared/test/repos_test.gleam b/shared/test/repos_test.gleam index ee4af0d..72eda00 100644 --- a/shared/test/repos_test.gleam +++ b/shared/test/repos_test.gleam @@ -1,16 +1,17 @@ import gen/repo.{type Repo, Repo} import gleam/list -import gleam/option.{Some} +import gleam/option.{None, Some} import gleeunit/should import repos fn repo(did: String, name: String, created_at: String) -> Repo { Repo( - name: name, + name: Some(name), description: Some(""), repo_did: did, created_at: created_at, topics: Some([]), + website: None, ) } @@ -26,18 +27,7 @@ pub fn select_top_dedupes_by_did_test() { let result = repos.select_top_repos(input) list.length(result) |> should.equal(1) let assert [r] = result - r.name |> should.equal("first") -} - -pub fn select_top_filters_hash_names_test() { - let input = [ - repo("did:plc:keep", "my-cool-project", "2026-01-01T00:00:00Z"), - repo("did:plc:drop", "abcdefghijklm", "2026-02-01T00:00:00Z"), - ] - let result = repos.select_top_repos(input) - list.length(result) |> should.equal(1) - let assert [r] = result - r.repo_did |> should.equal("did:plc:keep") + r.name |> should.equal(Some("first")) } pub fn select_top_sorts_newest_first_test() { @@ -47,7 +37,8 @@ pub fn select_top_sorts_newest_first_test() { repo("did:plc:mid", "mid", "2025-01-01T00:00:00Z"), ] let result = repos.select_top_repos(input) - list.map(result, fn(r) { r.name }) |> should.equal(["new", "mid", "old"]) + list.map(result, fn(r) { option.unwrap(r.name, "") }) + |> should.equal(["new", "mid", "old"]) } pub fn select_top_takes_max_repos_test() { diff --git a/src/build.gleam b/src/build.gleam index 90f9992..5b0e8f2 100644 --- a/src/build.gleam +++ b/src/build.gleam @@ -79,7 +79,11 @@ fn write_index(data: SiteData) { let dynamic = div([id("dynamic-sections")], [ - dynamic.dynamic_sections(data.profile, data.recent_plays, data.repos), + dynamic.dynamic_sections( + data.profile, + data.recent_plays, + list.map(data.repos, fn(record) { record.value }), + ), ]) let content = diff --git a/src/data/fetch.gleam b/src/data/fetch.gleam index 8d9a2a1..bc8db6b 100644 --- a/src/data/fetch.gleam +++ b/src/data/fetch.gleam @@ -1,123 +1,88 @@ -import api +//// Gather all site data for the SSG. +//// +//// Each section fetches its own URL through the shared decoders in +//// `shared/src/fetch.gleam`. Failure on a section logs a warning +//// and returns an empty value, so one slow PDS doesn't take the +//// whole build down. + import data/frontmatter import data/model.{type Post, type SiteData, SiteData} import data/transport -import fetch -import gen/actor/defs.{type ProfileViewDetailed} -import gen/actor/profile.{actor_profile_decoder} -import gen/alpha/feed/play.{type AlphaFeedPlay, alpha_feed_play_decoder} -import gen/client.{ - ActorGetProfileParams, RepoListRecordsParams, actor_get_profile, - repo_list_records, -} -import gen/repo.{type Repo, repo_decoder} -import gleam/dynamic/decode +import fetch.{type DecodedRecord} +import gen/alpha/feed/play.{type AlphaFeedPlay} +import gen/repo.{type Repo} import gleam/io import gleam/list -import gleam/option.{None, Some} import gleam/order -import gleam/result import gleam/string import simplifile pub fn fetch_all() -> SiteData { - let client = transport.http_client() + let pinned_dids = fetch_pinned_dids() + let profile = fetch_profile() + let recent_plays = fetch_plays() + let repos = fetch_repos(pinned_dids) - let profile = fetch_profile(client) - let recent_plays = fetch_plays(client) - let repos = fetch_repos(client) - let posts = read_posts() - - SiteData(profile:, recent_plays:, repos:, posts:) + SiteData(profile:, recent_plays:, repos:, posts: read_posts()) } -fn fetch_profile(client) -> ProfileViewDetailed { - let params = ActorGetProfileParams(actor: "karitham.dev") - case actor_get_profile(client, api.public_api, params, None) { - Ok(profile) -> profile - Error(e) -> { - io.println("Failed to fetch profile: " <> string.inspect(e)) - panic as "profile fetch failed" - } - } -} +// --- profile --- -fn fetch_plays(client) -> List(AlphaFeedPlay) { - let params = - RepoListRecordsParams( - repo: api.did, - collection: "fm.teal.alpha.feed.play", - limit: Some(api.plays_limit), - cursor: None, - reverse: None, - ) - case repo_list_records(client, api.pds_endpoint, params, None) { - Ok(output) -> - fetch.decode_record_values(output.records, alpha_feed_play_decoder()) - Error(e) -> { - io.println("Failed to fetch plays: " <> string.inspect(e)) - [] - } +fn fetch_profile() { + case transport.fetch_body(fetch.profile_url()) { + Ok(body) -> + case fetch.decode_profile(body) { + Ok(profile) -> profile + Error(e) -> log_fail_and_panic("profile", string.inspect(e)) + } + Error(e) -> log_fail_and_panic("profile", e) } } -fn fetch_repos(client) -> List(Repo) { - let pinned_dids = fetch_pinned_dids(client) - - let repos = fetch_all_repos(client) +// --- plays --- - api.filter_pinned_repos(repos, pinned_dids) +fn fetch_plays() -> List(AlphaFeedPlay) { + case transport.fetch_body(fetch.plays_url()) { + Ok(body) -> + case fetch.decode_plays(body) { + Ok(plays) -> plays + Error(e) -> log_fail("plays", string.inspect(e), []) + } + Error(e) -> log_fail("plays", e, []) + } } -fn fetch_pinned_dids(client) -> List(String) { - let params = - RepoListRecordsParams( - repo: api.did, - collection: "sh.tangled.actor.profile", - limit: None, - cursor: None, - reverse: None, - ) - case repo_list_records(client, api.pds_endpoint, params, None) { - Ok(output) -> - output.records - |> list.map(fn(record) { - decode.run(record.value, actor_profile_decoder()) - }) - |> result.values - |> list.map(fn(profile) { - case profile.pinned_repositories { - Some(dids) -> dids - None -> [] - } - }) - |> list.flatten - |> list.filter(fn(d) { d != "" }) - Error(e) -> { - io.println("Failed to fetch pinned DIDs: " <> string.inspect(e)) - [] - } +// --- repos --- + +fn fetch_repos(pinned_dids: List(String)) -> List(DecodedRecord(Repo)) { + case transport.fetch_body(fetch.repos_url()) { + Ok(body) -> + case fetch.decode_repos(body) { + Ok(records) -> + records + |> fetch.filter_repos_by_did(pinned_dids) + |> list.map(fetch.resolve_repo_name) + Error(e) -> log_fail("repos", string.inspect(e), []) + } + Error(e) -> log_fail("repos", e, []) } } -fn fetch_all_repos(client) -> List(Repo) { - let params = - RepoListRecordsParams( - repo: api.did, - collection: "sh.tangled.repo", - limit: None, - cursor: None, - reverse: None, - ) - case repo_list_records(client, api.pds_endpoint, params, None) { - Ok(output) -> fetch.decode_record_values(output.records, repo_decoder()) - Error(e) -> { - io.println("Failed to fetch repos: " <> string.inspect(e)) - [] - } +// --- pinned DIDs --- + +fn fetch_pinned_dids() -> List(String) { + case transport.fetch_body(fetch.pinned_dids_url()) { + Ok(body) -> + case fetch.decode_actor_profiles(body) { + Ok(profiles) -> fetch.pinned_dids_from_profiles(profiles) + Error(_) -> [] + } + Error(_) -> [] } } +// --- posts (filesystem, not HTTP) --- + /// Read every post under `priv/posts//index.md`. Includes /// drafts (caller filters them). Fails the build if any post has /// an invalid slug or frontmatter — see `data/frontmatter.gleam` @@ -188,3 +153,15 @@ fn format_parse_error(slug: String, e: frontmatter.ParseError) -> String { fn compare_posts_desc(a: Post, b: Post) -> order.Order { string.compare(b.date, a.date) } + +// --- logging --- + +fn log_fail_and_panic(what: String, reason: String) -> a { + io.println("Failed to fetch " <> what <> ": " <> reason) + panic as "required fetch failed" +} + +fn log_fail(what: String, reason: String, fallback: a) -> a { + io.println("Failed to fetch " <> what <> ": " <> reason) + fallback +} diff --git a/src/data/model.gleam b/src/data/model.gleam index 2c68acd..99397af 100644 --- a/src/data/model.gleam +++ b/src/data/model.gleam @@ -1,3 +1,4 @@ +import fetch.{type DecodedRecord} import gen/actor/defs.{type ProfileViewDetailed} import gen/alpha/feed/play.{type AlphaFeedPlay} import gen/repo.{type Repo} @@ -6,7 +7,7 @@ pub type SiteData { SiteData( profile: ProfileViewDetailed, recent_plays: List(AlphaFeedPlay), - repos: List(Repo), + repos: List(DecodedRecord(Repo)), posts: List(Post), ) } diff --git a/src/data/transport.gleam b/src/data/transport.gleam index 332d513..b973118 100644 --- a/src/data/transport.gleam +++ b/src/data/transport.gleam @@ -1,46 +1,26 @@ -//// XRPC transport for the SSG (Erlang target). +//// HTTP body fetcher for the SSG (Erlang target). //// -//// Creates an `atproto/xrpc.Client` backed by `gleam_httpc` for use -//// with the generated typed client in `gen/client.gleam`. +//// `fetch_body` does a GET and returns the response body as a string. +//// Status and error handling stay here so the shared decoders in +//// `shared/src/fetch.gleam` can stay pure. -import atproto/xrpc -import gleam/bit_array -import gleam/http/request.{type Request, Request} -import gleam/http/response.{type Response, Response} +import gleam/http/request import gleam/httpc - +import gleam/int import gleam/result import gleam/string -pub fn http_client() -> xrpc.Client { - xrpc.Client(send: send) -} - -fn body_to_string(bits: BitArray) -> String { - case bit_array.to_string(bits) { - Ok(s) -> s - Error(_) -> "" - } -} - -fn send(req: Request(BitArray)) -> Result(Response(BitArray), String) { - let text_req = - Request( - method: req.method, - headers: req.headers, - body: body_to_string(req.body), - scheme: req.scheme, - host: req.host, - port: req.port, - path: req.path, - query: req.query, - ) - use resp <- result.try( - httpc.send(text_req) |> result.map_error(string.inspect), +/// GET `url` and return the response body. Returns an error string on +/// network failure, non-2xx status, or invalid URL — the SSG logs +/// these and continues with an empty value where appropriate. +pub fn fetch_body(url: String) -> Result(String, String) { + use req <- result.try( + request.to(url) + |> result.replace_error("invalid url: " <> url), ) - Ok(Response( - status: resp.status, - headers: resp.headers, - body: bit_array.from_string(resp.body), - )) + use resp <- result.try(httpc.send(req) |> result.map_error(string.inspect)) + case resp.status >= 200 && resp.status < 300 { + True -> Ok(resp.body) + False -> Error("HTTP " <> int.to_string(resp.status) <> ": " <> resp.body) + } }