diff --git a/flake.nix b/flake.nix --- a/flake.nix +++ b/flake.nix @@ -56,6 +56,7 @@ beamMinimal28Packages.rebar3 treefmt just + nodejs rustc cargo diff --git a/justfile b/justfile --- a/justfile +++ b/justfile @@ -20,9 +20,11 @@ ssg: gleam run -# Run all tests (root + shared). +# Run all tests (root + shared + client). test: - gleam test && cd shared && gleam test + gleam test + cd shared && gleam test + cd client && gleam test # Scaffold a new post. Usage: just new my-post-slug new *ARGS: diff --git a/src/blog_tools.gleam b/src/blog_tools.gleam deleted file mode 100644 --- a/src/blog_tools.gleam +++ /dev/null @@ -1,197 +0,0 @@ -//// CLI subcommands for working with the blog. -//// -//// Usage: -//// gleam run Build the static site (default) -//// gleam run build same -//// gleam run client Build the client JS bundle -//// gleam run new Scaffold a new post at priv/posts// -//// gleam run help Print usage -//// -//// The SSG and the client build are separate `gleam run` commands -//// because shelling out from Gleam on the BEAM is fragile (the -//// `os:cmd/1` FFI in this OTP version rejects our binary form). -//// For a one-command build, use `make build` which chains them. - -import build -import date -import gleam/int -import gleam/io -import gleam/list -import gleam/string -import simplifile - -/// Build the static site. Assumes the client JS bundle is already -/// present at `client/build/dev/javascript/karitham_blog_client/`. -/// Use `gleam run client` (or `make build`) to produce that first. -pub fn build_site() { - let bundle = "client/build/dev/javascript/karitham_blog_client/client.mjs" - case simplifile.is_file(bundle) { - Ok(True) -> Nil - _ -> { - io.println( - "Client bundle not found at " - <> bundle - <> ". Run `gleam run client` (or `make build`) first.", - ) - panic as "client bundle missing" - } - } - build.build() -} - -/// Print a hint that client building isn't wired into this binary. -/// The real build happens via `cd client && gleam build --target -/// javascript` or `make client`. -pub fn build_client() { - io.println( - "Run `cd client && gleam build --target javascript` to build the client.", - ) - io.println("(Or use `make client` from the project root.)") - Nil -} - -/// Scaffold a new post at `priv/posts//index.md` from a -/// template. The input is slugified (lowercased, non-alphanumerics -/// become hyphens, runs of hyphens collapsed) so users can pass -/// whatever feels natural — `just new My new post!` becomes -/// `priv/posts/my-new-post/`. Defaults to `draft: true` so -/// half-written posts don't go live. -pub fn new_post(input: String) { - let slug = slugify(input) - case slug { - "" -> { - io.println( - "Error: \"" - <> input - <> "\" doesn't contain any valid slug characters (letters, digits, hyphens).", - ) - panic as "empty slug" - } - _ -> { - let dir = "priv/posts/" <> slug - let path = dir <> "/index.md" - case simplifile.is_directory(dir) { - Ok(True) -> { - io.println("Error: " <> dir <> " already exists.") - panic as "post already exists" - } - _ -> { - let _ = simplifile.create_directory_all(dir) - let content = template(slug) - case simplifile.write(to: path, contents: content) { - Ok(_) -> { - io.println("Created " <> path) - io.println("Edit the post, then remove `draft: true` to publish.") - } - Error(e) -> { - io.println( - "Failed to write " <> path <> ": " <> string.inspect(e), - ) - panic as "post write failed" - } - } - } - } - } - } -} - -/// Normalize free-form text into a valid slug: lowercase, every -/// non-`[a-z0-9-]` character becomes a hyphen, runs of hyphens -/// collapse to one, leading/trailing hyphens stripped. Returns -/// `""` for input that contains no usable characters. -pub fn slugify(input: String) -> String { - input - |> string.lowercase - |> string.trim - |> string.to_graphemes - |> list.map(slugify_char) - |> string.join("") - |> collapse_dashes - |> trim_dashes -} - -fn slugify_char(c: String) -> String { - case string.contains("abcdefghijklmnopqrstuvwxyz0123456789-", c) { - True -> c - False -> "-" - } -} - -fn collapse_dashes(s: String) -> String { - case string.contains(s, "--") { - True -> collapse_dashes(string.replace(s, each: "--", with: "-")) - False -> s - } -} - -fn trim_dashes(s: String) -> String { - case string.starts_with(s, "-") { - True -> trim_dashes(string.drop_start(s, 1)) - False -> - case string.ends_with(s, "-") { - True -> trim_dashes(string.drop_end(s, 1)) - False -> s - } - } -} - -pub fn print_usage() { - io.println( - "Usage: - gleam run Build the static site (alias for `build`) - gleam run build Build the static site - gleam run client Hint: build the client bundle with make - gleam run new Scaffold a new post at priv/posts// - gleam run help Show this message - -The site is written to ./dist/. The client and SSG are built -separately; for a one-shot build use `make build`.", - ) -} - -// --- helpers --- - -/// Render the post template for a given slug. Uses today's date -/// so the scaffolded post sorts to the top of the timeline. -fn template(slug: String) -> String { - let #(y, m, d) = today() - let date = int.to_string(y) <> "-" <> date.pad2(m) <> "-" <> date.pad2(d) - "---\n" - <> "title: " - <> title_from_slug(slug) - <> "\n" - <> "description: \n" - <> "date: " - <> date - <> "\n" - <> "tags: []\n" - <> "draft: true\n" - <> "image: \n" - <> "---\n" - <> "\n" - <> "Write your post here.\n" -} - -fn title_from_slug(slug: String) -> String { - // "hello-world" -> "Hello World". Best-effort — the user will - // overwrite the title anyway once they start writing. - slug - |> string.split(on: "-") - |> list.map(capitalize) - |> string.join(" ") -} - -fn capitalize(word: String) -> String { - case string.to_graphemes(word) { - [] -> "" - [first, ..rest] -> string.uppercase(first) <> string.join(rest, "") - } -} - -@external(erlang, "erlang", "date") -fn erlang_date() -> #(Int, Int, Int) - -fn today() -> #(Int, Int, Int) { - erlang_date() -} diff --git a/src/build.gleam b/src/build.gleam --- a/src/build.gleam +++ b/src/build.gleam @@ -1,174 +1,112 @@ -import api -import data/fetch +//// The build pipeline's commit shell: gather → render → write. +//// +//// Data fetching lives in `data/sources` + `data/transport`, image +//// mirroring in `data/images`, and pure page assembly in +//// `render/page`. This module only wires them together and performs +//// the filesystem writes — no business logic of its own. + +import config import data/images -import data/model.{type Post, type SiteData, SiteData} +import data/model.{type Post, SiteData} +import data/sources +import data/transport import dynamic -import encode -import filepath import gen/actor/defs.{type ProfileViewDetailed} import gleam/io -import gleam/json import gleam/list -import gleam/option.{type Option, None, Some, map as option_map} -import gleam/result import gleam/string -import hydration.{HydrationModel} -import lustre/attribute.{class, id, type_} -import lustre/element.{type Element, fragment, text, to_document_string} -import lustre/element/html.{div, h2, script} +import lustre/element.{type Element, to_document_string} +import render/page import simplifile -import view/components/post_view import view/layout -const dist_dir = "./dist" - -fn render_document(element: Element(Nil)) -> String { - element - |> to_document_string - |> dynamic.strip_fragment_comments -} - pub fn build() { + let cfg = config.read_env() + io.println("Fetching data...") - let site_data = fetch.fetch_all() + let site_data = sources.fetch_all(transport.fetch_body) // Mirror the profile's avatar/banner blobs into the site so the // browser never hits the PDS for them; the returned profile points // at the local copies and `rewrites` lets the client do the same. - let profile_images = images.mirror_profile_images(site_data.profile) + let profile_images = + images.mirror_profile_images( + site_data.profile, + transport.fetch_image, + write_bits, + ) let site_data = SiteData(..site_data, profile: profile_images.profile) - // Drafts are excluded from the SSG output but the user should - // know they exist (so they don't lose work or wonder where - // their post went). + // Drafts ARE built into dist/ so a half-written post is reachable + // by URL for preview. They're deliberately excluded from the index + // and the RSS feed, so nothing links to them from the home page — + // a direct URL is the only way in. Don't "fix" the copy below to + // skip drafts; that would break the preview flow. let #(drafts, published) = list.partition(site_data.posts, fn(p) { p.draft }) list.each(drafts, log_draft) io.println("Generating site...") - let _ = create_dir(dist_dir) + let _ = create_dir(cfg.dist_dir) let published_data = SiteData(..site_data, posts: published) - write_index(published_data, profile_images.rewrites) - write_posts(published, published_data.profile) - write_posts(drafts, published_data.profile) - write_style() - write_highlight() - write_rss(published) - copy_post_assets(published) - copy_post_assets(drafts) - copy_favicons() - copy_image_cache() - copy_client_js() + let index_html = + page.index_page(published_data, cfg.site_url, profile_images.rewrites) + |> render_document + write_text(cfg.dist_dir <> "/index.html", index_html) - io.println("Done! Site generated in " <> dist_dir) + write_posts(published, published_data.profile, cfg) + write_posts(drafts, published_data.profile, cfg) + + write_style(cfg) + write_highlight(cfg) + + let rss = layout.rss_feed(published, cfg.site_url) + write_text(cfg.dist_dir <> "/rss.xml", rss) + + copy_post_assets(published, cfg) + copy_post_assets(drafts, cfg) + copy_favicons(cfg) + copy_image_cache(cfg) + copy_client_js(cfg) + + io.println("Done! Site generated in " <> cfg.dist_dir) } fn log_draft(post: Post) -> Nil { io.println(" [draft] " <> post.slug <> " — " <> post.title) } -fn write_index(data: SiteData, rewrites: List(#(String, String))) { - let og_image: Option(String) = case data.profile.banner { - Some(img) -> Some(absolutize_img(img)) - None -> option_map(data.profile.avatar, absolutize_img) - } - - let description = case data.profile.description { - Some(desc) -> desc - None -> "Karitham's personal blog and project showcase" - } - - let model_json = - encode.encode_hydration_model(HydrationModel( - profile: data.profile, - plays: data.recent_plays, - repos: data.repos, - )) - - let dynamic = - div([id("dynamic-sections")], [ - dynamic.dynamic_sections( - data.profile, - data.recent_plays, - data.plays_stats, - list.map(data.repos, fn(record) { record.value }), - ), - ]) - - // The client re-fetches the profile on page load and re-renders it - // with the PDS's remote avatar/banner URLs; this map lets it point - // those at the local mirrors instead. - let rewrites_script = - script( - [type_("application/json"), id("image-rewrites")], - encode_rewrites(rewrites), - ) - - let content = - fragment([ - rewrites_script, - dynamic, - div([class("section")], [ - div([class("section-header")], [ - h2([], [text("Articles")]), - ]), - post_view.render_list(data.posts), - ]), - ]) - - let meta = - layout.Meta( - description: description, - image: og_image, - url: api.site_url() <> "/", - logo: option_map(data.profile.avatar, absolutize_img), - page_type: layout.Website, - ) - - let html = layout.page("~/kar", model_json, content, meta) |> render_document - let path = dist_dir <> "/index.html" - write_text(path, html) - io.println(" wrote " <> path) +/// simplifile's write_bits has labeled arguments; this unlabeled +/// wrapper matches `images.WriteBits` for injection. +fn write_bits( + path: String, + bits: BitArray, +) -> Result(Nil, simplifile.FileError) { + simplifile.write_bits(to: path, bits: bits) } -fn write_posts(posts: List(Post), profile: ProfileViewDetailed) { - list.each(posts, fn(post) { write_single_post(post, profile) }) +fn write_posts( + posts: List(Post), + profile: ProfileViewDetailed, + cfg: config.SiteConfig, +) { + list.each(posts, fn(post) { + let dir = cfg.dist_dir <> "/posts/" <> post.slug + let _ = create_dir(dir) + let html = page.post_page(post, profile, cfg.site_url) |> render_document + let path = dir <> "/index.html" + write_text(path, html) + io.println(" wrote " <> path) + }) } -fn write_single_post(post: Post, profile: ProfileViewDetailed) { - let dir = dist_dir <> "/posts/" <> post.slug - let _ = create_dir(dir) - - let title = post.title <> " - Kar" - let og_image: Option(String) = case post.image { - "" -> option_map(profile.avatar, absolutize_img) - img -> Some(resolve_og_image_url(post.slug, img)) - } - - let meta = - layout.Meta( - description: post.description, - image: og_image, - url: api.site_url() <> "/posts/" <> post.slug <> "/", - logo: profile.avatar, - page_type: layout.Article(published_time: post.date, tags: post.tags), - ) - - let html = - layout.page(title, "", post_view.render_single(post), meta) - |> render_document - let path = dir <> "/index.html" - write_text(path, html) - io.println(" wrote " <> path) -} - -fn write_style() { +fn write_style(cfg: config.SiteConfig) { // CSS lives as a real file at priv/static/style.css so it gets // editor highlighting and treefmt. Build just copies it. case simplifile.read("priv/static/style.css") { Ok(contents) -> { - let path = dist_dir <> "/style.css" + let path = cfg.dist_dir <> "/style.css" write_text(path, contents) io.println(" wrote " <> path) } @@ -176,12 +114,12 @@ } } -fn write_highlight() { +fn write_highlight(cfg: config.SiteConfig) { // Vendored highlight.js + a small init module that registers // gleam + nushell grammars. Served as static files. let files = [ - #("priv/static/highlight.min.js", dist_dir <> "/highlight.min.js"), - #("priv/static/highlight.mjs", dist_dir <> "/highlight.mjs"), + #("priv/static/highlight.min.js", cfg.dist_dir <> "/highlight.min.js"), + #("priv/static/highlight.mjs", cfg.dist_dir <> "/highlight.mjs"), ] list.each(files, fn(pair) { let #(src, dst) = pair @@ -195,24 +133,15 @@ }) } -fn write_rss(posts: List(Post)) { - let items = - list.map(posts, fn(p) { #(p.title, p.description, p.slug, p.date) }) - let rss = layout.rss_feed(items) - let path = dist_dir <> "/rss.xml" - write_text(path, rss) - io.println(" wrote " <> path) -} - -/// Copy every non-`index.md` file under each *published* post's -/// directory to `dist/posts//`. Drafts' assets are not -/// deployed — the build pipeline filters drafts out before -/// calling this, so we just walk the list we were given. -fn copy_post_assets(posts: List(Post)) { +/// Copy every non-`index.md` file under each post's directory to +/// `dist/posts//` (including drafts — same preview rationale as +/// the draft pages themselves). Files inside subdirectories are +/// skipped; only flat assets are deployed. +fn copy_post_assets(posts: List(Post), cfg: config.SiteConfig) { list.each(posts, fn(post) { copy_post_files( "priv/posts/" <> post.slug, - dist_dir <> "/posts/" <> post.slug, + cfg.dist_dir <> "/posts/" <> post.slug, ) }) } @@ -233,7 +162,7 @@ } } -fn copy_favicons() { +fn copy_favicons(cfg: config.SiteConfig) { let favicons = [ "favicon-32x32.png", "favicon-16x16.png", @@ -244,19 +173,47 @@ ] list.each(favicons, fn(name) { let src = "priv/static/icons/" <> name - let dst = dist_dir <> "/" <> name + let dst = cfg.dist_dir <> "/" <> name copy_file_bits(src, dst) }) } -fn copy_client_js() { +/// The top-level packages the compiled client bundle actually imports +/// (traced from `karitham_blog_client/*.mjs` and their transitive +/// imports). The full dev tree also contains test runners (gleeunit), +/// Erlang artefacts, and unused packages (atproto_client, kryptos, +/// gose, bigi, exception, houdini, gleam_otp, gleam_erlang, +/// gleam_http, gleam_crypto, fingerprint) — copying those would bloat +/// `dist/client` by ~12 MB for nothing. +const client_keep = [ + "prelude.mjs", + "karitham_blog_client", + "shared", + "gleam_stdlib", + "gleam_json", + "gleam_time", + "lustre", +] + +fn copy_client_js(cfg: config.SiteConfig) { let src = "client/build/dev/javascript" - let dst = dist_dir <> "/client" + let dst = cfg.dist_dir <> "/client" case simplifile.is_directory(src) { Ok(True) -> { + // A previous build may have copied the full dev tree here; + // delete first so stale packages don't linger under the + // whitelist. + let _ = simplifile.delete(dst) let _ = create_dir(dst) - copy_dir(src, dst) + list.each(client_keep, fn(entry) { + let src_path = src <> "/" <> entry + let dst_path = dst <> "/" <> entry + case simplifile.is_directory(src_path) { + Ok(True) -> copy_dir(src_path, dst_path) + _ -> copy_file_bits(src_path, dst_path) + } + }) io.println(" copied client JS") } _ -> @@ -269,63 +226,35 @@ /// Copy the mirrored cover/artist images from the refresh cache into /// the site so the browser serves them locally instead of hitting /// Cover Art Archive / Wikimedia at page load. No-op without a cache. -fn copy_image_cache() { +fn copy_image_cache(cfg: config.SiteConfig) { case simplifile.is_directory("priv/cache/img") { - Ok(True) -> copy_dir("priv/cache/img", dist_dir <> "/img") + Ok(True) -> copy_dir("priv/cache/img", cfg.dist_dir <> "/img") _ -> Nil } } -/// The remote→local rewrite map as a JSON object, embedded in -/// `#image-rewrites` for the client (client/browser_ffi.mjs). -fn encode_rewrites(rewrites: List(#(String, String))) -> String { - rewrites - |> list.map(fn(pair) { - let #(remote, local) = pair - #(remote, json.string(local)) - }) - |> json.object - |> json.to_string -} - -/// OG/Twitter image tags must be absolute URLs for crawlers; the -/// mirrored images are root-relative paths. -fn absolutize_img(img: String) -> String { - case string.starts_with(img, "/") { - True -> api.site_url() <> img - False -> img - } -} - -fn resolve_og_image_url(slug: String, img: String) -> String { - case - string.starts_with(img, "http://") || string.starts_with(img, "https://") - { - True -> img - False -> { - let expanded = filepath.expand(img) |> result.unwrap(img) - case filepath.is_absolute(expanded) { - True -> api.site_url() <> expanded - False -> - api.site_url() - <> filepath.join(filepath.join("/posts", slug), expanded) - } - } - } -} - fn copy_dir(src: String, dst: String) -> Nil { + let _ = create_dir(dst) case simplifile.read_directory(src) { Ok(entries) -> list.each(entries, fn(entry) { - let src_path = src <> "/" <> entry - let dst_path = dst <> "/" <> entry - case simplifile.is_directory(src_path) { - Ok(True) -> { - let _ = create_dir(dst_path) - copy_dir(src_path, dst_path) - } - _ -> copy_file_bits(src_path, dst_path) + // `.erl` files and `_gleam_artefacts` are Erlang-target build + // leftovers that leak into the JS dev tree; the browser never + // imports them, so skip them. + case entry { + "_gleam_artefacts" -> Nil + e -> + case string.ends_with(e, ".erl") { + True -> Nil + False -> { + let src_path = src <> "/" <> e + let dst_path = dst <> "/" <> e + case simplifile.is_directory(src_path) { + Ok(True) -> copy_dir(src_path, dst_path) + _ -> copy_file_bits(src_path, dst_path) + } + } + } } }) Error(_) -> Nil @@ -333,6 +262,12 @@ } // --- I/O helpers that log errors instead of silently swallowing them --- + +fn render_document(element: Element(Nil)) -> String { + element + |> to_document_string + |> dynamic.strip_fragment_comments +} fn write_text(path: String, contents: String) -> Nil { case simplifile.write(to: path, contents: contents) { diff --git a/src/cli.gleam b/src/cli.gleam new file mode 100644 --- /dev/null +++ b/src/cli.gleam @@ -0,0 +1,108 @@ +//// CLI subcommands for working with the blog — the impure shell. +//// +//// Usage: +//// gleam run Build the static site (default) +//// gleam run build same +//// gleam run new Scaffold a new post at priv/posts// +//// gleam run help Print usage +//// +//// The pure helpers (slugify, template) live in `cli/slug` and +//// `cli/template`; this module does the filesystem work and panics. +//// The SSG and the client build are separate `gleam run` commands +//// because shelling out from Gleam on the BEAM is fragile (the +//// `os:cmd/1` FFI in this OTP version rejects our binary form). +//// For a one-command build, use `make build` which chains them. + +import build +import cli/slug +import cli/template +import gleam/io +import gleam/string +import simplifile + +/// Build the static site. Assumes the client JS bundle is already +/// present at `client/build/dev/javascript/karitham_blog_client/`. +/// Use `make build` to produce that first. +pub fn build_site() { + let bundle = "client/build/dev/javascript/karitham_blog_client/client.mjs" + case simplifile.is_file(bundle) { + Ok(True) -> Nil + _ -> { + io.println( + "Client bundle not found at " + <> bundle + <> ". Run `make build` (or `cd client && gleam build --target javascript`) first.", + ) + panic as "client bundle missing" + } + } + build.build() +} + +/// Scaffold a new post at `priv/posts//index.md` from a +/// template. The input is slugified (lowercased, non-alphanumerics +/// become hyphens, runs of hyphens collapsed) so users can pass +/// whatever feels natural — `just new My new post!` becomes +/// `priv/posts/my-new-post/`. Defaults to `draft: true` so +/// half-written posts don't go live. +pub fn new_post(input: String) { + let slug = slug.slugify(input) + case slug { + "" -> { + io.println( + "Error: \"" + <> input + <> "\" doesn't contain any valid slug characters (letters, digits, hyphens).", + ) + panic as "empty slug" + } + _ -> { + let dir = "priv/posts/" <> slug + let path = dir <> "/index.md" + case simplifile.is_directory(dir) { + Ok(True) -> { + io.println("Error: " <> dir <> " already exists.") + panic as "post already exists" + } + _ -> { + let _ = simplifile.create_directory_all(dir) + let content = template.template(slug, today()) + case simplifile.write(to: path, contents: content) { + Ok(_) -> { + io.println("Created " <> path) + io.println("Edit the post, then remove `draft: true` to publish.") + } + Error(e) -> { + io.println( + "Failed to write " <> path <> ": " <> string.inspect(e), + ) + panic as "post write failed" + } + } + } + } + } + } +} + +pub fn print_usage() { + io.println( + "Usage: + gleam run Build the static site (alias for `build`) + gleam run build Build the static site + gleam run new Scaffold a new post at priv/posts// + gleam run help Show this message + +The site is written to ./dist/. The client and SSG are built +separately; for a one-shot build use `make build`.", + ) +} + +// --- helpers --- + +@external(erlang, "erlang", "date") +fn erlang_date() -> #(Int, Int, Int) + +fn today() -> #(Int, Int, Int) { + erlang_date() +} diff --git a/src/config.gleam b/src/config.gleam new file mode 100644 --- /dev/null +++ b/src/config.gleam @@ -0,0 +1,24 @@ +//// Build-time site configuration. The only module that reads the +//// environment — everything downstream receives the values as plain +//// parameters, so render code stays pure and testable. + +import gleam/string + +pub type SiteConfig { + SiteConfig(site_url: String, dist_dir: String) +} + +/// Read `BLOG_URL` (falls back to the production URL) and the output +/// directory. Called once at the top of `build.build()`. +pub fn read_env() -> SiteConfig { + SiteConfig( + site_url: case os_getenv("BLOG_URL") { + Ok(url) -> string.trim(url) + Error(_) -> "https://karitham.dev" + }, + dist_dir: "./dist", + ) +} + +@external(erlang, "config_ffi", "getenv") +fn os_getenv(name: String) -> Result(String, Nil) diff --git a/src/config_ffi.erl b/src/config_ffi.erl new file mode 100644 --- /dev/null +++ b/src/config_ffi.erl @@ -0,0 +1,8 @@ +-module(config_ffi). +-export([getenv/1]). + +getenv(Name) -> + case os:getenv(binary_to_list(Name)) of + false -> {error, nil}; + Value -> {ok, list_to_binary(Value)} + end. diff --git a/src/karitham_blog.gleam b/src/karitham_blog.gleam --- a/src/karitham_blog.gleam +++ b/src/karitham_blog.gleam @@ -1,22 +1,20 @@ import argv -import blog_tools +import cli import gleam/io import gleam/string /// Entry point. Dispatches to the right subcommand based on argv. /// gleam run → build (default) /// gleam run build → build the SSG -/// gleam run client → hint for building the client /// gleam run new → scaffold a new post /// gleam run help → usage pub fn main() { case argv.load().arguments { - [] | ["build"] -> blog_tools.build_site() - ["client"] -> blog_tools.build_client() - ["new", ..rest] -> blog_tools.new_post(string.join(rest, " ")) - ["help"] | ["-h"] | ["--help"] -> blog_tools.print_usage() + [] | ["build"] -> cli.build_site() + ["new", ..rest] -> cli.new_post(string.join(rest, " ")) + ["help"] | ["-h"] | ["--help"] -> cli.print_usage() other -> { - blog_tools.print_usage() + cli.print_usage() io.println("") io.println("Unknown command: " <> string.join(other, " ")) panic as "unknown subcommand" diff --git a/test/blog_tools_test.gleam b/test/blog_tools_test.gleam deleted file mode 100644 --- a/test/blog_tools_test.gleam +++ /dev/null @@ -1,53 +0,0 @@ -import blog_tools -import gleam/list -import gleeunit/should - -pub fn slugify_simple_test() { - blog_tools.slugify("hello") |> should.equal("hello") -} - -pub fn slugify_lowercases_test() { - blog_tools.slugify("Hello") |> should.equal("hello") -} - -pub fn slugify_joins_spaces_test() { - blog_tools.slugify("New blog ayo whos this") - |> should.equal("new-blog-ayo-whos-this") -} - -pub fn slugify_replaces_punctuation_test() { - blog_tools.slugify("Hello, World!") - |> should.equal("hello-world") -} - -pub fn slugify_underscores_become_dashes_test() { - blog_tools.slugify("hello_world") |> should.equal("hello-world") -} - -pub fn slugify_collapses_runs_of_dashes_test() { - blog_tools.slugify("foo---bar") |> should.equal("foo-bar") - blog_tools.slugify("a !! b") |> should.equal("a-b") -} - -pub fn slugify_trims_leading_and_trailing_dashes_test() { - blog_tools.slugify("---hello---") |> should.equal("hello") - blog_tools.slugify("!hello!") |> should.equal("hello") -} - -pub fn slugify_preserves_existing_dashes_test() { - blog_tools.slugify("my-post") |> should.equal("my-post") -} - -pub fn slugify_empty_input_test() { - blog_tools.slugify("") |> should.equal("") -} - -pub fn slugify_only_punctuation_test() { - blog_tools.slugify("!!!") |> should.equal("") -} - -pub fn slugify_already_valid_test() { - // Idempotency: a valid slug should be its own slugify output. - let cases = ["hello", "hello-world", "post-1", "a", "2024-07-18"] - list.each(cases, fn(s) { blog_tools.slugify(s) |> should.equal(s) }) -} diff --git a/test/stats_contract_test.gleam b/test/stats_contract_test.gleam new file mode 100644 --- /dev/null +++ b/test/stats_contract_test.gleam @@ -0,0 +1,52 @@ +//// Cross-language contract test for plays-stats.json. +//// +//// This file (tests/fixtures/plays-stats.min.json) is generated by the +//// Rust emitter (tools/parse-plays stats::build_ranges) and pinned here. +//// This Gleam test decodes it with the production decoder; the Rust +//// test `cross_language_contract_fixture_matches` regenerates it from +//// the same inputs and compares value-for-value. If either side drifts, +//// one of the two tests fails. + +import gleam/json +import gleam/list +import gleam/result +import gleeunit/should +import simplifile +import stats.{type RangeStats} + +pub fn fixture_decodes_to_expected_stats_test() { + let assert Ok(body) = simplifile.read("tests/fixtures/plays-stats.min.json") + let assert Ok(data) = json.parse(body, stats.stats_data_decoder()) + + data.ranges |> list.length |> should.equal(4) + + // The 1m range only contains Artist A (Artist B's play predates it). + let assert #(stats.OneMonth, one_month) = range_for(data, stats.OneMonth) + let assert [artist_a] = one_month.artists + artist_a.name |> should.equal("Artist A") + artist_a.plays |> should.equal(1) + artist_a.image |> should.equal("") + let assert [album_a] = one_month.albums + album_a.artist |> should.equal("Artist A") + album_a.name |> should.equal("Album A") + + // All-time has both, Artist B first (more ms_played). + let assert #(stats.AllTime, all_time) = range_for(data, stats.AllTime) + let assert [artist_b, _artist_a] = all_time.artists + artist_b.name |> should.equal("Artist B") + let assert [track_beta, _track_alpha] = all_time.tracks + track_beta.name |> should.equal("Beta") + track_beta.artist |> should.equal("Artist B") +} + +fn range_for( + data: stats.StatsData, + wanted: stats.Range, +) -> #(stats.Range, RangeStats) { + let found = + list.find(data.ranges, fn(pair) { + let #(range, _) = pair + range == wanted + }) + result.unwrap(found, #(wanted, stats.empty_range_stats())) +} diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -5,7 +5,7 @@ # and redeploy so the Music section stays current between pushes. # Runs on the default branch with the latest committed code. schedule: - - cron: '0 6 * * 1' # Mondays 06:00 UTC + - cron: "0 6 * * 1" # Mondays 06:00 UTC workflow_dispatch: concurrency: diff --git a/client/src/app.gleam b/client/src/app.gleam new file mode 100644 --- /dev/null +++ b/client/src/app.gleam @@ -0,0 +1,125 @@ +//// Client-side orchestration — the impure interpreter. +//// +//// Fetches fresh data via the browser FFI, decodes via shared, +//// plans via `pipeline.gleam`, and applies the resulting commands +//// with `browser.*` calls. Thin by design: every decision lives in +//// the pure pipeline. Kept imperative (not Lustre MVU) on purpose — +//// async `effect.from` dispatch is unreliable on the JS target. + +import atproto +import browser +import commit.{ + type Command, LocalizeDates, RemoveAttr, ReplaceHtml, RewriteRemoteImages, + SetAttr, +} +import gleam/list +import gleam/string +import pipeline + +/// Short enough that a new track shows up promptly, long enough that +/// we're not hammering the PDS. +const plays_poll_ms = 30_000 + +/// Wires up initial fetches, periodic poll, and visibility listener. +pub fn start() -> Nil { + // Guard: only run refresh logic on pages with dynamic sections. + case browser.has_element("profile-section") { + False -> Nil + True -> { + refresh_all() + browser.localize_dates() + browser.set_interval(plays_poll_ms, poll_tick) + browser.on_visibility_change(on_visibility_change) + } + } +} + +fn refresh_all() -> Nil { + fetch_profile() + fetch_pinned_dids_and_repos() + refresh_plays() +} + +fn fetch_profile() -> Nil { + browser.fetch_text(atproto.profile_url(), fn(text) { + case atproto.decode_profile(text) { + Ok(profile) -> commit(pipeline.plan_profile(profile)) + Error(reason) -> + browser.log_error("decode_profile failed: " <> string.inspect(reason)) + } + }) +} + +fn fetch_pinned_dids_and_repos() -> Nil { + browser.fetch_text(atproto.pinned_dids_url(), fn(pinned_text) { + case atproto.decode_actor_profiles(pinned_text) { + Ok(profiles) -> { + let pinned_dids = atproto.pinned_dids_from_profiles(profiles) + browser.fetch_text(atproto.repos_url(), fn(repos_text) { + case atproto.decode_repos(repos_text) { + Ok(records) -> { + let repos = + records + |> atproto.filter_repos_by_did(pinned_dids) + |> list.map(atproto.resolve_repo_name) + |> list.map(fn(record) { record.value }) + commit(pipeline.plan_repos(repos)) + } + Error(reason) -> + browser.log_error( + "decode_repos failed: " <> string.inspect(reason), + ) + } + }) + } + Error(reason) -> + browser.log_error( + "decode_actor_profiles failed: " <> string.inspect(reason), + ) + } + }) +} + +fn refresh_plays() -> Nil { + commit(pipeline.mark_plays_stale()) + browser.fetch_text(atproto.plays_url(), on_plays) +} + +fn on_plays(text: String) -> Nil { + case atproto.decode_plays(text) { + Ok(plays) -> commit(pipeline.plan_plays(plays)) + Error(reason) -> { + browser.log_error("decode_plays failed: " <> string.inspect(reason)) + // Keep the previous rows, just stop showing the stale pulse. + commit(pipeline.plan_plays([])) + } + } +} + +fn commit(commands: List(Command)) -> Nil { + list.each(commands, interpret) +} + +fn interpret(command: Command) -> Nil { + case command { + ReplaceHtml(id, html) -> browser.set_inner_html(id, html) + SetAttr(id, name, value) -> browser.set_attribute(id, name, value) + RemoveAttr(id, name) -> browser.remove_attribute(id, name) + LocalizeDates -> browser.localize_dates() + RewriteRemoteImages -> browser.rewrite_remote_images() + } +} + +fn poll_tick() -> Nil { + case browser.is_visible() { + True -> refresh_plays() + False -> Nil + } +} + +fn on_visibility_change(visible: Bool) -> Nil { + case visible { + True -> refresh_all() + False -> Nil + } +} diff --git a/client/src/client.gleam b/client/src/client.gleam --- a/client/src/client.gleam +++ b/client/src/client.gleam @@ -1,5 +1,5 @@ -import refresh +import app pub fn main() { - refresh.start() + app.start() } diff --git a/client/src/commit.gleam b/client/src/commit.gleam new file mode 100644 --- /dev/null +++ b/client/src/commit.gleam @@ -0,0 +1,11 @@ +//// The DOM operations the pure pipeline can produce. The interpreter +//// (`app.gleam`) maps each to a `browser.*` call; keeping them as +//// data means the planning half is unit-testable without a browser. + +pub type Command { + ReplaceHtml(id: String, html: String) + SetAttr(id: String, name: String, value: String) + RemoveAttr(id: String, name: String) + LocalizeDates + RewriteRemoteImages +} diff --git a/client/src/pipeline.gleam b/client/src/pipeline.gleam new file mode 100644 --- /dev/null +++ b/client/src/pipeline.gleam @@ -0,0 +1,54 @@ +//// Pure planning for the client's refresh pass. +//// +//// Given freshly fetched, decoded data, produce the DOM commands to +//// apply. No browser calls, no IO — unit-testable under Node with +//// plain values. The impure half (fetch + interpret) lives in +//// `app.gleam`. + +import commit.{ + type Command, LocalizeDates, RemoveAttr, ReplaceHtml, RewriteRemoteImages, + SetAttr, +} +import dynamic +import gen/actor/defs.{type ProfileViewDetailed} +import gen/alpha/feed/play.{type AlphaFeedPlay} +import gen/repo.{type Repo} +import plays as plays_view +import profile as profile_view +import repos as repos_view + +/// The fresh profile replaces the server-rendered section, then every +/// remote image in the document is pointed at its local mirror. +pub fn plan_profile(profile: ProfileViewDetailed) -> List(Command) { + [ + ReplaceHtml( + "profile-section", + dynamic.render(profile_view.profile(profile)), + ), + RewriteRemoteImages, + ] +} + +pub fn plan_repos(repos: List(Repo)) -> List(Command) { + [ReplaceHtml("repos", dynamic.render(repos_view.repos_section(repos)))] +} + +/// One plays poll tick: render the fresh rows, re-localize their +/// times, and clear the stale flag. An empty result (or an error +/// handled by the caller) just clears the flag so the UI stays calm. +pub fn plan_plays(plays: List(AlphaFeedPlay)) -> List(Command) { + case plays { + [] -> [RemoveAttr("plays", "data-stale")] + _ -> [ + ReplaceHtml("plays-rows", dynamic.render(plays_view.plays_rows(plays))), + LocalizeDates, + RemoveAttr("plays", "data-stale"), + ] + } +} + +/// The start of a poll cycle: flag the section stale so the CSS shows +/// it refreshing, then fetch. +pub fn mark_plays_stale() -> List(Command) { + [SetAttr("plays", "data-stale", "true")] +} diff --git a/client/src/refresh.gleam b/client/src/refresh.gleam deleted file mode 100644 --- a/client/src/refresh.gleam +++ /dev/null @@ -1,143 +0,0 @@ -//// Client-side runtime orchestration. -//// -//// Fetches fresh data and re-renders all dynamic sections on page load. -//// Plays are then polled every 30s while the tab is visible. - -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 -import repos as repos_view - -/// Short enough that a new track shows up promptly, long enough that -/// we're not hammering the PDS. -const plays_poll_ms = 30_000 - -/// Wires up initial fetches, periodic poll, and visibility listener. -pub fn start() -> Nil { - // Guard: only run refresh logic on pages with dynamic sections. - case browser.has_element("profile-section") { - False -> Nil - True -> { - refresh_all() - browser.localize_dates() - browser.set_interval(plays_poll_ms, poll_tick) - browser.on_visibility_change(on_visibility_change) - } - } -} - -fn refresh_all() -> Nil { - fetch_profile() - fetch_pinned_dids_and_repos() - refresh_plays() -} - -fn fetch_profile() -> Nil { - browser.fetch_text(fetch.profile_url(), on_profile) -} - -fn on_profile(text: String) -> Nil { - case fetch.decode_profile(text) { - Ok(profile) -> { - browser.set_inner_html( - "profile-section", - dynamic.render(profile_view.profile(profile)), - ) - // The fresh profile carries the PDS's remote avatar/banner URLs; - // point them at the local mirrors from the build. - browser.rewrite_remote_images() - } - Error(reason) -> - browser.log_error("decode_profile failed: " <> string.inspect(reason)) - } -} - -fn fetch_pinned_dids_and_repos() -> Nil { - browser.fetch_text(fetch.pinned_dids_url(), fn(pinned_text) { - 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(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_actor_profiles failed: " <> string.inspect(reason), - ) - } - }) -} - -fn commit_repos(repos: List(Repo)) -> Nil { - browser.set_inner_html( - "repos", - dynamic.render(repos_view.repos_section(repos)), - ) -} - -fn refresh_plays() -> Nil { - mark_plays_stale() - browser.fetch_text(fetch.plays_url(), on_plays) -} - -fn on_plays(text: String) -> Nil { - case fetch.decode_plays(text) { - Ok([]) -> mark_plays_fresh() - Ok(plays_data) -> commit_plays(plays_data) - Error(reason) -> { - browser.log_error("decode_plays failed: " <> string.inspect(reason)) - mark_plays_fresh() - } - } -} - -fn commit_plays(plays_data: List(AlphaFeedPlay)) -> Nil { - browser.set_inner_html( - "plays-rows", - dynamic.render(plays_view.plays_rows(plays_data)), - ) - browser.localize_dates() - mark_plays_fresh() -} - -fn mark_plays_stale() -> Nil { - browser.set_attribute("plays", "data-stale", "true") -} - -fn mark_plays_fresh() -> Nil { - browser.remove_attribute("plays", "data-stale") -} - -fn poll_tick() -> Nil { - case browser.is_visible() { - True -> refresh_plays() - False -> Nil - } -} - -fn on_visibility_change(visible: Bool) -> Nil { - case visible { - True -> refresh_all() - False -> Nil - } -} diff --git a/client/test/karitham_blog_client_test.gleam b/client/test/karitham_blog_client_test.gleam new file mode 100644 --- /dev/null +++ b/client/test/karitham_blog_client_test.gleam @@ -0,0 +1,5 @@ +import gleeunit + +pub fn main() { + gleeunit.main() +} diff --git a/client/test/pipeline_test.gleam b/client/test/pipeline_test.gleam new file mode 100644 --- /dev/null +++ b/client/test/pipeline_test.gleam @@ -0,0 +1,83 @@ +import commit +import gen/actor/defs.{type ProfileViewDetailed, ProfileViewDetailed} +import gen/alpha/feed/play.{type AlphaFeedPlay, AlphaFeedPlay, ArtistView} +import gen/repo.{type Repo, Repo} +import gleam/option.{None, Some} +import gleam/string +import gleeunit/should +import pipeline + +fn sample_profile() -> ProfileViewDetailed { + ProfileViewDetailed( + did: "did:plc:test", + handle: "test.bsky.social", + display_name: Some("Test User"), + description: Some("round-trip"), + avatar: Some("https://example.com/avatar.jpg"), + banner: None, + followers_count: None, + follows_count: None, + posts_count: None, + pronouns: None, + ) +} + +fn sample_play() -> AlphaFeedPlay { + AlphaFeedPlay( + track_name: "Track A", + artists: [ArtistView(artist_name: "Artist 1", artist_mb_id: Some(""))], + release_name: Some("Album A"), + duration: Some(180), + played_time: "2026-07-18T10:00:00Z", + origin_url: Some("https://example.com/a"), + ) +} + +fn sample_repos() -> List(Repo) { + [ + Repo( + name: Some("repo-one"), + description: Some("first"), + repo_did: "did:plc:one", + created_at: "2026-01-01T00:00:00Z", + topics: Some(["gleam"]), + website: None, + ), + ] +} + +pub fn plan_profile_replaces_section_and_rewrites_images_test() { + let assert [commit.ReplaceHtml(id, html), commit.RewriteRemoteImages] = + pipeline.plan_profile(sample_profile()) + id |> should.equal("profile-section") + string.contains(html, "Test User") |> should.be_true() +} + +pub fn plan_repos_replaces_section_test() { + let assert [commit.ReplaceHtml(id, html)] = + pipeline.plan_repos(sample_repos()) + id |> should.equal("repos") + string.contains(html, "repo-one") |> should.be_true() +} + +pub fn plan_plays_renders_rows_then_localizes_and_clears_stale_test() { + let assert [ + commit.ReplaceHtml(id, html), + commit.LocalizeDates, + commit.RemoveAttr(rid, name), + ] = pipeline.plan_plays([sample_play()]) + id |> should.equal("plays-rows") + string.contains(html, "Track A") |> should.be_true() + rid |> should.equal("plays") + name |> should.equal("data-stale") +} + +pub fn plan_plays_empty_clears_stale_without_rendering_test() { + pipeline.plan_plays([]) + |> should.equal([commit.RemoveAttr("plays", "data-stale")]) +} + +pub fn mark_plays_stale_sets_the_flag_test() { + pipeline.mark_plays_stale() + |> should.equal([commit.SetAttr("plays", "data-stale", "true")]) +} diff --git a/shared/src/api.gleam b/shared/src/api.gleam --- a/shared/src/api.gleam +++ b/shared/src/api.gleam @@ -1,17 +1,9 @@ -import gleam/string - -/// Returns the site URL, preferring the BLOG_URL environment variable -/// if set. Falls back to the hardcoded default. -pub fn site_url() -> String { - case os_getenv("BLOG_URL") { - Ok(url) -> string.trim(url) - Error(_) -> "https://karitham.dev" - } -} - -@external(erlang, "api_ffi", "getenv") -fn os_getenv(name: String) -> Result(String, Nil) - +/// Shared constants for AT Protocol endpoints and site identity. +/// +/// Pure constants only — no FFI, no environment reads, so this module +/// compiles and behaves identically on Erlang and JS. The site URL +/// used for OG/RSS absolute links lives in the SSG's `config.gleam` +/// (`BLOG_URL`), not here. pub const pds_endpoint = "https://eurosky.social" pub const public_api = "https://public.api.bsky.app" diff --git a/shared/src/api_ffi.erl b/shared/src/api_ffi.erl deleted file mode 100644 --- a/shared/src/api_ffi.erl +++ /dev/null @@ -1,8 +0,0 @@ --module(api_ffi). --export([getenv/1]). - -getenv(Name) -> - case os:getenv(binary_to_list(Name)) of - false -> {error, nil}; - Value -> {ok, list_to_binary(Value)} - end. diff --git a/shared/src/atproto.gleam b/shared/src/atproto.gleam new file mode 100644 --- /dev/null +++ b/shared/src/atproto.gleam @@ -0,0 +1,171 @@ +//// URL builders and response decoders for AT Protocol data. +//// +//// 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.{type ActorProfile, actor_profile_decoder} +import gen/alpha/feed/play.{type AlphaFeedPlay, alpha_feed_play_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.{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")] + api.public_api + <> "/xrpc/app.bsky.actor.getProfile?" + <> uri.query_to_string(params) +} + +pub fn plays_url() -> String { + let params = [ + #("repo", api.did), + #("collection", "fm.teal.alpha.feed.play"), + #("limit", int.to_string(api.plays_limit)), + ] + api.pds_endpoint + <> "/xrpc/com.atproto.repo.listRecords?" + <> uri.query_to_string(params) +} + +pub fn pinned_dids_url() -> String { + let params = [ + #("repo", api.did), + #("collection", "sh.tangled.actor.profile"), + ] + api.pds_endpoint + <> "/xrpc/com.atproto.repo.listRecords?" + <> uri.query_to_string(params) +} + +pub fn repos_url() -> String { + let params = [ + #("repo", api.did), + #("collection", "sh.tangled.repo"), + ] + 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) { + decode_records(body, alpha_feed_play_decoder()) + |> result.map(list.map(_, fn(record) { record.value })) +} + +/// 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()) +} + +/// 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(DecodedRecord(ActorProfile)), json.DecodeError) { + decode_records(body, actor_profile_decoder()) +} + +/// 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_records( + body: String, + decoder: decode.Decoder(a), +) -> Result(List(DecodedRecord(a)), json.DecodeError) { + use records <- result.try(json.parse(body, list_of_records_decoder())) + records + |> list.filter_map(fn(record) { + decode.run(record.value, decoder) + |> result.map(fn(value) { + DecodedRecord(uri: record.uri, cid: record.cid, value:) + }) + }) + |> Ok +} + +fn list_of_records_decoder() -> decode.Decoder(List(Record)) { + use records <- decode.field("records", decode.list(record_decoder())) + decode.success(records) +} + +/// 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.flat_map(fn(record) { + record.value.pinned_repositories + |> option.unwrap(or: []) + |> list.filter(fn(did) { did != "" }) + }) +} diff --git a/shared/src/decode.gleam b/shared/src/decode.gleam --- a/shared/src/decode.gleam +++ b/shared/src/decode.gleam @@ -1,4 +1,4 @@ -import fetch.{type DecodedRecord, DecodedRecord} +import atproto.{type DecodedRecord, DecodedRecord} import gen/actor/defs.{profile_view_detailed_decoder} import gen/alpha/feed/play.{alpha_feed_play_decoder} import gen/repo.{type Repo, repo_decoder} diff --git a/shared/src/encode.gleam b/shared/src/encode.gleam --- a/shared/src/encode.gleam +++ b/shared/src/encode.gleam @@ -1,4 +1,4 @@ -import fetch.{type DecodedRecord} +import atproto.{type DecodedRecord} import gen/actor/defs.{profile_view_detailed_fields} import gen/alpha/feed/play.{alpha_feed_play_fields} import gen/repo.{type Repo, repo_fields} diff --git a/shared/src/fetch.gleam b/shared/src/fetch.gleam deleted file mode 100644 --- a/shared/src/fetch.gleam +++ /dev/null @@ -1,171 +0,0 @@ -//// URL builders and response decoders for AT Protocol data. -//// -//// 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.{type ActorProfile, actor_profile_decoder} -import gen/alpha/feed/play.{type AlphaFeedPlay, alpha_feed_play_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.{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")] - api.public_api - <> "/xrpc/app.bsky.actor.getProfile?" - <> uri.query_to_string(params) -} - -pub fn plays_url() -> String { - let params = [ - #("repo", api.did), - #("collection", "fm.teal.alpha.feed.play"), - #("limit", int.to_string(api.plays_limit)), - ] - api.pds_endpoint - <> "/xrpc/com.atproto.repo.listRecords?" - <> uri.query_to_string(params) -} - -pub fn pinned_dids_url() -> String { - let params = [ - #("repo", api.did), - #("collection", "sh.tangled.actor.profile"), - ] - api.pds_endpoint - <> "/xrpc/com.atproto.repo.listRecords?" - <> uri.query_to_string(params) -} - -pub fn repos_url() -> String { - let params = [ - #("repo", api.did), - #("collection", "sh.tangled.repo"), - ] - 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) { - decode_records(body, alpha_feed_play_decoder()) - |> result.map(list.map(_, fn(record) { record.value })) -} - -/// 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()) -} - -/// 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(DecodedRecord(ActorProfile)), json.DecodeError) { - decode_records(body, actor_profile_decoder()) -} - -/// 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_records( - body: String, - decoder: decode.Decoder(a), -) -> Result(List(DecodedRecord(a)), json.DecodeError) { - use records <- result.try(json.parse(body, list_of_records_decoder())) - records - |> list.filter_map(fn(record) { - decode.run(record.value, decoder) - |> result.map(fn(value) { - DecodedRecord(uri: record.uri, cid: record.cid, value:) - }) - }) - |> Ok -} - -fn list_of_records_decoder() -> decode.Decoder(List(Record)) { - use records <- decode.field("records", decode.list(record_decoder())) - decode.success(records) -} - -/// 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.flat_map(fn(record) { - record.value.pinned_repositories - |> option.unwrap(or: []) - |> list.filter(fn(did) { did != "" }) - }) -} diff --git a/shared/src/hydration.gleam b/shared/src/hydration.gleam --- a/shared/src/hydration.gleam +++ b/shared/src/hydration.gleam @@ -1,4 +1,4 @@ -import fetch.{type DecodedRecord} +import atproto.{type DecodedRecord} import gen/actor/defs.{type ProfileViewDetailed} import gen/alpha/feed/play.{type AlphaFeedPlay} import gen/repo.{type Repo} diff --git a/shared/src/plays.gleam b/shared/src/plays.gleam --- a/shared/src/plays.gleam +++ b/shared/src/plays.gleam @@ -143,7 +143,7 @@ /// Render a play's `played_time` as `HH:MM` in UTC, and return the /// original ISO string for client-side re-localization. -fn format_play_time(iso: String) -> #(String, String) { +pub fn format_play_time(iso: String) -> #(String, String) { case timestamp.parse_rfc3339(iso) { Ok(ts) -> { let #(_, time) = timestamp.to_calendar(ts, calendar.utc_offset) diff --git a/shared/src/repos.gleam b/shared/src/repos.gleam --- a/shared/src/repos.gleam +++ b/shared/src/repos.gleam @@ -6,8 +6,9 @@ import gleam/order import gleam/string import gleam/time/timestamp -import lustre/element.{type Element, none} -import section +import lustre/attribute.{class, id} +import lustre/element.{type Element, none, text} +import lustre/element/html.{div, h2} const max_repos = 5 @@ -36,14 +37,19 @@ pub fn repos_section(repos: List(Repo)) -> Element(msg) { case select_top_repos(repos) { [] -> none() - top -> - section.section( - "Projects", - "repos", - False, - list.map(top, render_repo_card), - ) + top -> section("Projects", "repos", list.map(top, render_repo_card)) } +} + +/// The section frame used by the repos list: a titled container with +/// `data-stale` support for the client's refresh cycle (the CSS keys +/// off `.section[data-stale="true"]` for a pulsing dot in the header). +fn section( + title: String, + id_str: String, + items: List(Element(msg)), +) -> Element(msg) { + div([id(id_str), class("section")], [h2([], [text(title)]), ..items]) } fn dedup_by_did(repos: List(Repo)) -> List(Repo) { diff --git a/shared/src/section.gleam b/shared/src/section.gleam deleted file mode 100644 --- a/shared/src/section.gleam +++ /dev/null @@ -1,22 +0,0 @@ -import lustre/attribute.{attribute, class, id} -import lustre/element.{type Element, text} -import lustre/element/html.{div, h2} - -/// Wrap a titled list of items in the section frame used by plays/repos. -/// -/// When `stale` is `True` the section root gets `data-stale="true"`. The -/// client toggles this off once its fetch resolves, and back on for the -/// next poll cycle. The CSS in `priv/static/style.css` keys off -/// `.section[data-stale="true"]` to render a pulsing dot in the header. -pub fn section( - title: String, - id_str: String, - stale: Bool, - items: List(Element(msg)), -) -> Element(msg) { - let attrs = case stale { - True -> [id(id_str), class("section"), attribute("data-stale", "true")] - False -> [id(id_str), class("section")] - } - div(attrs, [h2([], [text(title)]), ..items]) -} diff --git a/shared/src/stats.gleam b/shared/src/stats.gleam --- a/shared/src/stats.gleam +++ b/shared/src/stats.gleam @@ -19,6 +19,7 @@ import gleam/dynamic import gleam/dynamic/decode +import gleam/json import gleam/list import gleam/result @@ -131,4 +132,59 @@ && list.is_empty(range_stats.albums) && list.is_empty(range_stats.tracks) }) +} + +/// Encode `StatsData` back to the plays-stats.json shape. Mirrors the +/// decoder so the cross-language contract test can round-trip the +/// golden fixture; optional fields are omitted when empty, exactly +/// like `tools/parse-plays` writes them. +pub fn encode_stats(data: StatsData) -> String { + json.object([ + #( + "ranges", + json.object( + list.map(data.ranges, fn(pair) { + let #(range, rs) = pair + #( + range_key(range), + json.object([ + #("artists", json.array(from: rs.artists, of: encode_item)), + #("albums", json.array(from: rs.albums, of: encode_item)), + #("tracks", json.array(from: rs.tracks, of: encode_item)), + ]), + ) + }), + ), + ), + ]) + |> json.to_string +} + +fn encode_item(item: StatsItem) -> json.Json { + json.object( + [ + #("name", json.string(item.name)), + #("plays", json.int(item.plays)), + #("ms_played", json.int(item.ms_played)), + ] + |> list.append(encode_optional(item)), + ) +} + +fn encode_optional(item: StatsItem) -> List(#(String, json.Json)) { + [ + case item.artist { + "" -> [] + a -> [#("artist", json.string(a))] + }, + case item.image { + "" -> [] + i -> [#("image", json.string(i))] + }, + case item.url { + "" -> [] + u -> [#("url", json.string(u))] + }, + ] + |> list.flatten } diff --git a/shared/test/atproto_test.gleam b/shared/test/atproto_test.gleam new file mode 100644 --- /dev/null +++ b/shared/test/atproto_test.gleam @@ -0,0 +1,163 @@ +import atproto.{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 + +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() { + atproto.rkey_from_uri("at://did:plc:abc/sh.tangled.repo/blog") + |> should.equal(Ok("blog")) +} + +pub fn rkey_from_uri_handles_handle_authority_test() { + atproto.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 = atproto.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 = atproto.resolve_repo_name(rec) + resolved.value.name |> should.equal(Some("my-cool-repo")) +} + +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 = atproto.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") + atproto.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") + atproto.filter_repos_by_did([rec], ["did:plc:other"]) + |> should.equal([]) +} + +// --- pinned_dids_from_profiles --- + +pub fn pinned_dids_from_profiles_extracts_test() { + atproto.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. + atproto.pinned_dids_from_profiles([ + profile_with_pins(["did:plc:keep", "", "", "", "", ""]), + ]) + |> should.equal(["did:plc:keep"]) +} + +pub fn pinned_dids_from_profiles_flattens_multiple_test() { + atproto.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), + ) + atproto.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) = atproto.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") +} + +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) = atproto.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/encode_test.gleam b/shared/test/encode_test.gleam --- a/shared/test/encode_test.gleam +++ b/shared/test/encode_test.gleam @@ -1,6 +1,6 @@ +import atproto.{type DecodedRecord, DecodedRecord} 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} diff --git a/shared/test/fetch_test.gleam b/shared/test/fetch_test.gleam deleted file mode 100644 --- a/shared/test/fetch_test.gleam +++ /dev/null @@ -1,163 +0,0 @@ -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 - -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")) -} - -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") -} - -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/plays_test.gleam b/shared/test/plays_test.gleam new file mode 100644 --- /dev/null +++ b/shared/test/plays_test.gleam @@ -0,0 +1,88 @@ +import atproto +import gleam/string +import gleeunit/should +import lustre/element.{to_string} +import plays +import stats.{type StatsData, type StatsItem, RangeStats, StatsData, StatsItem} + +// --- format_play_time --- + +pub fn format_play_time_parses_utc_test() { + plays.format_play_time("2026-07-18T10:05:00Z") + |> should.equal(#("10:05", "2026-07-18T10:05:00Z")) +} + +pub fn format_play_time_invalid_returns_empty_test() { + plays.format_play_time("not a time") |> should.equal(#("", "not a time")) +} + +// --- plays_rows --- + +fn sample_play_body() -> String { + // The minimal set of fields the play decoder requires. + "{\"records\":[{ + \"cid\": \"bafy1\", + \"uri\": \"at://did:plc:test/fm.teal.alpha.feed.play/abc\", + \"value\": { + \"artists\": [{\"artistName\": \"Artist A\"}], + \"playedTime\": \"2026-07-18T10:00:00Z\", + \"trackName\": \"Track A\" + } + }]}" +} + +pub fn plays_rows_renders_rows_test() { + let assert Ok(decoded) = atproto.decode_plays(sample_play_body()) + let html = plays.plays_rows(decoded) |> to_string + string.contains(html, "plays-rows") |> should.be_true() + string.contains(html, "Track A") |> should.be_true() + string.contains(html, "Artist A") |> should.be_true() +} + +// --- stats view tiles --- + +fn stats_with_tile(item: StatsItem) -> StatsData { + StatsData(ranges: [ + #(stats.OneMonth, RangeStats(artists: [item], albums: [], tracks: [])), + #(stats.SixMonths, RangeStats(artists: [], albums: [], tracks: [])), + #(stats.OneYear, RangeStats(artists: [], albums: [], tracks: [])), + #(stats.AllTime, RangeStats(artists: [], albums: [], tracks: [])), + ]) +} + +pub fn plays_section_tile_without_image_uses_placeholder_test() { + let assert Ok(decoded) = atproto.decode_plays(sample_play_body()) + let data = + stats_with_tile(StatsItem( + name: "Alpha", + artist: "", + plays: 5, + ms_played: 0, + image: "", + url: "", + )) + let html = plays.plays_section(decoded, data) |> to_string + string.contains(html, "tile-cover--none") |> should.be_true() + string.contains(html, "tile-initial") |> should.be_true() +} + +pub fn plays_section_tile_with_image_and_url_links_test() { + let assert Ok(decoded) = atproto.decode_plays(sample_play_body()) + let data = + stats_with_tile(StatsItem( + name: "Alpha", + artist: "Artist A", + plays: 5, + ms_played: 0, + image: "/img/alpha.jpg", + url: "https://musicbrainz.org/artist/a", + )) + let html = plays.plays_section(decoded, data) |> to_string + string.contains(html, "/img/alpha.jpg") |> should.be_true() + string.contains(html, "tile-link") |> should.be_true() + string.contains(html, "https://musicbrainz.org/artist/a") |> should.be_true() +} + +pub fn plays_section_hidden_when_no_plays_test() { + plays.plays_section([], stats.empty_stats()) |> to_string |> should.equal("") +} diff --git a/shared/test/stats_test.gleam b/shared/test/stats_test.gleam new file mode 100644 --- /dev/null +++ b/shared/test/stats_test.gleam @@ -0,0 +1,80 @@ +import gleam/json +import gleam/list +import gleeunit/should +import stats + +fn full_stats_body() -> String { + "{ + \"ranges\": { + \"1m\": { + \"artists\": [ + {\"name\": \"Alpha\", \"plays\": 5, \"ms_played\": 1000, + \"image\": \"/img/alpha.jpg\", \"url\": \"https://musicbrainz.org/artist/a\"} + ], + \"albums\": [ + {\"name\": \"Album A\", \"artist\": \"Alpha\", \"plays\": 3, \"ms_played\": 500} + ], + \"tracks\": [] + }, + \"6m\": {\"artists\": [], \"albums\": [], \"tracks\": []}, + \"1y\": {\"artists\": [], \"albums\": [], \"tracks\": []}, + \"all\": {\"artists\": [], \"albums\": [], \"tracks\": []} + } + }" +} + +pub fn decoder_reads_all_four_ranges_test() { + let assert Ok(data) = + json.parse(full_stats_body(), stats.stats_data_decoder()) + data.ranges |> list.length |> should.equal(4) + let assert [first, ..] = data.ranges + let #(range, range_stats) = first + range |> should.equal(stats.OneMonth) + range_stats.artists |> list.length |> should.equal(1) +} + +pub fn decoder_omits_optional_fields_test() { + let assert Ok(data) = + json.parse(full_stats_body(), stats.stats_data_decoder()) + let assert [first, ..] = data.ranges + let #(_, range_stats) = first + let assert [artist] = range_stats.artists + artist.artist |> should.equal("") + artist.url |> should.equal("https://musicbrainz.org/artist/a") + let assert [album] = range_stats.albums + album.artist |> should.equal("Alpha") + album.image |> should.equal("") +} + +pub fn decoder_defaults_missing_ranges_to_empty_test() { + let body = + "{\"ranges\": {\"1m\": {\"artists\": [], \"albums\": [], \"tracks\": []}}}" + let assert Ok(data) = json.parse(body, stats.stats_data_decoder()) + data.ranges |> list.length |> should.equal(4) + stats.is_empty(data) |> should.be_true() +} + +pub fn decoder_rejects_missing_ranges_test() { + json.parse("{}", stats.stats_data_decoder()) + |> should.be_error() +} + +pub fn empty_stats_is_empty_test() { + stats.is_empty(stats.empty_stats()) |> should.be_true() +} + +pub fn encode_round_trips_through_decoder_test() { + let assert Ok(original) = + json.parse(full_stats_body(), stats.stats_data_decoder()) + let json_string = stats.encode_stats(original) + let assert Ok(decoded) = json.parse(json_string, stats.stats_data_decoder()) + decoded |> should.equal(original) +} + +pub fn encode_empty_stats_test() { + // `empty_stats` has no ranges while the decoder always yields four + // (all empty) — the round trip preserves emptiness, not structure. + let json_string = stats.encode_stats(stats.empty_stats()) + let assert Ok(decoded) = json.parse(json_string, stats.stats_data_decoder()) + stats.is_empty(decoded) |> should.be_true() +} diff --git a/src/cli/slug.gleam b/src/cli/slug.gleam new file mode 100644 --- /dev/null +++ b/src/cli/slug.gleam @@ -0,0 +1,61 @@ +//// Pure slug and title helpers for the CLI. No I/O, no time — the +//// template's date is injected by `cli.gleam`. + +import gleam/list +import gleam/string + +/// Normalize free-form text into a valid slug: lowercase, every +/// non-`[a-z0-9-]` character becomes a hyphen, runs of hyphens +/// collapse to one, leading/trailing hyphens stripped. Returns +/// `""` for input that contains no usable characters. +pub fn slugify(input: String) -> String { + input + |> string.lowercase + |> string.trim + |> string.to_graphemes + |> list.map(slugify_char) + |> string.join("") + |> collapse_dashes + |> trim_dashes +} + +fn slugify_char(c: String) -> String { + case string.contains("abcdefghijklmnopqrstuvwxyz0123456789-", c) { + True -> c + False -> "-" + } +} + +fn collapse_dashes(s: String) -> String { + case string.contains(s, "--") { + True -> collapse_dashes(string.replace(s, each: "--", with: "-")) + False -> s + } +} + +fn trim_dashes(s: String) -> String { + case string.starts_with(s, "-") { + True -> trim_dashes(string.drop_start(s, 1)) + False -> + case string.ends_with(s, "-") { + True -> trim_dashes(string.drop_end(s, 1)) + False -> s + } + } +} + +/// Best-effort title from a slug: `"hello-world"` -> `"Hello World"`. +/// The user overwrites the title anyway once they start writing. +pub fn title_from_slug(slug: String) -> String { + slug + |> string.split(on: "-") + |> list.map(capitalize) + |> string.join(" ") +} + +fn capitalize(word: String) -> String { + case string.to_graphemes(word) { + [] -> "" + [first, ..rest] -> string.uppercase(first) <> string.join(rest, "") + } +} diff --git a/src/cli/template.gleam b/src/cli/template.gleam new file mode 100644 --- /dev/null +++ b/src/cli/template.gleam @@ -0,0 +1,28 @@ +//// Pure post template. The date is injected so the scaffolder is +//// testable with a fixed "today". + +import cli/slug +import date +import gleam/int + +/// Render the post template for a given slug. `today` is the current +/// date as `#(year, month, day)` so the scaffolded post sorts to the +/// top of the timeline; the shell (`cli.gleam`) reads the clock. +pub fn template(slug: String, today: #(Int, Int, Int)) -> String { + let #(y, m, d) = today + let date = int.to_string(y) <> "-" <> date.pad2(m) <> "-" <> date.pad2(d) + "---\n" + <> "title: " + <> slug.title_from_slug(slug) + <> "\n" + <> "description: \n" + <> "date: " + <> date + <> "\n" + <> "tags: []\n" + <> "draft: true\n" + <> "image: \n" + <> "---\n" + <> "\n" + <> "Write your post here.\n" +} diff --git a/src/data/fetch.gleam b/src/data/fetch.gleam deleted file mode 100644 --- a/src/data/fetch.gleam +++ /dev/null @@ -1,187 +0,0 @@ -//// 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.{type DecodedRecord} -import gen/alpha/feed/play.{type AlphaFeedPlay} -import gen/repo.{type Repo} -import gleam/io -import gleam/json -import gleam/list -import gleam/order -import gleam/string -import simplifile -import stats - -pub fn fetch_all() -> SiteData { - let pinned_dids = fetch_pinned_dids() - let profile = fetch_profile() - let recent_plays = fetch_plays() - let plays_stats = fetch_plays_stats() - let repos = fetch_repos(pinned_dids) - - SiteData(profile:, recent_plays:, plays_stats:, repos:, posts: read_posts()) -} - -// --- plays stats (derived cache from tools/parse-plays, not live) --- - -/// Read the precomputed top-N grids from `priv/cache/plays-stats.json`. -/// Generated by `just refresh`; absent cache degrades to an empty -/// aside rather than failing the build. -fn fetch_plays_stats() -> stats.StatsData { - case simplifile.read("priv/cache/plays-stats.json") { - Ok(body) -> - case json.parse(body, stats.stats_data_decoder()) { - Ok(data) -> data - Error(e) -> - log_fail("plays-stats", string.inspect(e), stats.empty_stats()) - } - Error(e) -> log_fail("plays-stats", string.inspect(e), stats.empty_stats()) - } -} - -// --- profile --- - -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) - } -} - -// --- plays --- - -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, []) - } -} - -// --- 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, []) - } -} - -// --- 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` -/// for what counts as invalid. -pub fn read_posts() -> List(Post) { - case simplifile.read_directory("priv/posts") { - Ok(entries) -> - entries - |> list.filter(fn(entry) { - case simplifile.is_directory("priv/posts/" <> entry) { - Ok(True) -> True - _ -> False - } - }) - |> list.map(read_post) - |> list.sort(by: compare_posts_desc) - Error(_) -> [] - } -} - -fn read_post(slug: String) -> Post { - case frontmatter.is_valid_slug(slug) { - False -> { - io.println( - "Error: post directory \"" - <> slug - <> "\" is not a valid slug (lowercase letters, digits, and hyphens only).", - ) - panic as "invalid slug" - } - True -> { - let path = "priv/posts/" <> slug <> "/index.md" - case simplifile.read(path) { - Ok(content) -> - case frontmatter.parse(slug, content) { - Ok(post) -> post - Error(e) -> { - io.println("Error: " <> format_parse_error(slug, e)) - panic as "post parse failed" - } - } - Error(e) -> { - io.println( - "Failed to read post " <> slug <> ": " <> string.inspect(e), - ) - panic as "post read failed" - } - } - } - } -} - -fn format_parse_error(slug: String, e: frontmatter.ParseError) -> String { - case e { - frontmatter.MissingField(_, field) -> - "post \"" <> slug <> "\" is missing required field \"" <> field <> "\"" - frontmatter.InvalidDate(_, value) -> - "post \"" - <> slug - <> "\" has invalid date \"" - <> value - <> "\" (expected YYYY-MM-DD)" - frontmatter.InvalidYaml(_, error) -> - "post \"" <> slug <> "\" has invalid frontmatter: " <> error - } -} - -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/image_ext.gleam b/src/data/image_ext.gleam new file mode 100644 --- /dev/null +++ b/src/data/image_ext.gleam @@ -0,0 +1,22 @@ +//// Pure content-type → file extension mapping, extracted from the +//// image mirroring logic so it's unit-testable. + +import gleam/string + +/// Pick a file extension for a mirrored image from its Content-Type +/// header. Falls back to `jpg` for anything unrecognized. +pub fn ext_for_content_type(content_type: String) -> String { + let content_type = string.lowercase(content_type) + ext_for(content_type, [#("png", "png"), #("webp", "webp"), #("gif", "gif")]) +} + +fn ext_for(content_type: String, pairs: List(#(String, String))) -> String { + case pairs { + [] -> "jpg" + [#(needle, ext), ..rest] -> + case string.contains(content_type, needle) { + True -> ext + False -> ext_for(content_type, rest) + } + } +} diff --git a/src/data/images.gleam b/src/data/images.gleam --- a/src/data/images.gleam +++ b/src/data/images.gleam @@ -1,6 +1,6 @@ //// Build-time mirroring of the profile's avatar/banner blobs. //// -//// The browser re-fetches the profile on page load (client/refresh.gleam) +//// The browser re-fetches the profile on page load (client/app.gleam) //// and would otherwise pull the avatar/banner straight from the PDS on //// every visit. Instead the SSG downloads them once per build into //// `dist/img/profile/` and rewrites the profile data to point at the @@ -8,13 +8,21 @@ //// (`#image-rewrites`) so the client can do the same for its fresh //// render. A failed download keeps the remote URL — the page still //// works, just with the extra PDS request. +//// +//// `fetch_image` and `write_bits` are injected so the mirroring logic +//// is testable with stubs (no network, no real files). -import data/transport +import data/image_ext import gen/actor/defs.{type ProfileViewDetailed, ProfileViewDetailed} import gleam/list import gleam/option.{type Option, None, Some} -import gleam/string import simplifile + +pub type FetchImage = + fn(String) -> Result(#(BitArray, String), String) + +pub type WriteBits = + fn(String, BitArray) -> Result(Nil, simplifile.FileError) pub type ProfileImages { ProfileImages(profile: ProfileViewDetailed, rewrites: List(#(String, String))) @@ -24,10 +32,14 @@ /// Download the avatar/banner blobs (if any) and return a profile whose /// image fields point at the local copies, plus the rewrite map. -pub fn mirror_profile_images(profile: ProfileViewDetailed) -> ProfileImages { +pub fn mirror_profile_images( + profile: ProfileViewDetailed, + fetch_image: FetchImage, + write_bits: WriteBits, +) -> ProfileImages { let _ = simplifile.create_directory_all(img_dir) - let avatar = mirror_one("avatar", profile.avatar) - let banner = mirror_one("banner", profile.banner) + let avatar = mirror_one("avatar", profile.avatar, fetch_image, write_bits) + let banner = mirror_one("banner", profile.banner, fetch_image, write_bits) ProfileImages( profile: ProfileViewDetailed( ..profile, @@ -42,19 +54,23 @@ MirrorResult(local_url: Option(String), rewrites: List(#(String, String))) } -fn mirror_one(name: String, remote: Option(String)) -> MirrorResult { +fn mirror_one( + name: String, + remote: Option(String), + fetch_image: FetchImage, + write_bits: WriteBits, +) -> MirrorResult { case remote { None -> MirrorResult(local_url: None, rewrites: []) Some(url) -> { let fallback = MirrorResult(local_url: remote, rewrites: []) - case transport.fetch_image(url) { + case fetch_image(url) { Error(_) -> fallback Ok(#(bits, content_type)) -> { - let filename = name <> "." <> ext_for_content_type(content_type) + let filename = + name <> "." <> image_ext.ext_for_content_type(content_type) let path = "/img/profile/" <> filename - case - simplifile.write_bits(to: img_dir <> "/" <> filename, bits: bits) - { + case write_bits(img_dir <> "/" <> filename, bits) { Ok(Nil) -> MirrorResult(local_url: Some(path), rewrites: [#(url, path)]) Error(_) -> fallback @@ -62,21 +78,5 @@ } } } - } -} - -fn ext_for_content_type(content_type: String) -> String { - let content_type = string.lowercase(content_type) - ext_for(content_type, [#("png", "png"), #("webp", "webp"), #("gif", "gif")]) -} - -fn ext_for(content_type: String, pairs: List(#(String, String))) -> String { - case pairs { - [] -> "jpg" - [#(needle, ext), ..rest] -> - case string.contains(content_type, needle) { - True -> ext - False -> ext_for(content_type, rest) - } } } diff --git a/src/data/model.gleam b/src/data/model.gleam --- a/src/data/model.gleam +++ b/src/data/model.gleam @@ -1,4 +1,4 @@ -import fetch.{type DecodedRecord} +import atproto.{type DecodedRecord} import gen/actor/defs.{type ProfileViewDetailed} import gen/alpha/feed/play.{type AlphaFeedPlay} import gen/repo.{type Repo} diff --git a/src/data/sources.gleam b/src/data/sources.gleam new file mode 100644 --- /dev/null +++ b/src/data/sources.gleam @@ -0,0 +1,201 @@ +//// Gather all site data for the SSG. +//// +//// Each section fetches its own URL through the shared decoders in +//// `shared/src/atproto.gleam`. The `http_get` seam is injected from +//// `build.gleam` (which passes `transport.fetch_body`) so this module +//// is testable with stub functions and the network stays out of the +//// decision logic. Failure on a section logs a warning and returns an +//// empty value, so one slow PDS doesn't take the whole build down. + +import atproto.{type DecodedRecord} +import data/frontmatter +import data/model.{type Post, type SiteData, SiteData} +import gen/alpha/feed/play.{type AlphaFeedPlay} +import gen/repo.{type Repo} +import gleam/io +import gleam/json +import gleam/list +import gleam/order +import gleam/string +import simplifile +import stats + +/// A GET request that returns the response body as a string. The SSG +/// passes `transport.fetch_body`; tests pass canned stubs. +pub type HttpGet = + fn(String) -> Result(String, String) + +pub fn fetch_all(http_get: HttpGet) -> SiteData { + let pinned_dids = fetch_pinned_dids(http_get) + let profile = fetch_profile(http_get) + let recent_plays = fetch_plays(http_get) + let plays_stats = fetch_plays_stats() + let repos = fetch_repos(http_get, pinned_dids) + + SiteData(profile:, recent_plays:, plays_stats:, repos:, posts: read_posts()) +} + +// --- plays stats (derived cache from tools/parse-plays, not live) --- + +/// Read the precomputed top-N grids from `priv/cache/plays-stats.json`. +/// Generated by `just refresh`; absent cache degrades to an empty +/// aside rather than failing the build. +fn fetch_plays_stats() -> stats.StatsData { + case simplifile.read("priv/cache/plays-stats.json") { + Ok(body) -> + case json.parse(body, stats.stats_data_decoder()) { + Ok(data) -> data + Error(e) -> + log_fail("plays-stats", string.inspect(e), stats.empty_stats()) + } + Error(e) -> log_fail("plays-stats", string.inspect(e), stats.empty_stats()) + } +} + +// --- profile --- + +fn fetch_profile(http_get: HttpGet) { + case http_get(atproto.profile_url()) { + Ok(body) -> + case atproto.decode_profile(body) { + Ok(profile) -> profile + Error(e) -> log_fail_and_panic("profile", string.inspect(e)) + } + Error(e) -> log_fail_and_panic("profile", e) + } +} + +// --- plays --- + +fn fetch_plays(http_get: HttpGet) -> List(AlphaFeedPlay) { + case http_get(atproto.plays_url()) { + Ok(body) -> plays_from_body(body) + Error(reason) -> log_fail("plays", reason, []) + } +} + +/// Pure: decode a plays `listRecords` body, falling back to the empty +/// list when a record fails to decode. +pub fn plays_from_body(body: String) -> List(AlphaFeedPlay) { + case atproto.decode_plays(body) { + Ok(plays) -> plays + Error(e) -> log_fail("plays", string.inspect(e), []) + } +} + +// --- repos --- + +fn fetch_repos( + http_get: HttpGet, + pinned_dids: List(String), +) -> List(DecodedRecord(Repo)) { + case http_get(atproto.repos_url()) { + Ok(body) -> + case atproto.decode_repos(body) { + Ok(records) -> + records + |> atproto.filter_repos_by_did(pinned_dids) + |> list.map(atproto.resolve_repo_name) + Error(e) -> log_fail("repos", string.inspect(e), []) + } + Error(e) -> log_fail("repos", e, []) + } +} + +// --- pinned DIDs --- + +fn fetch_pinned_dids(http_get: HttpGet) -> List(String) { + case http_get(atproto.pinned_dids_url()) { + Ok(body) -> + case atproto.decode_actor_profiles(body) { + Ok(profiles) -> atproto.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` +/// for what counts as invalid. +pub fn read_posts() -> List(Post) { + case simplifile.read_directory("priv/posts") { + Ok(entries) -> + entries + |> list.filter(fn(entry) { + case simplifile.is_directory("priv/posts/" <> entry) { + Ok(True) -> True + _ -> False + } + }) + |> list.map(read_post) + |> list.sort(by: compare_posts_desc) + Error(_) -> [] + } +} + +fn read_post(slug: String) -> Post { + case frontmatter.is_valid_slug(slug) { + False -> { + io.println( + "Error: post directory \"" + <> slug + <> "\" is not a valid slug (lowercase letters, digits, and hyphens only).", + ) + panic as "invalid slug" + } + True -> { + let path = "priv/posts/" <> slug <> "/index.md" + case simplifile.read(path) { + Ok(content) -> + case frontmatter.parse(slug, content) { + Ok(post) -> post + Error(e) -> { + io.println("Error: " <> format_parse_error(slug, e)) + panic as "post parse failed" + } + } + Error(e) -> { + io.println( + "Failed to read post " <> slug <> ": " <> string.inspect(e), + ) + panic as "post read failed" + } + } + } + } +} + +fn format_parse_error(slug: String, e: frontmatter.ParseError) -> String { + case e { + frontmatter.MissingField(_, field) -> + "post \"" <> slug <> "\" is missing required field \"" <> field <> "\"" + frontmatter.InvalidDate(_, value) -> + "post \"" + <> slug + <> "\" has invalid date \"" + <> value + <> "\" (expected YYYY-MM-DD)" + frontmatter.InvalidYaml(_, error) -> + "post \"" <> slug <> "\" has invalid frontmatter: " <> error + } +} + +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/transport.gleam b/src/data/transport.gleam --- a/src/data/transport.gleam +++ b/src/data/transport.gleam @@ -3,13 +3,16 @@ //// `fetch_body` does a GET and returns the response body as a string; //// `fetch_image` does a GET and returns raw bytes plus the response's //// Content-Type (for picking a file extension). Status and error -//// handling stay here so the shared decoders in `shared/src/fetch.gleam` +//// handling stay here so the shared decoders in `shared/src/atproto.gleam` //// can stay pure. //// //// Both retry transient failures (network errors, 429/5xx) a few times //// with a short fixed delay, so a blip from the PDS or a CDN doesn't -//// blank out a section for this build. +//// blank out a section for this build. The retry decision tree is pure +//// and lives in `data/transport/core`; this module owns the httpc +//// calls and the sleep. +import data/transport/core.{type HttpError} import gleam/http/request import gleam/http/response import gleam/httpc @@ -19,13 +22,6 @@ @external(erlang, "transport_ffi", "sleep") fn sleep(ms: Int) -> Nil - -/// Retryable vs permanent failure. Transport errors and 429/5xx are -/// transient; 4xx and invalid URLs are permanent. -type HttpError { - Transient(String) - Permanent(String) -} const max_attempts = 3 @@ -53,17 +49,13 @@ f: fn() -> Result(a, HttpError), attempts: Int, ) -> Result(a, String) { - case f() { - Ok(value) -> Ok(value) - Error(Permanent(reason)) -> Error(reason) - Error(Transient(reason)) -> - case attempts <= 1 { - True -> Error(reason) - False -> { - sleep(retry_delay_ms) - retry_from(f, attempts - 1) - } - } + case core.decide(f(), attempts) { + core.Succeeded(value) -> Ok(value) + core.GivenUp(reason) -> Error(reason) + core.Retry(_) -> { + sleep(retry_delay_ms) + retry_from(f, attempts - 1) + } } } @@ -71,16 +63,17 @@ use req <- result.try( request.to(url) |> result.replace_error("invalid url: " <> url) - |> result.map_error(fn(e) { Permanent(e) }), + |> result.map_error(fn(e) { core.Permanent(e) }), ) use resp <- result.try( - httpc.send(req) |> result.map_error(fn(e) { Transient(string.inspect(e)) }), + httpc.send(req) + |> result.map_error(fn(e) { core.Transient(string.inspect(e)) }), ) case resp.status >= 200 && resp.status < 300 { True -> Ok(resp.body) False -> { let reason = "HTTP " <> int.to_string(resp.status) <> ": " <> resp.body - classify_status(resp.status, reason) + core.classify_status(resp.status, reason) } } } @@ -90,24 +83,18 @@ request.to(url) |> result.map(fn(req) { request.set_body(req, <<>>) }) |> result.replace_error("invalid url: " <> url) - |> result.map_error(fn(e) { Permanent(e) }), + |> result.map_error(fn(e) { core.Permanent(e) }), ) use resp <- result.try( httpc.send_bits(req) - |> result.map_error(fn(e) { Transient(string.inspect(e)) }), + |> result.map_error(fn(e) { core.Transient(string.inspect(e)) }), ) case resp.status >= 200 && resp.status < 300 { - False -> classify_status(resp.status, "HTTP " <> int.to_string(resp.status)) + False -> + core.classify_status(resp.status, "HTTP " <> int.to_string(resp.status)) True -> { let content_type = response.get_header(resp, "content-type") Ok(#(resp.body, result.unwrap(content_type, ""))) } - } -} - -fn classify_status(status: Int, reason: String) -> Result(a, HttpError) { - case status == 429 || status >= 500 { - True -> Error(Transient(reason)) - False -> Error(Permanent(reason)) } } diff --git a/src/render/page.gleam b/src/render/page.gleam new file mode 100644 --- /dev/null +++ b/src/render/page.gleam @@ -0,0 +1,154 @@ +//// Pure page assembly: `SiteData`/`Post` → full document `Element`. +//// +//// No I/O here — `build.gleam` gathers the data, calls these, and +//// writes the result. `site_url` is a plain parameter (never read +//// from the environment) so OG/RSS links are correct both for +//// production and for `BLOG_URL=http://localhost:8000` previews. + +import data/model.{type Post, type SiteData} +import dynamic +import encode +import gen/actor/defs.{type ProfileViewDetailed} +import gleam/json +import gleam/list +import gleam/option.{type Option, None, Some, map as option_map} +import gleam/string +import hydration.{HydrationModel} +import lustre/attribute.{class, id, type_} +import lustre/element.{type Element, fragment, text} +import lustre/element/html.{div, h2, script} +import view/components/post_view +import view/layout + +/// The home page: dynamic sections (profile, music, repos) plus the +/// published article list. `rewrites` is the remote→local image map +/// embedded for the client's hydration pass. +pub fn index_page( + data: SiteData, + site_url: String, + rewrites: List(#(String, String)), +) -> Element(Nil) { + let og_image: Option(String) = case data.profile.banner { + Some(img) -> Some(absolutize_img(img, site_url)) + None -> + option_map(data.profile.avatar, fn(img) { absolutize_img(img, site_url) }) + } + + let description = case data.profile.description { + Some(desc) -> desc + None -> "Karitham's personal blog and project showcase" + } + + let model_json = + encode.encode_hydration_model(HydrationModel( + profile: data.profile, + plays: data.recent_plays, + repos: data.repos, + )) + + let dynamic_sections = + div([id("dynamic-sections")], [ + dynamic.dynamic_sections( + data.profile, + data.recent_plays, + data.plays_stats, + list.map(data.repos, fn(record) { record.value }), + ), + ]) + + // The client re-fetches the profile on page load and re-renders it + // with the PDS's remote avatar/banner URLs; this map lets it point + // those at the local mirrors instead. + let rewrites_script = + script( + [type_("application/json"), id("image-rewrites")], + encode_rewrites(rewrites), + ) + + let content = + fragment([ + rewrites_script, + dynamic_sections, + div([class("section")], [ + div([class("section-header")], [ + h2([], [text("Articles")]), + ]), + post_view.render_list(data.posts), + ]), + ]) + + let meta = + layout.Meta( + description: description, + image: og_image, + url: site_url <> "/", + logo: option_map(data.profile.avatar, fn(img) { + absolutize_img(img, site_url) + }), + page_type: layout.Website, + ) + + layout.page("~/kar", site_url, model_json, content, meta) +} + +/// A single article page. +pub fn post_page( + post: Post, + profile: ProfileViewDetailed, + site_url: String, +) -> Element(Nil) { + let og_image: Option(String) = case post.image { + "" -> option_map(profile.avatar, fn(img) { absolutize_img(img, site_url) }) + img -> Some(og_image_for_post(post.slug, img, site_url)) + } + + let meta = + layout.Meta( + description: post.description, + image: og_image, + url: site_url <> "/posts/" <> post.slug <> "/", + logo: profile.avatar, + page_type: layout.Article(published_time: post.date, tags: post.tags), + ) + + layout.page( + post.title <> " - Kar", + site_url, + "", + post_view.render_single(post), + meta, + ) +} + +// --- helpers --- + +/// OG/Twitter image tags must be absolute URLs for crawlers; the +/// mirrored images are root-relative paths. +fn absolutize_img(img: String, site_url: String) -> String { + case string.starts_with(img, "/") { + True -> site_url <> img + False -> img + } +} + +/// Resolve a post's `image:` frontmatter value to an absolute URL for +/// OG meta. Reuses the same path logic as the article ``. +fn og_image_for_post(slug: String, img: String, site_url: String) -> String { + let path = post_view.resolve_image_url(slug, img) + case string.starts_with(path, "/") { + True -> site_url <> path + False -> path + } +} + +/// The remote→local rewrite map as a JSON object, embedded in +/// `#image-rewrites` for the client (client/browser_ffi.mjs). +fn encode_rewrites(rewrites: List(#(String, String))) -> String { + rewrites + |> list.map(fn(pair) { + let #(remote, local) = pair + #(remote, json.string(local)) + }) + |> json.object + |> json.to_string +} diff --git a/src/view/layout.gleam b/src/view/layout.gleam --- a/src/view/layout.gleam +++ b/src/view/layout.gleam @@ -1,4 +1,4 @@ -import api +import data/model.{type Post} import date import gleam/list import gleam/option.{type Option, None, Some} @@ -25,14 +25,18 @@ ) } +/// Render the full document shell. `site_url` is threaded in (not +/// read from the environment) so the whole view layer is pure and +/// testable with a fixed URL. pub fn page( + site_url: String, title: String, model_json: String, content: Element(Nil), meta: Meta, ) -> Element(Nil) { html.html([attribute.lang("en")], [ - html.head([], head_children(title, model_json, meta)), + html.head([], head_children(site_url, title, model_json, meta)), html.body([], [ html.div([attribute.id("content")], [ nav_bar(), @@ -44,6 +48,7 @@ } fn head_children( + site_url: String, title: String, model_json: String, meta: Meta, @@ -64,7 +69,7 @@ // Open Graph html.meta([ attribute.attribute("property", "og:site_name"), - attribute.content(string.replace(api.site_url(), "https://", "")), + attribute.content(string.replace(site_url, "https://", "")), ]), html.meta([ attribute.attribute("property", "og:type"), @@ -353,26 +358,24 @@ ) } -pub fn rss_feed(posts: List(#(String, String, String, String))) -> String { - let items = - list.map(posts, fn(t) { - let #(title, description, slug, date_str) = t - " - " <> title <> " - " <> description <> " - " <> api.site_url() <> "/posts/" <> slug <> "/ - " <> date.to_rfc822(date_str) <> " - " - }) +/// Render the RSS feed. `site_url` is threaded in so absolute links +/// are correct in local previews (`BLOG_URL=http://localhost:8000`). +pub fn rss_feed(posts: List(Post), site_url: String) -> String { + let items = list.map(posts, fn(post) { " + " <> post.title <> " + " <> post.description <> " + " <> site_url <> "/posts/" <> post.slug <> "/ + " <> date.to_rfc822(post.date) <> " + " }) " Karitham's Thoughts - " <> api.site_url() <> " + " <> site_url <> " Kar's thoughts en - api.site_url() <> "/rss.xml\" rel=\"self\" type=\"application/rss+xml\"/> + site_url <> "/rss.xml\" rel=\"self\" type=\"application/rss+xml\"/> " <> string.join(items, "\n") <> " " diff --git a/test/cli/slug_test.gleam b/test/cli/slug_test.gleam new file mode 100644 --- /dev/null +++ b/test/cli/slug_test.gleam @@ -0,0 +1,60 @@ +import cli/slug +import gleam/list +import gleeunit/should + +pub fn slugify_simple_test() { + slug.slugify("hello") |> should.equal("hello") +} + +pub fn slugify_lowercases_test() { + slug.slugify("Hello") |> should.equal("hello") +} + +pub fn slugify_joins_spaces_test() { + slug.slugify("New blog ayo whos this") + |> should.equal("new-blog-ayo-whos-this") +} + +pub fn slugify_replaces_punctuation_test() { + slug.slugify("Hello, World!") |> should.equal("hello-world") +} + +pub fn slugify_underscores_become_dashes_test() { + slug.slugify("hello_world") |> should.equal("hello-world") +} + +pub fn slugify_collapses_runs_of_dashes_test() { + slug.slugify("foo---bar") |> should.equal("foo-bar") + slug.slugify("a !! b") |> should.equal("a-b") +} + +pub fn slugify_trims_leading_and_trailing_dashes_test() { + slug.slugify("---hello---") |> should.equal("hello") + slug.slugify("!hello!") |> should.equal("hello") +} + +pub fn slugify_preserves_existing_dashes_test() { + slug.slugify("my-post") |> should.equal("my-post") +} + +pub fn slugify_empty_input_test() { + slug.slugify("") |> should.equal("") +} + +pub fn slugify_only_punctuation_test() { + slug.slugify("!!!") |> should.equal("") +} + +pub fn slugify_already_valid_test() { + // Idempotency: a valid slug should be its own slugify output. + let cases = ["hello", "hello-world", "post-1", "a", "2024-07-18"] + list.each(cases, fn(s) { slug.slugify(s) |> should.equal(s) }) +} + +pub fn title_from_slug_capitalizes_words_test() { + slug.title_from_slug("hello-world") |> should.equal("Hello World") +} + +pub fn title_from_slug_empty_test() { + slug.title_from_slug("") |> should.equal("") +} diff --git a/test/data/image_ext_test.gleam b/test/data/image_ext_test.gleam new file mode 100644 --- /dev/null +++ b/test/data/image_ext_test.gleam @@ -0,0 +1,29 @@ +import data/image_ext +import gleeunit/should + +pub fn ext_png_test() { + image_ext.ext_for_content_type("image/png") |> should.equal("png") +} + +pub fn ext_webp_test() { + image_ext.ext_for_content_type("image/webp") |> should.equal("webp") +} + +pub fn ext_gif_test() { + image_ext.ext_for_content_type("image/gif") |> should.equal("gif") +} + +pub fn ext_unknown_falls_back_to_jpg_test() { + image_ext.ext_for_content_type("application/octet-stream") + |> should.equal("jpg") +} + +pub fn ext_uppercase_is_lowercased_test() { + image_ext.ext_for_content_type("image/PNG") |> should.equal("png") +} + +pub fn ext_svg_falls_back_test() { + // SVG files are served but deliberately not mirrored with an .svg + // extension; the default jpg keeps the old filename scheme stable. + image_ext.ext_for_content_type("image/svg+xml") |> should.equal("jpg") +} diff --git a/test/data/images_test.gleam b/test/data/images_test.gleam new file mode 100644 --- /dev/null +++ b/test/data/images_test.gleam @@ -0,0 +1,95 @@ +import data/images +import gen/actor/defs.{type ProfileViewDetailed, ProfileViewDetailed} +import gleam/option.{type Option, None, Some} +import gleeunit/should +import simplifile + +fn profile( + avatar: Option(String), + banner: Option(String), +) -> ProfileViewDetailed { + ProfileViewDetailed( + did: "did:plc:test", + handle: "test.bsky.social", + avatar: avatar, + banner: banner, + display_name: None, + description: None, + followers_count: None, + follows_count: None, + posts_count: None, + pronouns: None, + ) +} + +fn ok_write( + _path: String, + _bits: BitArray, +) -> Result(Nil, simplifile.FileError) { + Ok(Nil) +} + +fn failing_write( + _path: String, + _bits: BitArray, +) -> Result(Nil, simplifile.FileError) { + Error(simplifile.Eio) +} + +fn good_fetch(url: String) -> Result(#(BitArray, String), String) { + case url { + "https://example.com/avatar" -> Ok(#(<<"avatar-bits">>, "image/png")) + "https://example.com/banner" -> Ok(#(<<"banner-bits">>, "image/webp")) + _ -> Error("unexpected url: " <> url) + } +} + +pub fn mirror_downloads_and_rewrites_both_images_test() { + let result = + images.mirror_profile_images( + profile( + Some("https://example.com/avatar"), + Some("https://example.com/banner"), + ), + good_fetch, + ok_write, + ) + result.profile.avatar |> should.equal(Some("/img/profile/avatar.png")) + result.profile.banner |> should.equal(Some("/img/profile/banner.webp")) + result.rewrites + |> should.equal([ + #("https://example.com/avatar", "/img/profile/avatar.png"), + #("https://example.com/banner", "/img/profile/banner.webp"), + ]) +} + +pub fn mirror_keeps_remote_url_when_fetch_fails_test() { + let fetch = fn(_url: String) { Error("network down") } + let result = + images.mirror_profile_images( + profile(Some("https://example.com/avatar"), None), + fetch, + ok_write, + ) + result.profile.avatar |> should.equal(Some("https://example.com/avatar")) + result.rewrites |> should.equal([]) +} + +pub fn mirror_keeps_remote_url_when_write_fails_test() { + let result = + images.mirror_profile_images( + profile(Some("https://example.com/avatar"), None), + good_fetch, + failing_write, + ) + result.profile.avatar |> should.equal(Some("https://example.com/avatar")) + result.rewrites |> should.equal([]) +} + +pub fn mirror_with_no_images_has_no_rewrites_test() { + let result = + images.mirror_profile_images(profile(None, None), good_fetch, ok_write) + result.profile.avatar |> should.equal(None) + result.profile.banner |> should.equal(None) + result.rewrites |> should.equal([]) +} diff --git a/test/data/sources_test.gleam b/test/data/sources_test.gleam new file mode 100644 --- /dev/null +++ b/test/data/sources_test.gleam @@ -0,0 +1,74 @@ +import atproto +import data/sources +import gleam/list +import gleeunit/should + +fn profile_body() -> String { + "{\"did\":\"did:plc:test\",\"handle\":\"test.bsky.social\"}" +} + +fn plays_body() -> String { + "{\"records\":[{ + \"cid\": \"bafy1\", + \"uri\": \"at://did:plc:test/fm.teal.alpha.feed.play/abc\", + \"value\": { + \"artists\": [{\"artistName\": \"Artist A\"}], + \"playedTime\": \"2026-07-18T10:00:00Z\", + \"trackName\": \"Track A\" + } + }]}" +} + +fn empty_records_body() -> String { + "{\"records\":[]}" +} + +/// A stub http_get that returns canned bodies per endpoint. +fn good_stub(url: String) -> Result(String, String) { + let profile = atproto.profile_url() + let plays = atproto.plays_url() + let pinned = atproto.pinned_dids_url() + let repos = atproto.repos_url() + case url { + u if u == profile -> Ok(profile_body()) + u if u == plays -> Ok(plays_body()) + u if u == pinned -> Ok(empty_records_body()) + u if u == repos -> Ok(empty_records_body()) + _ -> Error("unexpected url: " <> url) + } +} + +/// A stub where everything except the profile fails like a dead PDS. +fn failing_stub(url: String) -> Result(String, String) { + let profile = atproto.profile_url() + case url { + u if u == profile -> Ok(profile_body()) + _ -> Error("network down") + } +} + +pub fn fetch_all_decodes_all_sections_test() { + let data = sources.fetch_all(good_stub) + data.profile.handle |> should.equal("test.bsky.social") + data.recent_plays |> list.length |> should.equal(1) + data.repos |> should.equal([]) +} + +pub fn fetch_all_falls_back_on_section_failure_test() { + let data = sources.fetch_all(failing_stub) + data.profile.handle |> should.equal("test.bsky.social") + data.recent_plays |> should.equal([]) + data.repos |> should.equal([]) +} + +pub fn plays_from_body_decodes_records_test() { + sources.plays_from_body(plays_body()) |> list.length |> should.equal(1) +} + +pub fn plays_from_body_empty_on_bad_json_test() { + sources.plays_from_body("not json") |> should.equal([]) +} + +pub fn plays_from_body_empty_on_empty_records_test() { + sources.plays_from_body(empty_records_body()) |> should.equal([]) +} diff --git a/test/data/transport_core_test.gleam b/test/data/transport_core_test.gleam new file mode 100644 --- /dev/null +++ b/test/data/transport_core_test.gleam @@ -0,0 +1,47 @@ +import data/transport/core +import gleeunit/should + +// --- classify_status --- + +pub fn classify_429_is_transient_test() { + core.classify_status(429, "rate limited") + |> should.equal(Error(core.Transient("rate limited"))) +} + +pub fn classify_500_is_transient_test() { + core.classify_status(500, "boom") + |> should.equal(Error(core.Transient("boom"))) +} + +pub fn classify_503_is_transient_test() { + core.classify_status(503, "unavailable") + |> should.equal(Error(core.Transient("unavailable"))) +} + +pub fn classify_404_is_permanent_test() { + core.classify_status(404, "not found") + |> should.equal(Error(core.Permanent("not found"))) +} + +pub fn classify_400_is_permanent_test() { + core.classify_status(400, "bad request") + |> should.equal(Error(core.Permanent("bad request"))) +} + +// --- decide --- + +pub fn decide_success_succeeds_test() { + core.decide(Ok("ok"), 3) |> should.equal(core.Succeeded("ok")) +} + +pub fn decide_permanent_gives_up_immediately_test() { + core.decide(Error(core.Permanent("p")), 3) |> should.equal(core.GivenUp("p")) +} + +pub fn decide_transient_retries_while_attempts_remain_test() { + core.decide(Error(core.Transient("t")), 3) |> should.equal(core.Retry("t")) +} + +pub fn decide_transient_gives_up_on_last_attempt_test() { + core.decide(Error(core.Transient("t")), 1) |> should.equal(core.GivenUp("t")) +} diff --git a/test/render/page_test.gleam b/test/render/page_test.gleam new file mode 100644 --- /dev/null +++ b/test/render/page_test.gleam @@ -0,0 +1,90 @@ +import data/model.{type Post, type SiteData, Post, SiteData} +import gen/actor/defs.{type ProfileViewDetailed, ProfileViewDetailed} +import gleam/option.{None} +import gleam/string +import gleeunit/should +import lustre/element.{to_string} +import render/page +import stats + +fn sample_profile() -> ProfileViewDetailed { + ProfileViewDetailed( + did: "did:plc:test", + handle: "test.bsky.social", + display_name: None, + description: None, + avatar: None, + banner: None, + followers_count: None, + follows_count: None, + posts_count: None, + pronouns: None, + ) +} + +fn sample_post() -> Post { + Post( + title: "My Post", + description: "A short summary", + slug: "my-post", + date: "2026-07-18", + content: "

