From d864302bb377d19e6ba5df33abd52d1461ba1b44 Mon Sep 17 00:00:00 2001 From: karitham Date: Sun, 30 Aug 2026 23:10:33 +0200 Subject: [PATCH] client: replace imperative innerHTML client with Lustre islands The old client justified skipping MVU with a claim that async effect.from dispatch is unreliable on the JS target. That claim is false on Lustre 5.7.1 (verified statically against the shipped runtime and empirically under Node), so the client is now three lustre.application islands mounting on the server-rendered #profile-section, #plays-rows, and #repos. - hydration: #site-model now carries the remote->local image rewrite map alongside the build-time model; islands take it as flags so their first render matches the SSG markup. The separate #image-rewrites tag and the document-wide img rewrite pass are gone; profile images are localized as data before render. - pipeline/commit and the browser set_inner_html/set_attribute FFIs are deleted; DOM ids are exported from shared so markup and selectors can't drift. - data-stale moved to a wrapper the plays island owns; the CSS pulse dot keys off .section:has([data-stale="true"]). - the client bundle is now a single minified self-executing module from lustre/dev build (87KB) instead of a whitelisted copy of the dev tree; the client lockfile regeneration also dropped five unused crypto packages. --- .gitignore | 1 + README.md | 18 +- client/gleam.toml | 1 + client/manifest.toml | 207 +++++++++++++++--- client/src/app.gleam | 171 +++++---------- client/src/browser.gleam | 34 +-- client/src/browser_ffi.mjs | 68 ++---- client/src/commit.gleam | 11 - ...lient.gleam => karitham_blog_client.gleam} | 0 client/src/pipeline.gleam | 54 ----- client/src/plays_island.gleam | 139 ++++++++++++ client/src/profile_island.gleam | 93 ++++++++ client/src/repos_island.gleam | 105 +++++++++ client/test/islands_test.gleam | 187 ++++++++++++++++ client/test/pipeline_test.gleam | 83 ------- justfile | 7 +- priv/static/style.css | 2 +- shared/src/decode.gleam | 31 +++ shared/src/dynamic.gleam | 43 ---- shared/src/encode.gleam | 54 +++++ shared/src/hydration.gleam | 48 ++++ shared/src/plays.gleam | 29 ++- shared/src/profile.gleam | 44 ++-- shared/src/repos.gleam | 31 ++- shared/test/dynamic_test.gleam | 45 ---- shared/test/encode_test.gleam | 163 ++++++++++++++ shared/test/hydration_test.gleam | 53 +++++ shared/test/plays_test.gleam | 39 ++-- src/build.gleam | 86 +++----- src/cli.gleam | 31 +-- src/data/images.gleam | 17 +- src/karitham_blog.gleam | 3 +- src/render/page.gleam | 74 +++---- src/view/layout.gleam | 25 ++- test/render/page_test.gleam | 7 +- test/view/layout_test.gleam | 4 +- tools/parse-plays/src/images.rs | 5 +- tools/parse-plays/src/testutil.rs | 4 +- 38 files changed, 1354 insertions(+), 663 deletions(-) delete mode 100644 client/src/commit.gleam rename client/src/{client.gleam => karitham_blog_client.gleam} (100%) delete mode 100644 client/src/pipeline.gleam create mode 100644 client/src/plays_island.gleam create mode 100644 client/src/profile_island.gleam create mode 100644 client/src/repos_island.gleam create mode 100644 client/test/islands_test.gleam delete mode 100644 client/test/pipeline_test.gleam create mode 100644 shared/src/decode.gleam delete mode 100644 shared/src/dynamic.gleam create mode 100644 shared/src/encode.gleam create mode 100644 shared/src/hydration.gleam delete mode 100644 shared/test/dynamic_test.gleam create mode 100644 shared/test/encode_test.gleam create mode 100644 shared/test/hydration_test.gleam diff --git a/.gitignore b/.gitignore index 63065bb..52fe540 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ build/ dist/ priv/cache/ tools/*/target/ +client/.lustre/ result diff --git a/README.md b/README.md index 7929edf..56d069b 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # karitham.dev -Personal site. Gleam SSG that fetches from the AT Protocol (Bluesky PDS + Tangled) at build time, hydrates client-side with a Lustre component tree. Album covers, artist photos, and the profile avatar/banner are mirrored into the site at build time, so the visitor's browser never has to fetch from Cover Art Archive, Wikimedia, or the PDS directly — every image is served from `/img/...` on the site itself. +Personal site. Gleam SSG that fetches from the AT Protocol (Bluesky PDS + Tangled) at build time, then three small Lustre islands (profile, music, repos) mounted on the server-rendered markup re-fetch fresh data and re-render client-side. Album covers, artist photos, and the profile avatar/banner are mirrored into the site at build time, so the visitor's browser never has to fetch from Cover Art Archive, Wikimedia, or the PDS directly — every image is served from `/img/...` on the site itself. ## Build @@ -77,17 +77,17 @@ error if something's wrong. Four components: -| Component | Role | -| ----------------------- | ----------------------------------------------------------------------------------------------------------------------- | -| **`shared/`** | Model types, generated decoders, view components (compiled to both Erlang and JS) | -| **root** | SSG — fetches data, mirrors images, renders, writes `dist/` | -| **`client/`** | Browser bundle — fetches fresh data on page load (profile images rewritten to the local mirrors), polls plays every 30s | -| **`tools/parse-plays`** | Rust CLI — CAR → play stats, cover/artist resolution, image mirroring | +| Component | Role | +| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **`shared/`** | Model types, generated decoders, view components, DOM id constants (compiled to both Erlang and JS) | +| **root** | SSG — fetches data, mirrors images, renders, writes `dist/` | +| **`client/`** | Three Lustre islands on the server-rendered markup — re-fetch on load and tab visibility, plays poll every 30s. `just client` builds one self-executing minified module via `lustre/dev build` | +| **`tools/parse-plays`** | Rust CLI — CAR → play stats, cover/artist resolution, image mirroring | ## Tests ```sh just test -# or: -gleam test && cd shared && gleam test +# or, per suite: +gleam test && (cd shared && gleam test) && (cd client && gleam test) ``` diff --git a/client/gleam.toml b/client/gleam.toml index b33d2f9..f07bf3f 100644 --- a/client/gleam.toml +++ b/client/gleam.toml @@ -10,3 +10,4 @@ shared = { path = "../shared" } [dev_dependencies] gleeunit = ">= 1.9.0 and < 2.0.0" +lustre_dev_tools = ">= 2.0.0 and < 3.0.0" diff --git a/client/manifest.toml b/client/manifest.toml index 2cddb44..cccf990 100644 --- a/client/manifest.toml +++ b/client/manifest.toml @@ -7,27 +7,50 @@ # You should check this file into your source control repository. packages = [ - { name = "atproto_client", version = "0.1.1", build_tools = [ + { name = "argv", version = "1.1.0", build_tools = [ "gleam", ], requirements = [ - "gleam_crypto", - "gleam_http", - "gleam_json", + ], otp_app = "argv", source = "hex", outer_checksum = "3277D100448BDB4A29B6D58C0F36F631CBC349E8BDD09766C6309DF202831140" }, + { name = "booklet", version = "1.1.0", build_tools = [ + "gleam", + ], requirements = [ + ], otp_app = "booklet", source = "hex", outer_checksum = "08E0FDB78DC4D8A5D3C80295B021505C7D2A2E7B6C6D5EAB7286C36F4A53C851" }, + { name = "directories", version = "1.2.0", build_tools = [ + "gleam", + ], requirements = [ + "envoy", "gleam_stdlib", - "gleam_time", - "gose", - "kryptos", - ], otp_app = "atproto_client", source = "hex", outer_checksum = "97A6C830B0CF6F3EB9BA6ECF92C7F4E57366919F417DA35FD325431C03B95E19" }, - { name = "bigi", version = "4.1.1", build_tools = [ + "platform", + "simplifile", + ], otp_app = "directories", source = "hex", outer_checksum = "D13090CFCDF6759B87217E8DDD73A75903A700148A82C1D33799F333E249BF9E" }, + { name = "envoy", version = "1.2.0", build_tools = [ "gleam", ], requirements = [ "gleam_stdlib", - ], otp_app = "bigi", source = "hex", outer_checksum = "44FEDF1DF5C1F426A80B67C32B976D0828D66524E11BDC2DD22B78D37C883BA7" }, + ], otp_app = "envoy", source = "hex", outer_checksum = "9C6FBB6BFA02A52798BEEC5977A738CAD6E4A057F4B67FD0C8061AD2502C191A" }, { name = "exception", version = "2.1.1", build_tools = [ "gleam", ], requirements = [ "gleam_stdlib", ], otp_app = "exception", source = "hex", outer_checksum = "6BDEA95248093599391C3B5DF1835C5C6A86C353C2F99CE539B450E3432FE117" }, + { name = "filepath", version = "1.1.2", build_tools = [ + "gleam", + ], requirements = [ + "gleam_stdlib", + ], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" }, + { name = "gleam_community_ansi", version = "1.5.0", build_tools = [ + "gleam", + ], requirements = [ + "gleam_community_colour", + "gleam_regexp", + "gleam_stdlib", + ], otp_app = "gleam_community_ansi", source = "hex", outer_checksum = "B5AA433AF84313E23FDF90CCFF752B9380FE9FFCE02B2949D49B7AACCC77B16D" }, + { name = "gleam_community_colour", version = "2.0.4", build_tools = [ + "gleam", + ], requirements = [ + "gleam_json", + "gleam_stdlib", + ], otp_app = "gleam_community_colour", source = "hex", outer_checksum = "6DB4665555D7D2B27F0EA32EF47E8BEBC4303821765F9C73D483F38EE24894F0" }, { name = "gleam_crypto", version = "1.6.0", build_tools = [ "gleam", ], requirements = [ @@ -43,50 +66,92 @@ packages = [ ], requirements = [ "gleam_stdlib", ], otp_app = "gleam_http", source = "hex", outer_checksum = "82EA6A717C842456188C190AFB372665EA56CE13D8559BF3B1DD9E40F619EE0C" }, + { name = "gleam_httpc", version = "5.0.0", build_tools = [ + "gleam", + ], requirements = [ + "gleam_erlang", + "gleam_http", + "gleam_stdlib", + ], otp_app = "gleam_httpc", source = "hex", outer_checksum = "C545172618D07811494E97AAA4A0FB34DA6F6D0061FDC8041C2F8E3BE2B2E48F" }, { name = "gleam_json", version = "3.1.0", build_tools = [ "gleam", ], requirements = [ "gleam_stdlib", ], otp_app = "gleam_json", source = "hex", outer_checksum = "44FDAA8847BE8FC48CA7A1C089706BD54BADCC4C45B237A992EDDF9F2CDB2836" }, - { name = "gleam_otp", version = "1.2.0", build_tools = [ + { name = "gleam_otp", version = "1.3.0", build_tools = [ "gleam", ], requirements = [ "gleam_erlang", "gleam_stdlib", - ], otp_app = "gleam_otp", source = "hex", outer_checksum = "BA6A294E295E428EC1562DC1C11EA7530DCB981E8359134BEABC8493B7B2258E" }, - { name = "gleam_stdlib", version = "1.0.3", build_tools = [ + ], otp_app = "gleam_otp", source = "hex", outer_checksum = "DE4CA6850842F0266EE95317A25DD6A0A0F20CDFAB7C0ADC2E63251D7C3C72EC" }, + { name = "gleam_regexp", version = "1.1.1", build_tools = [ "gleam", ], requirements = [ - ], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "1F543AFBA5D33DA493E6087F4E4C4F20D899411343512686C98A8ABB2963CF22" }, - { name = "gleam_time", version = "1.8.0", build_tools = [ + "gleam_stdlib", + ], otp_app = "gleam_regexp", source = "hex", outer_checksum = "9C215C6CA84A5B35BB934A9B61A9A306EC743153BE2B0425A0D032E477B062A9" }, + { name = "gleam_stdlib", version = "1.0.5", build_tools = [ + "gleam", + ], requirements = [ + ], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "CEE5B6C076A85B45F60C585F4316C63EC8B7127C119D5738C3958A9C4D50404E" }, + { name = "gleam_time", version = "1.10.0", build_tools = [ "gleam", ], requirements = [ "gleam_stdlib", - ], otp_app = "gleam_time", source = "hex", outer_checksum = "533D8723774D61AD4998324F5DD1DABDCDBFABAFB9E87CB5D03C6955448FC97D" }, + ], otp_app = "gleam_time", source = "hex", outer_checksum = "56539216E4C4B1748714652AB38F0BD16B9101F61DB62769FDC7CD42A8E5E833" }, { name = "gleeunit", version = "1.11.0", build_tools = [ "gleam", ], requirements = [ "gleam_stdlib", ], otp_app = "gleeunit", source = "hex", outer_checksum = "EC31ABA74256AEA531EDF8169931D775BBB384FED0A8A1BDC4DD9354E3E21826" }, - { name = "gose", version = "2.1.0", build_tools = [ + { name = "glint", version = "1.3.0", build_tools = [ "gleam", ], requirements = [ - "gleam_json", + "gleam_community_ansi", + "gleam_community_colour", "gleam_stdlib", - "gleam_time", - "kryptos", - ], otp_app = "gose", source = "hex", outer_checksum = "F1F172D428A07BC6F3229F8363B85E6E8C79A5E9E28F27B07C2EFBF1F6B08114" }, + "snag", + ], otp_app = "glint", source = "hex", outer_checksum = "26CCA9BC3ACB56CD9D754ACF598F49B4C281C4FDD81AE61FA21C8E92E9C8218E" }, + { name = "glisten", version = "9.0.1", build_tools = [ + "gleam", + ], requirements = [ + "gleam_erlang", + "gleam_otp", + "gleam_stdlib", + "logging", + ], otp_app = "glisten", source = "hex", outer_checksum = "7795AA50830656F3A0316A6B26595F893C83272DA901B3405E31339CAA31A10B" }, + { name = "gramps", version = "6.0.1", build_tools = [ + "gleam", + ], requirements = [ + "gleam_crypto", + "gleam_erlang", + "gleam_http", + "gleam_stdlib", + ], otp_app = "gramps", source = "hex", outer_checksum = "D55636072DEE173F6586A5679D3C02EC7A0DE3F8646B78C351B72908FF223DF7" }, + { name = "group_registry", version = "1.0.0", build_tools = [ + "gleam", + ], requirements = [ + "gleam_erlang", + "gleam_otp", + "gleam_stdlib", + ], otp_app = "group_registry", source = "hex", outer_checksum = "BC798A53D6F2406DB94E27CB45C57052CB56B32ACF7CC16EA20F6BAEC7E36B90" }, { name = "houdini", version = "1.2.1", build_tools = [ "gleam", ], requirements = [ ], otp_app = "houdini", source = "hex", outer_checksum = "6F8AC2F12974567FB744BEA66AC93CEB76AAEA19AD28564623F76CDA9BC26A85" }, - { name = "kryptos", version = "1.4.0", build_tools = [ + { name = "hpack_erl", version = "0.3.0", build_tools = [ + "rebar3", + ], requirements = [ + ], otp_app = "hpack", source = "hex", outer_checksum = "D6137D7079169D8C485C6962DFE261AF5B9EF60FBC557344511C1E65E3D95FB0" }, + { name = "justin", version = "1.1.0", build_tools = [ "gleam", ], requirements = [ - "bigi", "gleam_stdlib", - "gleam_time", - ], otp_app = "kryptos", source = "hex", outer_checksum = "734DAB837962A4A45788F8E6350EF4A6DF20A1F54734736CD1FF3A03FFCE4C97" }, + ], otp_app = "justin", source = "hex", outer_checksum = "8B1C62269E8607D0A0ED698B7903984CE0BF7B6B3C0DEA3C6C8302B39402837B" }, + { name = "logging", version = "1.5.0", build_tools = [ + "gleam", + ], requirements = [ + "gleam_stdlib", + ], otp_app = "logging", source = "hex", outer_checksum = "BC5F18CE5DD9686100229FE5409BDC3DD5C46D5A7DF2F804AD2D8F0DD6C5060E" }, { name = "lustre", version = "5.7.1", build_tools = [ "gleam", ], requirements = [ @@ -97,14 +162,103 @@ packages = [ "gleam_stdlib", "houdini", ], otp_app = "lustre", source = "hex", outer_checksum = "663A2D1A3458914CA537A42AC07E601DD3FD85415EBDAF4717F2DFF6E86F90F9" }, + { name = "lustre_dev_tools", version = "2.3.6", build_tools = [ + "gleam", + ], requirements = [ + "argv", + "booklet", + "filepath", + "gleam_community_ansi", + "gleam_crypto", + "gleam_erlang", + "gleam_http", + "gleam_httpc", + "gleam_json", + "gleam_otp", + "gleam_regexp", + "gleam_stdlib", + "glint", + "group_registry", + "justin", + "lustre", + "mist", + "polly", + "simplifile", + "tom", + "wisp", + ], otp_app = "lustre_dev_tools", source = "hex", outer_checksum = "7A650A107C7E767D902A8CB608460B927DE0CDD47D5BA04DED71F9F622FE6AD9" }, + { name = "marceau", version = "1.3.0", build_tools = [ + "gleam", + ], requirements = [ + ], otp_app = "marceau", source = "hex", outer_checksum = "2D1C27504BEF45005F5DFB18591F8610FB4BFA91744878210BDC464412EC44E9" }, + { name = "mist", version = "6.0.3", build_tools = [ + "gleam", + ], requirements = [ + "exception", + "gleam_erlang", + "gleam_http", + "gleam_otp", + "gleam_stdlib", + "glisten", + "gramps", + "hpack_erl", + "logging", + ], otp_app = "mist", source = "hex", outer_checksum = "1B07F321D5FA0CB162D81496F2DE96AEB6EF8980F4F38230A4CC3F849497E020" }, + { name = "platform", version = "1.0.0", build_tools = [ + "gleam", + ], requirements = [ + ], otp_app = "platform", source = "hex", outer_checksum = "8339420A95AD89AAC0F82F4C3DB8DD401041742D6C3F46132A8739F6AEB75391" }, + { name = "polly", version = "3.1.0", build_tools = [ + "gleam", + ], requirements = [ + "filepath", + "gleam_erlang", + "gleam_otp", + "gleam_stdlib", + "simplifile", + ], otp_app = "polly", source = "hex", outer_checksum = "51FB565D81FF6212FDF3306D44419601F2A7C4EDD1F00FC9DA5C376A00AED4FE" }, { name = "shared", version = "1.0.0", build_tools = [ "gleam", ], requirements = [ - "atproto_client", "gleam_json", "gleam_stdlib", + "gleam_time", "lustre", ], source = "local", path = "../shared" }, + { name = "simplifile", version = "2.7.0", build_tools = [ + "gleam", + ], requirements = [ + "filepath", + "gleam_stdlib", + ], otp_app = "simplifile", source = "hex", outer_checksum = "A2727627B063E87351934C7F7F008F2D1FDB16F6DE0B8C79F9E46459CFC9C164" }, + { name = "snag", version = "1.2.0", build_tools = [ + "gleam", + ], requirements = [ + "gleam_stdlib", + ], otp_app = "snag", source = "hex", outer_checksum = "274F41D6C3ECF99F7686FDCE54183333E41D2C1CA5A3A673F9A8B2C7A4401077" }, + { name = "tom", version = "2.1.0", build_tools = [ + "gleam", + ], requirements = [ + "gleam_stdlib", + "gleam_time", + ], otp_app = "tom", source = "hex", outer_checksum = "DCF04CB7AB35D58CFC598C66EA2E1816D160759802C89B2BA6238780D59BC256" }, + { name = "wisp", version = "2.2.2", build_tools = [ + "gleam", + ], requirements = [ + "directories", + "exception", + "filepath", + "gleam_crypto", + "gleam_erlang", + "gleam_http", + "gleam_json", + "gleam_stdlib", + "houdini", + "logging", + "marceau", + "mist", + "simplifile", + ], otp_app = "wisp", source = "hex", outer_checksum = "5FF5F1E288C3437252ABB93D8F9CF42FF652CE7AD54480CFE736038DC09C4F22" }, ] [requirements] @@ -112,4 +266,5 @@ gleam_json = { version = ">= 2.0.0 and < 4.0.0" } gleam_stdlib = { version = ">= 1.0.0 and < 2.0.0" } gleeunit = { version = ">= 1.9.0 and < 2.0.0" } lustre = { version = ">= 4.0.0 and < 6.0.0" } +lustre_dev_tools = { version = ">= 2.0.0 and < 3.0.0" } shared = { path = "../shared" } diff --git a/client/src/app.gleam b/client/src/app.gleam index 812e52d..5c55fb3 100644 --- a/client/src/app.gleam +++ b/client/src/app.gleam @@ -1,138 +1,65 @@ -//// Client-side orchestration — the impure interpreter. +//// Client entry: decode the hydration payload and start the islands. //// -//// 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. +//// The SSG embeds the build-time data as `#site-model`; each island +//// takes its slice as flags so its first render matches the server +//// markup, then re-fetches fresh data through its own effects. Pages +//// without dynamic sections (articles) start nothing. -import atproto import browser -import commit.{ - type Command, LocalizeDates, RemoveAttr, ReplaceHtml, RewriteRemoteImages, - SetAttr, -} -import gleam/int +import decode import gleam/list +import gleam/option.{None, Some} import gleam/string -import pipeline -import tangled +import plays as plays_view +import plays_island +import profile as profile_view +import profile_island +import repos as repos_view +import repos_island -/// Short enough that a new track shows up promptly, long enough that -/// we're not hammering the PDS. -const plays_poll_ms = 30_000 +const site_model_id = "site-model" -/// 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") { + case browser.has_element(profile_view.profile_section_id) { + // Article pages have no dynamic sections — nothing to hydrate. 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 = tangled.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 - |> tangled.filter_repos_by_did(pinned_dids) - |> list.map(tangled.resolve_repo_name) - |> list.map(fn(record) { record.value }) - commit(pipeline.plan_repos(repos)) - } + True -> + case browser.script_text(site_model_id) { + // The SSG always renders #site-model next to the sections it + // feeds; its absence means the markup drifted from here. + None -> + browser.log_error( + "missing #" <> site_model_id <> " — islands not started", + ) + // No payload on this page; nothing to hydrate. + Some("") -> Nil + Some(payload) -> + case decode.decode_hydration_model(payload) { Error(reason) -> browser.log_error( - "decode_repos failed: " <> string.inspect(reason), + "hydration model decode failed: " <> string.inspect(reason), ) + Ok(model) -> { + profile_island.start(profile_island.Flags( + rewrites: model.rewrites, + profile: model.profile, + )) + case browser.has_element(plays_view.plays_rows_id) { + True -> + plays_island.start(plays_island.Flags(plays: model.plays)) + False -> Nil + } + case browser.has_element(repos_view.repos_section_id) { + True -> + repos_island.start( + repos_island.Flags( + repos: list.map(model.repos, fn(record) { record.value }), + ), + ) + False -> Nil + } + } } - }) - } - 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, drops)) -> { - case drops > 0 { - True -> - browser.log_error( - "decode_plays: dropped " - <> int.to_string(drops) - <> " undecodable play record(s)", - ) - False -> Nil } - 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/browser.gleam b/client/src/browser.gleam index d486fb3..3d6219b 100644 --- a/client/src/browser.gleam +++ b/client/src/browser.gleam @@ -1,17 +1,20 @@ -@external(javascript, "./browser_ffi.mjs", "fetch_text") -pub fn fetch_text(url: String, callback: fn(String) -> Nil) -> Nil - -@external(javascript, "./browser_ffi.mjs", "set_inner_html") -pub fn set_inner_html(id: String, html: String) -> Nil +//// Browser FFI for the client islands. Deliberately tiny: the VDOM +//// owns the DOM now, so the only operations are fetching data, +//// reading embedded JSON, timers, visibility, and one post-paint pass +//// (date localization) scoped to an island's own root. -@external(javascript, "./browser_ffi.mjs", "rewrite_remote_images") -pub fn rewrite_remote_images() -> Nil +import gleam/dynamic.{type Dynamic} +import gleam/option.{type Option} -@external(javascript, "./browser_ffi.mjs", "set_attribute") -pub fn set_attribute(id: String, name: String, value: String) -> Nil +@external(javascript, "./browser_ffi.mjs", "fetch_text") +pub fn fetch_text(url: String, callback: fn(String) -> Nil) -> Nil -@external(javascript, "./browser_ffi.mjs", "remove_attribute") -pub fn remove_attribute(id: String, name: String) -> Nil +/// The text content of the SSG-embedded `"), + description: Some("a & b < c > d"), + avatar: Some("https://example.com/avatar.jpg"), + banner: Some("https://example.com/banner.jpg"), + followers_count: Some(1), + follows_count: Some(1), + posts_count: Some(1), + pronouns: None, + ) + let original = + HydrationModel(profile: hostile, plays: [], repos: [], rewrites: dict.new()) + + let json = encode.encode_hydration_model(original) + // Raw `` would terminate the embedding script tag. + json |> string.contains("") |> should.be_false() + json |> string.contains("\\u003c/script\\u003e") |> should.be_true() + // The escapes are lossless for the JSON decoder. + let assert Ok(decoded) = decode.decode_hydration_model(json) + decoded.profile |> should.equal(hostile) +} + +pub fn round_trip_zero_counts_test() { + let profile = + ProfileViewDetailed( + did: "did:plc:zero", + handle: "zero.test", + display_name: Some(""), + description: Some(""), + avatar: Some(""), + banner: Some(""), + followers_count: Some(0), + follows_count: Some(0), + posts_count: Some(0), + pronouns: None, + ) + let original = + HydrationModel(profile: profile, plays: [], repos: [], rewrites: dict.new()) + let json = encode.encode_hydration_model(original) + let assert Ok(decoded) = decode.decode_hydration_model(json) + decoded.profile |> should.equal(profile) +} diff --git a/shared/test/hydration_test.gleam b/shared/test/hydration_test.gleam new file mode 100644 index 0000000..9684b7e --- /dev/null +++ b/shared/test/hydration_test.gleam @@ -0,0 +1,53 @@ +//// Tests for build→run-time agreement helpers: profile image +//// localization so the client's fresh render matches the mirrored +//// images the SSG embedded. + +import gen/actor/defs.{type ProfileViewDetailed, ProfileViewDetailed} +import gleam/dict +import gleam/option.{None, Some} +import gleeunit/should +import hydration.{localize_profile} + +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://cdn.bsky.app/avatar.jpeg"), + banner: Some("https://cdn.bsky.app/banner.jpeg"), + followers_count: None, + follows_count: None, + posts_count: None, + pronouns: None, + ) +} + +fn mirror_map() { + dict.from_list([ + #("https://cdn.bsky.app/avatar.jpeg", "/img/profile/avatar.jpeg"), + #("https://cdn.bsky.app/banner.jpeg", "/img/profile/banner.jpeg"), + ]) +} + +pub fn localize_profile_maps_avatar_and_banner_test() { + let localized = localize_profile(sample_profile(), mirror_map()) + localized.avatar + |> should.equal(Some("/img/profile/avatar.jpeg")) + localized.banner + |> should.equal(Some("/img/profile/banner.jpeg")) +} + +pub fn localize_profile_keeps_unmapped_urls_test() { + // An image changed after the last build isn't in the map: keep the + // remote URL so the browser falls back to the origin host. + let localized = localize_profile(sample_profile(), dict.from_list([])) + localized.avatar + |> should.equal(Some("https://cdn.bsky.app/avatar.jpeg")) +} + +pub fn localize_profile_leaves_other_fields_untouched_test() { + let localized = localize_profile(sample_profile(), mirror_map()) + localized.handle |> should.equal("test.bsky.social") + localized.did |> should.equal("did:plc:test") +} diff --git a/shared/test/plays_test.gleam b/shared/test/plays_test.gleam index de31d42..25f4d9f 100644 --- a/shared/test/plays_test.gleam +++ b/shared/test/plays_test.gleam @@ -1,5 +1,4 @@ import atproto -import gleam/list import gleam/string import gleeunit/should import lustre/element.{to_string} @@ -23,7 +22,7 @@ fn sample_play_body() -> String { // The minimal set of fields the play decoder requires. "{\"records\":[{ \"cid\": \"bafy1\", - \"uri\": \"at://did:plc:test/fm.teal.feed.play/abc\", + \"uri\": \"at://did:plc:test/fm.teal.alpha.feed.play/abc\", \"value\": { \"artists\": [{\"artistName\": \"Artist A\"}], \"playedTime\": \"2026-07-18T10:00:00Z\", @@ -33,26 +32,26 @@ fn sample_play_body() -> String { } 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() + let assert Ok(#(decoded, _drops)) = atproto.decode_plays(sample_play_body()) + let html = plays.plays_rows(decoded, True) |> to_string string.contains(html, "Track A") |> should.be_true() string.contains(html, "Artist A") |> should.be_true() } -pub fn decode_plays_counts_undecodable_records_test() { - let body = - "{\"records\":[ - {\"cid\": \"bafy1\", \"uri\": \"at://x/y/a\", \"value\": { - \"artists\": [{\"artistName\": \"Artist A\"}], - \"playedTime\": \"2026-07-18T10:00:00Z\", - \"trackName\": \"Track A\" - }}, - {\"cid\": \"bafy2\", \"uri\": \"at://x/y/b\", \"value\": \"not an object\"} - ]}" - let assert Ok(#(plays, drops)) = atproto.decode_plays(body) - plays |> list.length |> should.equal(1) - drops |> should.equal(1) +pub fn plays_rows_stale_sets_attribute_test() { + let assert Ok(#(decoded, _drops)) = atproto.decode_plays(sample_play_body()) + plays.plays_rows(decoded, True) + |> to_string + |> string.contains("data-stale") + |> should.be_true() +} + +pub fn plays_rows_fresh_omits_attribute_test() { + let assert Ok(#(decoded, _drops)) = atproto.decode_plays(sample_play_body()) + plays.plays_rows(decoded, False) + |> to_string + |> string.contains("data-stale") + |> should.be_false() } // --- stats view tiles --- @@ -67,7 +66,7 @@ fn stats_with_tile(item: StatsItem) -> StatsData { } pub fn plays_section_tile_without_image_uses_placeholder_test() { - let assert Ok(#(decoded, _)) = atproto.decode_plays(sample_play_body()) + let assert Ok(#(decoded, _drops)) = atproto.decode_plays(sample_play_body()) let data = stats_with_tile(StatsItem( name: "Alpha", @@ -83,7 +82,7 @@ pub fn plays_section_tile_without_image_uses_placeholder_test() { } pub fn plays_section_tile_with_image_and_url_links_test() { - let assert Ok(#(decoded, _)) = atproto.decode_plays(sample_play_body()) + let assert Ok(#(decoded, _drops)) = atproto.decode_plays(sample_play_body()) let data = stats_with_tile(StatsItem( name: "Alpha", diff --git a/src/build.gleam b/src/build.gleam index f1d4f7b..d21d5d3 100644 --- a/src/build.gleam +++ b/src/build.gleam @@ -10,7 +10,6 @@ import data/images import data/model.{type Post, SiteData} import data/sources import data/transport -import dynamic import gen/actor/defs.{type ProfileViewDetailed} import gleam/io import gleam/list @@ -20,7 +19,27 @@ import render/page import simplifile import view/layout +const client_bundle_src = "client/dist/karitham_blog_client.js" + +/// The bundle path must exist before the (slow, network-bound) data +/// fetches run — a site that silently builds without its client JS is +/// worse than a failed build. +fn ensure_client_bundle() { + case simplifile.is_file(client_bundle_src) { + Ok(True) -> Nil + _ -> { + io.println( + "Client bundle not found at " + <> client_bundle_src + <> ". Run `just client` (or `cd client && gleam run -m lustre/dev build --minify=true --no-html=true`) first.", + ) + panic as "client bundle missing" + } + } +} + pub fn build() { + ensure_client_bundle() let cfg = config.read_env() io.println("Fetching data...") @@ -68,7 +87,7 @@ pub fn build() { copy_post_assets(drafts, cfg) copy_favicons(cfg) copy_image_cache(cfg) - copy_client_js(cfg) + copy_client_bundle(cfg) io.println("Done! Site generated in " <> cfg.dist_dir) } @@ -178,52 +197,19 @@ fn copy_favicons(cfg: config.SiteConfig) { }) } -/// The top-level packages the compiled client bundle actually imports -/// (traced from `karitham_blog_client/client.mjs` and their transitive -/// imports). `houdini` is lustre's vdom HTML-escaping dependency — -/// without it the browser 404s mid-module-graph and the whole client -/// fails to boot. The full dev tree also contains test runners -/// (gleeunit), Erlang artefacts, and unused packages (atproto_client, -/// kryptos, gose, bigi, exception, 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", - "houdini", -] - -fn copy_client_js(cfg: config.SiteConfig) { - let src = "client/build/dev/javascript" - 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) - 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") - } - _ -> - io.println( - " client JS not built — skip 'cd client && gleam build' first", - ) - } +/// Copy the client bundle produced by `just client` (a single +/// self-executing ES module) into the site. Existence is already +/// enforced by `ensure_client_bundle` before the build starts. +fn copy_client_bundle(cfg: config.SiteConfig) { + // Delete first so a whitelisted dev tree from an older build + // can't linger next to the bundle. + let _ = simplifile.delete(cfg.dist_dir <> "/client") + let _ = create_dir(cfg.dist_dir <> "/client") + copy_file_bits( + client_bundle_src, + cfg.dist_dir <> "/client/karitham_blog_client.js", + ) + io.println(" copied client bundle") } /// Copy the mirrored cover/artist images from the refresh cache into @@ -267,9 +253,7 @@ fn copy_dir(src: String, dst: String) -> Nil { // --- 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 + to_document_string(element) } fn write_text(path: String, contents: String) -> Nil { diff --git a/src/cli.gleam b/src/cli.gleam index bad33ff..40e31e0 100644 --- a/src/cli.gleam +++ b/src/cli.gleam @@ -1,4 +1,4 @@ -//// CLI subcommands for working with the blog — the impure shell. +//// Post scaffolding and usage text. //// //// Usage: //// gleam run Build the static site (default) @@ -6,39 +6,16 @@ //// 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 +//// The build subcommand dispatches straight to `build.build()`. 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 @@ -94,7 +71,7 @@ pub fn print_usage() { 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`.", +separately; for a one-shot build use `just build`.", ) } diff --git a/src/data/images.gleam b/src/data/images.gleam index 12edfe5..cab6216 100644 --- a/src/data/images.gleam +++ b/src/data/images.gleam @@ -1,13 +1,14 @@ //// Build-time mirroring of the profile's avatar/banner blobs. //// -//// 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 -//// local copies; the remote→local rewrite map is embedded in the page -//// (`#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. +//// The browser re-fetches the profile on page load (the client's +//// profile island) 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/`, rewrites the +//// profile data to point at the local copies, and embeds the +//// remote→local map in the hydration payload (`#site-model`) so the +//// island 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). diff --git a/src/karitham_blog.gleam b/src/karitham_blog.gleam index dc00e85..96d0952 100644 --- a/src/karitham_blog.gleam +++ b/src/karitham_blog.gleam @@ -1,4 +1,5 @@ import argv +import build import cli import gleam/io import gleam/string @@ -10,7 +11,7 @@ import gleam/string /// gleam run help → usage pub fn main() { case argv.load().arguments { - [] | ["build"] -> cli.build_site() + [] | ["build"] -> build.build() ["new", ..rest] -> cli.new_post(string.join(rest, " ")) ["help"] | ["-h"] | ["--help"] -> cli.print_usage() other -> { diff --git a/src/render/page.gleam b/src/render/page.gleam index acd1542..ca4980a 100644 --- a/src/render/page.gleam +++ b/src/render/page.gleam @@ -6,21 +6,25 @@ //// 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/dict import gleam/list import gleam/option.{type Option, None, Some, map as option_map} import gleam/string -import lustre/attribute.{class, id, type_} -import lustre/element.{type Element, fragment, text} -import lustre/element/html.{div, h2, script} +import hydration.{HydrationModel} +import lustre/attribute.{class, id} +import lustre/element.{type Element, text} +import lustre/element/html.{div, h2} +import plays as plays_view +import profile as profile_view +import repos as repos_view 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. +/// embedded in the hydration payload for the client's islands. pub fn index_page( data: SiteData, site_url: String, @@ -37,35 +41,32 @@ pub fn index_page( 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, + rewrites: dict.from_list(rewrites), + )) + + // The client mounts one island per dynamic section on the ids these + // render (`#profile-section`, `#plays-rows`, `#repos`) and adopts + // this markup as its first render. let dynamic_sections = div([id("dynamic-sections")], [ - dynamic.dynamic_sections( - data.profile, - data.recent_plays, - data.plays_stats, + profile_view.profile(data.profile), + plays_view.plays_section(data.recent_plays, data.plays_stats), + repos_view.repos_section( 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 articles = + div([class("section")], [ + div([class("section-header")], [ + h2([], [text("Articles")]), ]), + post_view.render_list(data.posts), ]) let meta = @@ -79,7 +80,7 @@ pub fn index_page( page_type: layout.Website, ) - layout.page("~/kar", site_url, content, meta) + layout.page("~/kar", site_url, model_json, [dynamic_sections, articles], meta) } /// A single article page. @@ -105,7 +106,8 @@ pub fn post_page( layout.page( post.title <> " - Kar", site_url, - post_view.render_single(post), + "", + [post_view.render_single(post)], meta, ) } @@ -130,15 +132,3 @@ fn og_image_for_post(slug: String, img: String, site_url: String) -> String { 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 index 90cf6b8..37c28ce 100644 --- a/src/view/layout.gleam +++ b/src/view/layout.gleam @@ -27,19 +27,21 @@ pub type Meta { /// 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. +/// testable with a fixed URL. `model_json` is the hydration payload +/// embedded for the client (`""` on pages without dynamic sections). pub fn page( site_url: String, title: String, - content: Element(Nil), + model_json: String, + content: List(Element(Nil)), meta: Meta, ) -> Element(Nil) { html.html([attribute.lang("en")], [ - html.head([], head_children(site_url, title, meta)), + html.head([], head_children(site_url, title, model_json, meta)), html.body([], [ html.div([attribute.id("content")], [ nav_bar(), - html.div([attribute.id("main")], [content]), + html.div([attribute.id("main")], content), footer_bar(), ]), ]), @@ -49,6 +51,7 @@ pub fn page( fn head_children( site_url: String, title: String, + model_json: String, meta: Meta, ) -> List(Element(Nil)) { let base = [ @@ -154,6 +157,10 @@ fn head_children( attribute.rel("manifest"), attribute.href("/site.webmanifest"), ]), + html.script( + [attribute.type_("application/json"), attribute.id("site-model")], + model_json, + ), // Vendored highlight.js (common bundle) — must load before the module below. // `defer` keeps the download off the critical path while ensuring the // script executes before any DOMContentLoaded listeners (including the @@ -167,9 +174,15 @@ fn head_children( [attribute.type_("module"), attribute.src("/highlight.mjs")], "", ), + // Client bundle: a single self-executing ES module produced by + // `lustre/dev build` (bun). It reads #site-model, mounts the + // islands, and stops there on pages without dynamic sections. html.script( - [attribute.type_("module")], - "import{main}from'/client/karitham_blog_client/client.mjs';main();", + [ + attribute.type_("module"), + attribute.src("/client/karitham_blog_client.js"), + ], + "", ), ] diff --git a/test/render/page_test.gleam b/test/render/page_test.gleam index f3b28fb..2236a0c 100644 --- a/test/render/page_test.gleam +++ b/test/render/page_test.gleam @@ -54,13 +54,16 @@ pub fn index_page_renders_articles_and_post_test() { string.contains(html, "og:site_name") |> should.be_true() } -pub fn index_page_embeds_rewrites_script_test() { +pub fn index_page_embeds_rewrites_in_hydration_model_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() + // The rewrite map travels inside the #site-model hydration payload + // now, not in its own script tag. + string.contains(html, "site-model") |> should.be_true() + string.contains(html, "\"rewrites\"") |> should.be_true() string.contains(html, "https://remote/avatar.jpg") |> should.be_true() } diff --git a/test/view/layout_test.gleam b/test/view/layout_test.gleam index c32bc31..57bfad8 100644 --- a/test/view/layout_test.gleam +++ b/test/view/layout_test.gleam @@ -16,7 +16,7 @@ pub fn page_uses_site_url_for_og_site_name_test() { page_type: layout.Website, ) let html = - layout.page("https://karitham.dev", "Title", div([], []), meta) + 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() @@ -32,7 +32,7 @@ pub fn page_uses_local_site_url_in_preview_test() { page_type: layout.Website, ) let html = - layout.page("http://localhost:8000", "Title", div([], []), meta) + 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 diff --git a/tools/parse-plays/src/images.rs b/tools/parse-plays/src/images.rs index 2b0c1e3..194fc68 100644 --- a/tools/parse-plays/src/images.rs +++ b/tools/parse-plays/src/images.rs @@ -215,7 +215,10 @@ mod tests { fn download_many_fetches_and_404s_are_permanent() { let png: &'static [u8] = b"\x89PNG\r\n\x1a\n fake png bytes"; let stub = crate::testutil::StubClient::new() - .route("cover.png", crate::testutil::Response::Bytes(png, "image/png")) + .route( + "cover.png", + crate::testutil::Response::Bytes(png, "image/png"), + ) .route("missing.png", crate::testutil::Response::Status(404)); let req_log = stub.request_log(); let limits = RateLimits::unthrottled(); diff --git a/tools/parse-plays/src/testutil.rs b/tools/parse-plays/src/testutil.rs index b824cf4..26dd52a 100644 --- a/tools/parse-plays/src/testutil.rs +++ b/tools/parse-plays/src/testutil.rs @@ -100,9 +100,7 @@ impl HttpFetch for StubClient { self.record(url); matches!( self.route_for(url), - Some(Response::Status(200)) - | Some(Response::Json(_)) - | Some(Response::Bytes(_, _)) + Some(Response::Status(200)) | Some(Response::Json(_)) | Some(Response::Bytes(_, _)) ) } } -- knotmirror 0.0.1