hello

", + tags: ["gleam"], + draft: False, + image: "", + ) +} + +fn sample_data() -> SiteData { + SiteData( + profile: sample_profile(), + recent_plays: [], + plays_stats: stats.empty_stats(), + repos: [], + posts: [sample_post()], + ) +} + +pub fn index_page_renders_articles_and_post_test() { + let html = + page.index_page(sample_data(), "https://karitham.dev", []) + |> to_string + string.contains(html, "Articles") |> should.be_true() + string.contains(html, "My Post") |> should.be_true() + string.contains(html, "og:site_name") |> should.be_true() +} + +pub fn index_page_embeds_rewrites_script_test() { + let html = + page.index_page(sample_data(), "https://karitham.dev", [ + #("https://remote/avatar.jpg", "/img/profile/avatar.jpg"), + ]) + |> to_string + string.contains(html, "image-rewrites") |> should.be_true() + string.contains(html, "https://remote/avatar.jpg") |> should.be_true() +} + +pub fn post_page_uses_site_url_for_og_url_test() { + let html = + page.post_page(sample_post(), sample_profile(), "https://example.com") + |> to_string + string.contains(html, "https://example.com/posts/my-post/") + |> should.be_true() +} + +pub fn post_page_absolutizes_post_image_for_og_test() { + let post = Post(..sample_post(), image: "hero.png") + let html = + page.post_page(post, sample_profile(), "https://karitham.dev") + |> to_string + string.contains(html, "https://karitham.dev/posts/my-post/hero.png") + |> should.be_true() +} + +pub fn post_page_no_image_falls_back_to_avatar_none_test() { + // No avatar in the profile and no post image: og:image is absent. + let html = + page.post_page(sample_post(), sample_profile(), "https://karitham.dev") + |> to_string + string.contains(html, "property=\"og:image\"") |> should.be_false() +} diff --git a/test/view/layout_test.gleam b/test/view/layout_test.gleam new file mode 100644 --- /dev/null +++ b/test/view/layout_test.gleam @@ -0,0 +1,81 @@ +import data/model.{Post} +import gleam/option.{None} +import gleam/string +import gleeunit/should +import lustre/element.{to_string} +import lustre/element/html.{div} +import view/layout + +pub fn page_uses_site_url_for_og_site_name_test() { + let meta = + layout.Meta( + description: "desc", + image: None, + url: "https://karitham.dev/", + logo: None, + page_type: layout.Website, + ) + let html = + layout.page("https://karitham.dev", "Title", "{}", div([], []), meta) + |> to_string + string.contains(html, "og:site_name") |> should.be_true() + string.contains(html, "karitham.dev") |> should.be_true() +} + +pub fn page_uses_local_site_url_in_preview_test() { + let meta = + layout.Meta( + description: "desc", + image: None, + url: "http://localhost:8000/", + logo: None, + page_type: layout.Website, + ) + let html = + layout.page("http://localhost:8000", "Title", "{}", div([], []), meta) + |> to_string + // The site name strips the scheme, whatever it is — the preview URL + // must end up in og:site_name. (The nav brand is hardcoded to the + // production handle, so don't assert on karitham.dev absence.) + string.contains(html, "localhost:8000") |> should.be_true() +} + +pub fn rss_feed_links_are_absolute_with_site_url_test() { + let post = + Post( + title: "My Post", + description: "A short summary", + slug: "my-post", + date: "2024-09-21", + content: "

hi

", + tags: [], + draft: False, + image: "", + ) + let rss = layout.rss_feed([post], "https://karitham.dev") + string.contains(rss, "https://karitham.dev/posts/my-post/") + |> should.be_true() + string.contains(rss, "My Post") |> should.be_true() +} + +pub fn rss_feed_preview_uses_localhost_test() { + let post = + Post( + title: "T", + description: "", + slug: "t", + date: "2024-09-21", + content: "", + tags: [], + draft: False, + image: "", + ) + let rss = layout.rss_feed([post], "http://localhost:8000") + string.contains(rss, "http://localhost:8000/posts/t/") |> should.be_true() + string.contains(rss, "karitham.dev") |> should.be_false() +} + +pub fn rss_feed_empty_posts_test() { + let rss = layout.rss_feed([], "https://karitham.dev") + string.contains(rss, "") |> should.be_true() +} diff --git a/tests/fixtures/plays-stats.min.json b/tests/fixtures/plays-stats.min.json new file mode 100644 --- /dev/null +++ b/tests/fixtures/plays-stats.min.json @@ -0,0 +1,90 @@ +{ + "ranges": { + "1m": { + "albums": [ + { + "artist": "Artist A", + "ms_played": 1000, + "name": "Album A", + "plays": 1 + } + ], + "artists": [{ "ms_played": 1000, "name": "Artist A", "plays": 1 }], + "tracks": [ + { "artist": "Artist A", "ms_played": 1000, "name": "Alpha", "plays": 1 } + ] + }, + "6m": { + "albums": [ + { + "artist": "Artist B", + "ms_played": 3000, + "name": "Album B", + "plays": 1 + }, + { + "artist": "Artist A", + "ms_played": 1000, + "name": "Album A", + "plays": 1 + } + ], + "artists": [ + { "ms_played": 3000, "name": "Artist B", "plays": 1 }, + { "ms_played": 1000, "name": "Artist A", "plays": 1 } + ], + "tracks": [ + { "artist": "Artist B", "ms_played": 3000, "name": "Beta", "plays": 1 }, + { "artist": "Artist A", "ms_played": 1000, "name": "Alpha", "plays": 1 } + ] + }, + "1y": { + "albums": [ + { + "artist": "Artist B", + "ms_played": 3000, + "name": "Album B", + "plays": 1 + }, + { + "artist": "Artist A", + "ms_played": 1000, + "name": "Album A", + "plays": 1 + } + ], + "artists": [ + { "ms_played": 3000, "name": "Artist B", "plays": 1 }, + { "ms_played": 1000, "name": "Artist A", "plays": 1 } + ], + "tracks": [ + { "artist": "Artist B", "ms_played": 3000, "name": "Beta", "plays": 1 }, + { "artist": "Artist A", "ms_played": 1000, "name": "Alpha", "plays": 1 } + ] + }, + "all": { + "albums": [ + { + "artist": "Artist B", + "ms_played": 3000, + "name": "Album B", + "plays": 1 + }, + { + "artist": "Artist A", + "ms_played": 1000, + "name": "Album A", + "plays": 1 + } + ], + "artists": [ + { "ms_played": 3000, "name": "Artist B", "plays": 1 }, + { "ms_played": 1000, "name": "Artist A", "plays": 1 } + ], + "tracks": [ + { "artist": "Artist B", "ms_played": 3000, "name": "Beta", "plays": 1 }, + { "artist": "Artist A", "ms_played": 1000, "name": "Alpha", "plays": 1 } + ] + } + } +} diff --git a/src/data/transport/core.gleam b/src/data/transport/core.gleam new file mode 100644 --- /dev/null +++ b/src/data/transport/core.gleam @@ -0,0 +1,46 @@ +//// Pure retry classification for the HTTP transport. +//// +//// `transport.gleam` performs the actual requests and sleeps between +//// retries; this module decides whether a result is worth retrying so +//// the decision tree is unit-testable without a network. + +/// Retryable vs permanent failure. Transport errors and 429/5xx are +/// transient; 4xx and invalid URLs are permanent. +pub type HttpError { + Transient(String) + Permanent(String) +} + +/// What the retry loop should do after one attempt. +pub type Outcome(a) { + Succeeded(a) + GivenUp(String) + Retry(String) +} + +/// Classify a non-2xx HTTP status into transient/permanent. 429 and +/// 5xx are rate-limited/server failures worth retrying; everything +/// else (404, 400, ...) is permanent. +pub fn classify_status(status: Int, reason: String) -> Result(a, HttpError) { + case status == 429 || status >= 500 { + True -> Error(Transient(reason)) + False -> Error(Permanent(reason)) + } +} + +/// Given one attempt's result and the number of attempts remaining +/// (including this one), decide what the retry loop should do. +pub fn decide( + result: Result(a, HttpError), + attempts_remaining: Int, +) -> Outcome(a) { + case result { + Ok(value) -> Succeeded(value) + Error(Permanent(reason)) -> GivenUp(reason) + Error(Transient(reason)) -> + case attempts_remaining <= 1 { + True -> GivenUp(reason) + False -> Retry(reason) + } + } +} diff --git a/src/view/components/post_view.gleam b/src/view/components/post_view.gleam --- a/src/view/components/post_view.gleam +++ b/src/view/components/post_view.gleam @@ -62,7 +62,11 @@ ]) } -fn resolve_image_url(slug: String, img: String) -> String { +/// Resolve a post's `image:` frontmatter value to a URL usable in an +/// `` tag: absolute URLs pass through, relative paths resolve +/// against `/posts//`. Shared with `render/page.gleam` so the +/// article hero and the OG meta never disagree. +pub fn resolve_image_url(slug: String, img: String) -> String { case string.starts_with(img, "http://") || string.starts_with(img, "https://") { diff --git a/test/view/components/post_view_test.gleam b/test/view/components/post_view_test.gleam new file mode 100644 --- /dev/null +++ b/test/view/components/post_view_test.gleam @@ -0,0 +1,56 @@ +import data/model.{type Post, Post} +import gleam/string +import gleeunit/should +import lustre/element.{to_string} +import view/components/post_view + +fn sample_post() -> Post { + Post( + title: "My Post", + description: "A short summary", + slug: "my-post", + date: "2026-07-18", + content: "

hello

", + tags: ["gleam"], + draft: False, + image: "", + ) +} + +pub fn render_single_includes_title_test() { + let html = post_view.render_single(sample_post()) |> to_string + string.contains(html, "My Post") |> should.be_true() + string.contains(html, "Written") |> should.be_true() +} + +pub fn render_list_empty_test() { + let html = post_view.render_list([]) |> to_string + string.contains(html, "No articles yet.") |> should.be_true() +} + +pub fn render_list_renders_cards_test() { + let html = post_view.render_list([sample_post()]) |> to_string + string.contains(html, "My Post") |> should.be_true() + string.contains(html, "/posts/my-post/") |> should.be_true() +} + +pub fn render_single_hero_image_uses_resolved_url_test() { + let post = Post(..sample_post(), image: "hero.png") + let html = post_view.render_single(post) |> to_string + string.contains(html, "/posts/my-post/hero.png") |> should.be_true() +} + +pub fn resolve_image_url_passes_absolute_urls_test() { + post_view.resolve_image_url("my-post", "https://example.com/x.png") + |> should.equal("https://example.com/x.png") +} + +pub fn resolve_image_url_resolves_relative_to_post_dir_test() { + post_view.resolve_image_url("my-post", "hero.png") + |> should.equal("/posts/my-post/hero.png") +} + +pub fn resolve_image_url_resolves_dot_relative_test() { + post_view.resolve_image_url("my-post", "./diagram.png") + |> should.equal("/posts/my-post/diagram.png") +} diff --git a/tools/parse-plays/src/stats.rs b/tools/parse-plays/src/stats.rs --- a/tools/parse-plays/src/stats.rs +++ b/tools/parse-plays/src/stats.rs @@ -693,4 +693,31 @@ assert_eq!(normalize("JAY-Z"), normalize("Jay Z")); assert_eq!(normalize_name(" Animals "), "animals"); } + + #[test] + fn cross_language_contract_fixture_matches() { + // The Gleam decoder (shared/src/stats.gleam) and this Rust + // emitter must agree on the plays-stats.json shape. Both sides + // pin the shared golden file tests/fixtures/plays-stats.min.json: + // if the shape drifts on either side, one of the two tests + // fails. See blog/test/stats_contract_test.gleam. + let fixture = std::fs::read_to_string(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../tests/fixtures/plays-stats.min.json" + )) + .expect("golden fixture missing — run `cargo test` from tools/parse-plays"); + let expected: serde_json::Value = serde_json::from_str(&fixture).unwrap(); + + let today = chrono::NaiveDate::from_ymd_opt(2026, 8, 3).unwrap(); + let plays = vec![ + play("Alpha", "Artist A", "Album A", "2026-07-20T10:00:00Z", 1000), + play("Beta", "Artist B", "Album B", "2026-02-10T10:00:00Z", 3000), + ]; + let agg = aggregate(&plays, today); + // main.rs wraps the ranges map in {"ranges": ...} before + // writing the file; pin that same file shape here. + let actual = json!({ "ranges": build_ranges(&agg, &ResolvedStats::default()) }); + + assert_eq!(actual, expected); + } }