├── .github └── workflows │ └── release.yml ├── .gitignore ├── README.md ├── birdie_snapshots ├── simulate_a_single_navigation.accepted └── simulate_an_invalid_base_url.accepted ├── examples └── 01-simple │ ├── README.md │ ├── gleam.toml │ ├── manifest.toml │ └── src │ └── app.gleam ├── gleam.toml ├── manifest.toml ├── src ├── modem.ffi.mjs └── modem.gleam └── test ├── apps └── example.gleam ├── modem_test.gleam └── simulations └── simulate_navigation_test.gleam /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | tags: ["v*"] 6 | 7 | jobs: 8 | publish: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - uses: actions/checkout@v3.1.0 13 | - uses: erlef/setup-beam@v1.18.2 14 | with: 15 | otp-version: "27.0" 16 | rebar3-version: "3" 17 | gleam-version: "1.10.0" 18 | 19 | - run: | 20 | version="v$(cat gleam.toml | grep -m 1 "version" | sed -r "s/version *= *\"([[:digit:].]+)\"/\1/")" 21 | if [ "$version" != "${{ github.ref_name }}" ]; then 22 | echo "tag '${{ github.ref_name }}' does not match the version in gleam.toml" 23 | echo "expected a tag name 'v$version'" 24 | exit 1 25 | fi 26 | 27 | name: check version 28 | 29 | - run: gleam format --check 30 | 31 | - run: gleam test --target erlang 32 | 33 | - run: gleam test --target javascript 34 | 35 | - run: gleam publish -y 36 | env: 37 | HEXPM_API_KEY: ${{ secrets.HEXPM_API_KEY }} 38 | 39 | - uses: softprops/action-gh-release@v2 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.beam 2 | *.ez 3 | build 4 | erl_crash.dump 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # modem 2 | 3 | [![Package Version](https://img.shields.io/hexpm/v/modem)](https://hex.pm/packages/modem) 4 | [![Hex Docs](https://img.shields.io/badge/hex-docs-ffaff3)](https://hexdocs.pm/modem/) 5 | 6 | > **modem**: a device that converts signals produced by one type of device (such 7 | > as a computer) to a form compatible with another (such as a telephone) – [Merriam-Webster](https://www.merriam-webster.com/dictionary/modem) 8 | 9 | Modem is a little library for Lustre that helps you manage navigation and URLs in 10 | the browser. It converts url requests into messages that you can handle in your 11 | app's update function. Modem isn't a router, but it can help you build one! 12 | 13 | ## Quickstart 14 | 15 | Getting started with modem is easy! Most application's can get by with pattern 16 | matching on a url's path: no complicated router setup required. Let's see what 17 | that looks like with modem: 18 | 19 | ```sh 20 | gleam add lustre modem 21 | ``` 22 | 23 | ```gleam 24 | import gleam/result 25 | import gleam/uri.{type Uri} 26 | import lustre 27 | import lustre/attribute 28 | import lustre/element.{type Element} 29 | import lustre/element/html 30 | import lustre/effect.{type Effect} 31 | import modem 32 | 33 | pub fn main() { 34 | let app = lustre.application(init, update, view) 35 | let assert Ok(_) = lustre.start(app, "#app", Nil) 36 | } 37 | 38 | pub type Route { 39 | Wibble 40 | Wobble 41 | } 42 | 43 | pub fn init(_) -> #(Route, Effect(Msg)) { 44 | let route = 45 | modem.initial_uri() 46 | |> result.map(fn(uri) { uri.path_segments(uri.path) }) 47 | |> fn(path) { 48 | case path { 49 | Ok(["wibble"]) -> Wibble 50 | Ok(["wobble"]) -> Wobble 51 | _ -> Wibble 52 | } 53 | } 54 | 55 | #(route, modem.init(on_url_change)) 56 | } 57 | 58 | fn on_url_change(uri: Uri) -> Msg { 59 | case uri.path_segments(uri.path) { 60 | ["wibble"] -> OnRouteChange(Wibble) 61 | ["wobble"] -> OnRouteChange(Wobble) 62 | _ -> OnRouteChange(Wibble) 63 | } 64 | } 65 | 66 | pub type Msg { 67 | OnRouteChange(Route) 68 | } 69 | 70 | fn update(_, msg: Msg) -> #(Route, Effect(Msg)) { 71 | case msg { 72 | OnRouteChange(route) -> #(route, effect.none()) 73 | } 74 | } 75 | 76 | fn view(route: Route) -> Element(Msg) { 77 | html.div([], [ 78 | html.nav([], [ 79 | html.a([attribute.href("/wibble")], [element.text("Go to wibble")]), 80 | html.a([attribute.href("/wobble")], [element.text("Go to wobble")]), 81 | ]), 82 | case route { 83 | Wibble -> html.h1([], [element.text("You're on wibble")]) 84 | Wobble -> html.h1([], [element.text("You're on wobble")]) 85 | }, 86 | ]) 87 | } 88 | ``` 89 | 90 | Here's a breakdown of what's happening: 91 | 92 | - We define a `Route` type that represents the page or route we're currently on. 93 | 94 | - `modem.init` is an [`Effect`](https://hexdocs.pm/lustre/4.0.0-rc.2/lustre/effect.html#Effect) 95 | that intercepts clicks to local links and browser back/forward navigation and 96 | lets you handle them. 97 | 98 | - `on_url_change` is a function we write that takes an incoming [`Uri`](https://hexdocs.pm/gleam_stdlib/gleam/uri.html#Uri) 99 | and converts it to our app's `Msg` type. 100 | 101 | - In our `view` we can just use normal `html.a.` elements: no special link component 102 | necessary. Pattern matching on the `Route` type lets us render different content 103 | for each page. 104 | -------------------------------------------------------------------------------- /birdie_snapshots/simulate_a_single_navigation.accepted: -------------------------------------------------------------------------------- 1 | --- 2 | version: 1.3.0 3 | title: Simulate a single navigation 4 | file: ./test/simulations/simulate_navigation_test.gleam 5 | test_name: simulate_single_navigation_test 6 | --- 7 |

8 | You're on wobble 9 |

-------------------------------------------------------------------------------- /birdie_snapshots/simulate_an_invalid_base_url.accepted: -------------------------------------------------------------------------------- 1 | --- 2 | version: 1.3.0 3 | title: Simulate an invalid base URL 4 | file: ./test/simulations/simulate_navigation_test.gleam 5 | test_name: simulate_invalid_base_url_test 6 | --- 7 | [Problem("ModemInvalidBaseURL", "`invalid-url` is not a valid base URL")] -------------------------------------------------------------------------------- /examples/01-simple/README.md: -------------------------------------------------------------------------------- 1 | # app 2 | 3 | [![Package Version](https://img.shields.io/hexpm/v/app)](https://hex.pm/packages/app) 4 | [![Hex Docs](https://img.shields.io/badge/hex-docs-ffaff3)](https://hexdocs.pm/app/) 5 | 6 | ```sh 7 | gleam add app 8 | ``` 9 | ```gleam 10 | import app 11 | 12 | pub fn main() { 13 | // TODO: An example of the project in use 14 | } 15 | ``` 16 | 17 | Further documentation can be found at . 18 | 19 | ## Development 20 | 21 | ```sh 22 | gleam run # Run the project 23 | gleam test # Run the tests 24 | gleam shell # Run an Erlang shell 25 | ``` 26 | -------------------------------------------------------------------------------- /examples/01-simple/gleam.toml: -------------------------------------------------------------------------------- 1 | name = "app" 2 | version = "1.0.0" 3 | 4 | # Fill out these fields if you intend to generate HTML documentation or publish 5 | # your project to the Hex package manager. 6 | # 7 | # description = "" 8 | # licences = ["Apache-2.0"] 9 | # repository = { type = "github", user = "username", repo = "project" } 10 | # links = [{ title = "Website", href = "https://gleam.run" }] 11 | # 12 | # For a full reference of all the available options, you can have a look at 13 | # https://gleam.run/writing-gleam/gleam-toml/. 14 | 15 | [dependencies] 16 | gleam_stdlib = "~> 0.34 or ~> 1.0" 17 | lustre = "4.0.0-rc.2" 18 | modem = { path = "../../" } 19 | 20 | [dev-dependencies] 21 | gleeunit = "~> 1.0" 22 | -------------------------------------------------------------------------------- /examples/01-simple/manifest.toml: -------------------------------------------------------------------------------- 1 | # This file was generated by Gleam 2 | # You typically do not need to edit this file 3 | 4 | packages = [ 5 | { name = "argv", version = "1.0.1", build_tools = ["gleam"], requirements = [], otp_app = "argv", source = "hex", outer_checksum = "A6E9009E50BBE863EB37D963E4315398D41A3D87D0075480FC244125808F964A" }, 6 | { name = "filepath", version = "0.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "FC1B1B29438A5BA6C990F8047A011430BEC0C5BA638BFAA62718C4EAEFE00435" }, 7 | { name = "gleam_community_ansi", version = "1.4.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_community_colour"], otp_app = "gleam_community_ansi", source = "hex", outer_checksum = "FE79E08BF97009729259B6357EC058315B6FBB916FAD1C2FF9355115FEB0D3A4" }, 8 | { name = "gleam_community_colour", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_community_colour", source = "hex", outer_checksum = "A49A5E3AE8B637A5ACBA80ECB9B1AFE89FD3D5351FF6410A42B84F666D40D7D5" }, 9 | { name = "gleam_erlang", version = "0.24.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "26BDB52E61889F56A291CB34167315780EE4AA20961917314446542C90D1C1A0" }, 10 | { name = "gleam_json", version = "0.7.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "thoas"], otp_app = "gleam_json", source = "hex", outer_checksum = "CB405BD93A8828BCD870463DE29375E7B2D252D9D124C109E5B618AAC00B86FC" }, 11 | { name = "gleam_otp", version = "0.10.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "0B04FE915ACECE539B317F9652CAADBBC0F000184D586AAAF2D94C100945D72B" }, 12 | { name = "gleam_stdlib", version = "0.36.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "C0D14D807FEC6F8A08A7C9EF8DFDE6AE5C10E40E21325B2B29365965D82EB3D4" }, 13 | { name = "glearray", version = "0.2.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "glearray", source = "hex", outer_checksum = "908154F695D330E06A37FAB2C04119E8F315D643206F8F32B6A6C14A8709FFF4" }, 14 | { name = "gleeunit", version = "1.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "D364C87AFEB26BDB4FB8A5ABDE67D635DC9FA52D6AB68416044C35B096C6882D" }, 15 | { name = "glint", version = "0.16.0", build_tools = ["gleam"], requirements = ["gleam_community_colour", "gleam_stdlib", "snag", "gleam_community_ansi"], otp_app = "glint", source = "hex", outer_checksum = "61B7E85CBB0CCD2FD8A9C7AE06CA97A80BF6537716F34362A39DF9C74967BBBC" }, 16 | { name = "justin", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "justin", source = "hex", outer_checksum = "7FA0C6DB78640C6DC5FBFD59BF3456009F3F8B485BF6825E97E1EB44E9A1E2CD" }, 17 | { name = "lustre", version = "4.0.0-rc1", build_tools = ["gleam"], requirements = ["justin", "gleam_erlang", "gleam_community_ansi", "glint", "argv", "filepath", "simplifile", "spinner", "tom", "gleam_otp", "gleam_stdlib", "gleam_json", "shellout"], otp_app = "lustre", source = "hex", outer_checksum = "E49E96AB45A43B0AFDF82B58D727AFB45FF457BEAD2BA158381A8E979F51F604" }, 18 | { name = "modem", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "lustre"], source = "local", path = "../.." }, 19 | { name = "repeatedly", version = "2.1.1", build_tools = ["gleam"], requirements = [], otp_app = "repeatedly", source = "hex", outer_checksum = "38808C3EC382B0CD981336D5879C24ECB37FCB9C1D1BD128F7A80B0F74404D79" }, 20 | { name = "shellout", version = "1.6.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "shellout", source = "hex", outer_checksum = "E2FCD18957F0E9F67E1F497FC9FF57393392F8A9BAEAEA4779541DE7A68DD7E0" }, 21 | { name = "simplifile", version = "1.5.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "EB9AA8E65E5C1E3E0FDCFC81BC363FD433CB122D7D062750FFDF24DE4AC40116" }, 22 | { name = "snag", version = "0.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "snag", source = "hex", outer_checksum = "54D32E16E33655346AA3E66CBA7E191DE0A8793D2C05284E3EFB90AD2CE92BCC" }, 23 | { name = "spinner", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_community_ansi", "glearray", "repeatedly", "gleam_stdlib"], otp_app = "spinner", source = "hex", outer_checksum = "200BA3D4A04D468898E63C0D316E23F526E02514BC46454091975CB5BAE41E8F" }, 24 | { name = "thoas", version = "0.4.1", build_tools = ["rebar3"], requirements = [], otp_app = "thoas", source = "hex", outer_checksum = "4918D50026C073C4AB1388437132C77A6F6F7C8AC43C60C13758CC0ADCE2134E" }, 25 | { name = "tom", version = "0.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "tom", source = "hex", outer_checksum = "0831C73E45405A2153091226BF98FB485ED16376988602CC01A5FD086B82D577" }, 26 | ] 27 | 28 | [requirements] 29 | gleam_stdlib = { version = "~> 0.34 or ~> 1.0" } 30 | gleeunit = { version = "~> 1.0" } 31 | lustre = { version = "4.0.0-rc.2" } 32 | modem = { path = "../../" } 33 | -------------------------------------------------------------------------------- /examples/01-simple/src/app.gleam: -------------------------------------------------------------------------------- 1 | import gleam/result 2 | import gleam/uri.{type Uri} 3 | import lustre 4 | import lustre/attribute 5 | import lustre/effect.{type Effect} 6 | import lustre/element.{type Element} 7 | import lustre/element/html 8 | import modem 9 | 10 | pub fn main() { 11 | lustre.application(init, update, view) 12 | } 13 | 14 | pub type Route { 15 | Wibble 16 | Wobble 17 | } 18 | 19 | pub fn init(_) -> #(Route, Effect(Msg)) { 20 | let route = 21 | modem.initial_uri() 22 | |> result.map(fn(uri) { uri.path_segments(uri.path) }) 23 | |> fn(path) { 24 | case path { 25 | Ok(["wibble"]) -> Wibble 26 | Ok(["wobble"]) -> Wobble 27 | _ -> Wibble 28 | } 29 | } 30 | 31 | #(route, modem.init(on_url_change)) 32 | } 33 | 34 | fn on_url_change(uri: Uri) -> Msg { 35 | case uri.path_segments(uri.path) { 36 | ["wibble"] -> OnRouteChange(Wibble) 37 | ["wobble"] -> OnRouteChange(Wobble) 38 | _ -> OnRouteChange(Wibble) 39 | } 40 | } 41 | 42 | pub type Msg { 43 | OnRouteChange(Route) 44 | } 45 | 46 | fn update(_, msg: Msg) -> #(Route, Effect(Msg)) { 47 | case msg { 48 | OnRouteChange(route) -> #(route, effect.none()) 49 | } 50 | } 51 | 52 | fn view(route: Route) -> Element(Msg) { 53 | html.div([], [ 54 | html.nav([], [ 55 | html.a([attribute.href("/wibble")], [element.text("Go to wibble")]), 56 | html.a([attribute.href("/wobble")], [element.text("Go to wobble")]), 57 | ]), 58 | case route { 59 | Wibble -> html.h1([], [element.text("You're on wibble")]) 60 | Wobble -> html.h1([], [element.text("You're on wobble")]) 61 | }, 62 | ]) 63 | } 64 | -------------------------------------------------------------------------------- /gleam.toml: -------------------------------------------------------------------------------- 1 | name = "modem" 2 | version = "2.1.0" 3 | 4 | description = "A friendly Lustre package to help you handle routing, links, and manage URLs." 5 | repository = { type = "github", user = "hayleigh-dot-dev", repo = "modem" } 6 | licences = ["MIT"] 7 | 8 | links = [ 9 | { title = "Lustre", href = "https://hexdocs.pm/lustre/" }, 10 | { title = "Sponsor", href = "https://github.com/sponsors/hayleigh-dot-dev" }, 11 | ] 12 | 13 | 14 | [dependencies] 15 | gleam_stdlib = ">= 0.60.0 and < 2.0.0" 16 | lustre = ">= 5.1.0 and < 6.0.0" 17 | 18 | [dev-dependencies] 19 | gleeunit = "~> 1.0" 20 | birdie = ">= 1.3.0 and < 2.0.0" 21 | -------------------------------------------------------------------------------- /manifest.toml: -------------------------------------------------------------------------------- 1 | # This file was generated by Gleam 2 | # You typically do not need to edit this file 3 | 4 | packages = [ 5 | { name = "argv", version = "1.0.2", build_tools = ["gleam"], requirements = [], otp_app = "argv", source = "hex", outer_checksum = "BA1FF0929525DEBA1CE67256E5ADF77A7CDDFE729E3E3F57A5BDCAA031DED09D" }, 6 | { name = "birdie", version = "1.3.0", build_tools = ["gleam"], requirements = ["argv", "edit_distance", "filepath", "glance", "gleam_community_ansi", "gleam_stdlib", "justin", "rank", "simplifile", "term_size", "trie_again"], otp_app = "birdie", source = "hex", outer_checksum = "425916385B6CD82A58F54CC39605262A524B169746FC9AD9C799BC76E88F7AF3" }, 7 | { name = "edit_distance", version = "2.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "edit_distance", source = "hex", outer_checksum = "A1E485C69A70210223E46E63985FA1008B8B2DDA9848B7897469171B29020C05" }, 8 | { name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" }, 9 | { name = "glance", version = "3.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "glexer"], otp_app = "glance", source = "hex", outer_checksum = "F3458292AFB4136CEE23142A8727C1270494E7A96978B9B9F9D2C1618583EF3D" }, 10 | { name = "gleam_community_ansi", version = "1.4.3", build_tools = ["gleam"], requirements = ["gleam_community_colour", "gleam_regexp", "gleam_stdlib"], otp_app = "gleam_community_ansi", source = "hex", outer_checksum = "8A62AE9CC6EA65BEA630D95016D6C07E4F9973565FA3D0DE68DC4200D8E0DD27" }, 11 | { name = "gleam_community_colour", version = "2.0.1", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib"], otp_app = "gleam_community_colour", source = "hex", outer_checksum = "F0ACE69E3A47E913B03D3D0BB23A5563A91A4A7D20956916286068F4A9F817FE" }, 12 | { name = "gleam_erlang", version = "0.34.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "0C38F2A128BAA0CEF17C3000BD2097EB80634E239CE31A86400C4416A5D0FDCC" }, 13 | { name = "gleam_json", version = "3.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "5BA154440B22D9800955B1AB854282FA37B97F30F409D76B0824D0A60C934188" }, 14 | { name = "gleam_otp", version = "0.16.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "50DA1539FC8E8FA09924EB36A67A2BBB0AD6B27BCDED5A7EF627057CF69D035E" }, 15 | { name = "gleam_regexp", version = "1.1.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_regexp", source = "hex", outer_checksum = "9C215C6CA84A5B35BB934A9B61A9A306EC743153BE2B0425A0D032E477B062A9" }, 16 | { name = "gleam_stdlib", version = "0.60.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "621D600BB134BC239CB2537630899817B1A42E60A1D46C5E9F3FAE39F88C800B" }, 17 | { name = "gleeunit", version = "1.3.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "A7DD6C07B7DA49A6E28796058AA89E651D233B357D5607006D70619CD89DAAAB" }, 18 | { name = "glexer", version = "2.2.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "glexer", source = "hex", outer_checksum = "5C235CBDF4DA5203AD5EAB1D6D8B456ED8162C5424FE2309CFFB7EF438B7C269" }, 19 | { name = "houdini", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "houdini", source = "hex", outer_checksum = "5BA517E5179F132F0471CB314F27FE210A10407387DA1EA4F6FD084F74469FC2" }, 20 | { name = "justin", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "justin", source = "hex", outer_checksum = "7FA0C6DB78640C6DC5FBFD59BF3456009F3F8B485BF6825E97E1EB44E9A1E2CD" }, 21 | { name = "lustre", version = "5.1.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_json", "gleam_otp", "gleam_stdlib", "houdini"], otp_app = "lustre", source = "hex", outer_checksum = "A0ADD4D936A49EE2CEBB8070F39058009122F0321D4B5445843D56E54875ECD8" }, 22 | { name = "rank", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "rank", source = "hex", outer_checksum = "5660E361F0E49CBB714CC57CC4C89C63415D8986F05B2DA0C719D5642FAD91C9" }, 23 | { name = "simplifile", version = "2.2.1", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "C88E0EE2D509F6D86EB55161D631657675AA7684DAB83822F7E59EB93D9A60E3" }, 24 | { name = "term_size", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "term_size", source = "hex", outer_checksum = "D00BD2BC8FB3EBB7E6AE076F3F1FF2AC9D5ED1805F004D0896C784D06C6645F1" }, 25 | { name = "trie_again", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "trie_again", source = "hex", outer_checksum = "5B19176F52B1BD98831B57FDC97BD1F88C8A403D6D8C63471407E78598E27184" }, 26 | ] 27 | 28 | [requirements] 29 | birdie = { version = ">= 1.3.0 and < 2.0.0" } 30 | gleam_stdlib = { version = ">= 0.60.0 and < 2.0.0" } 31 | gleeunit = { version = "~> 1.0" } 32 | lustre = { version = ">= 5.1.0 and < 6.0.0" } 33 | -------------------------------------------------------------------------------- /src/modem.ffi.mjs: -------------------------------------------------------------------------------- 1 | import { Ok, Error } from "./gleam.mjs"; 2 | import { Some, None } from "../gleam_stdlib/gleam/option.mjs"; 3 | import { Uri, to_string } from "../gleam_stdlib/gleam/uri.mjs"; 4 | 5 | // CONSTANTS ------------------------------------------------------------------- 6 | 7 | const defaults = { 8 | handle_external_links: false, 9 | handle_internal_links: true, 10 | }; 11 | 12 | const initial_location = globalThis?.window?.location?.href; 13 | 14 | // EXPORTS --------------------------------------------------------------------- 15 | 16 | export const do_initial_uri = () => { 17 | if (!initial_location) { 18 | return new Error(undefined); 19 | } else { 20 | return new Ok(uri_from_url(new URL(initial_location))); 21 | } 22 | }; 23 | 24 | export const do_init = (dispatch, options = defaults) => { 25 | document.addEventListener("click", (event) => { 26 | const a = find_anchor(event.target); 27 | 28 | if (!a) return; 29 | 30 | try { 31 | const url = new URL(a.href); 32 | const uri = uri_from_url(url); 33 | const is_external = url.host !== window.location.host; 34 | 35 | if (!options.handle_external_links && is_external) return; 36 | if (!options.handle_internal_links && !is_external) return; 37 | 38 | event.preventDefault(); 39 | 40 | if (!is_external) { 41 | window.history.pushState({}, "", a.href); 42 | window.requestAnimationFrame(() => { 43 | // The browser automatically attempts to scroll to an element with a matching 44 | // id if a hash is present in the URL. Because we need to `preventDefault` 45 | // the event to prevent navigation, we also need to manually scroll to the 46 | // element if a 47 | if (url.hash) { 48 | document.getElementById(url.hash.slice(1))?.scrollIntoView(); 49 | } 50 | }); 51 | } 52 | 53 | return dispatch(uri); 54 | } catch { 55 | return; 56 | } 57 | }); 58 | 59 | window.addEventListener("popstate", (e) => { 60 | e.preventDefault(); 61 | 62 | const url = new URL(window.location.href); 63 | const uri = uri_from_url(url); 64 | 65 | window.requestAnimationFrame(() => { 66 | if (url.hash) { 67 | document.getElementById(url.hash.slice(1))?.scrollIntoView(); 68 | } 69 | }); 70 | 71 | dispatch(uri); 72 | }); 73 | 74 | window.addEventListener("modem-push", ({ detail }) => { 75 | dispatch(detail); 76 | }); 77 | 78 | window.addEventListener("modem-replace", ({ detail }) => { 79 | dispatch(detail); 80 | }); 81 | }; 82 | 83 | export const do_push = (uri) => { 84 | window.history.pushState({}, "", to_string(uri)); 85 | window.requestAnimationFrame(() => { 86 | if (uri.fragment[0]) { 87 | document.getElementById(uri.fragment[0])?.scrollIntoView(); 88 | } 89 | }); 90 | 91 | window.dispatchEvent(new CustomEvent("modem-push", { detail: uri })); 92 | }; 93 | 94 | export const do_replace = (uri) => { 95 | window.history.replaceState({}, "", to_string(uri)); 96 | window.requestAnimationFrame(() => { 97 | if (uri.fragment[0]) { 98 | document.getElementById(uri.fragment[0])?.scrollIntoView(); 99 | } 100 | }); 101 | 102 | window.dispatchEvent(new CustomEvent("modem-replace", { detail: uri })); 103 | }; 104 | 105 | export const do_load = (uri) => { 106 | window.location = to_string(uri); 107 | }; 108 | 109 | export const do_forward = (steps) => { 110 | if (steps < 1) return; 111 | 112 | for (let i = 0; i < steps; i++) { 113 | try { 114 | window.history.forward(); 115 | } catch { 116 | continue; 117 | } 118 | } 119 | }; 120 | 121 | export const do_back = (steps) => { 122 | if (steps < 1) return; 123 | 124 | for (let i = 0; i < steps; i++) { 125 | try { 126 | window.history.back(); 127 | } catch { 128 | continue; 129 | } 130 | } 131 | }; 132 | 133 | // UTILS ----------------------------------------------------------------------- 134 | 135 | const find_anchor = (el) => { 136 | if (!el || el.tagName === "BODY") { 137 | return null; 138 | } else if (el.tagName === "A") { 139 | return el; 140 | } else { 141 | return find_anchor(el.parentElement); 142 | } 143 | }; 144 | 145 | const uri_from_url = (url) => { 146 | return new Uri( 147 | /* scheme */ url.protocol 148 | ? new Some(url.protocol.slice(0, -1)) 149 | : new None(), 150 | /* userinfo */ new None(), 151 | /* host */ url.hostname ? new Some(url.hostname) : new None(), 152 | /* port */ url.port ? new Some(Number(url.port)) : new None(), 153 | /* path */ url.pathname, 154 | /* query */ url.search ? new Some(url.search.slice(1)) : new None(), 155 | /* fragment */ url.hash ? new Some(url.hash.slice(1)) : new None(), 156 | ); 157 | }; 158 | -------------------------------------------------------------------------------- /src/modem.gleam: -------------------------------------------------------------------------------- 1 | //// > **modem**: a device that converts signals produced by one type of device 2 | //// > (such as a computer) to a form compatible with another (such as a 3 | //// > telephone) – [Merriam-Webster](https://www.merriam-webster.com/dictionary/modem) 4 | //// 5 | //// Modem is a little library for Lustre that helps you manage navigation and URLs 6 | //// in the browser. It converts url requests into messages that you can handle 7 | //// in your app's update function. Modem isn't a router, but it can help you 8 | //// build one! 9 | //// 10 | //// 11 | 12 | // IMPORTS --------------------------------------------------------------------- 13 | 14 | import gleam/bool 15 | import gleam/list 16 | import gleam/option.{type Option, None} 17 | import gleam/result 18 | import gleam/uri.{type Uri, Uri} 19 | import lustre 20 | import lustre/dev/query.{type Query} 21 | import lustre/dev/simulate.{type Simulation} as lustre_simulate 22 | import lustre/effect.{type Effect} 23 | import lustre/vdom/vattr.{Attribute} 24 | import lustre/vdom/vnode.{Element} 25 | 26 | // CONSTANTS ------------------------------------------------------------------- 27 | 28 | const relative: Uri = Uri( 29 | scheme: None, 30 | userinfo: None, 31 | host: None, 32 | port: None, 33 | path: "", 34 | query: None, 35 | fragment: None, 36 | ) 37 | 38 | // TYPES ----------------------------------------------------------------------- 39 | 40 | pub type Options { 41 | Options( 42 | /// Enable this option if you'd like to trigger your url change handler when 43 | /// a link to the same domain is clicked. When enabled, internal links will 44 | /// _always_ update the url shown in the browser's address bar but they _won't_ 45 | /// trigger a page load. 46 | /// 47 | handle_internal_links: Bool, 48 | /// Enable this option if you'd like to trigger your url change handler even 49 | /// when the link is to some external domain. You might want to do this for 50 | /// example to save some state in your app before the user navigates away. 51 | /// 52 | /// You will need to manually call [`load`](#load) to actually navigate to 53 | /// the external link! 54 | /// 55 | handle_external_links: Bool, 56 | ) 57 | } 58 | 59 | // QUERIES --------------------------------------------------------------------- 60 | 61 | /// Get the `Uri` of the page when it first loaded. This can be useful to read 62 | /// in your own app's `init` function so you can choose the correct initial 63 | /// route for your app. 64 | /// 65 | /// To subscribe to changes in the uri when a user navigates around your app, see 66 | /// the [`init`](#init) and [`advanced`](#advanced) functions. 67 | /// 68 | /// > **Note**: this function is only meaningful when run in the browser. When 69 | /// > run in a backend JavaScript environment or in Erlang this function will 70 | /// > always fail. 71 | /// 72 | @external(javascript, "./modem.ffi.mjs", "do_initial_uri") 73 | pub fn initial_uri() -> Result(Uri, Nil) { 74 | Error(Nil) 75 | } 76 | 77 | // EFFECTS --------------------------------------------------------------------- 78 | 79 | /// Initialise a simple modem that intercepts internal links and sends them to 80 | /// your update function through the provided handler. 81 | /// 82 | /// > **Note**: this effect is only meaningful in the browser. When executed in 83 | /// > a backend JavaScript environment or in Erlang this effect will always be 84 | /// > equivalent to `effect.none()` 85 | /// 86 | pub fn init(handler: fn(Uri) -> msg) -> Effect(msg) { 87 | use dispatch <- effect.from 88 | use <- bool.guard(!lustre.is_browser(), Nil) 89 | use uri <- do_init 90 | 91 | uri 92 | |> handler 93 | |> dispatch 94 | } 95 | 96 | @external(javascript, "./modem.ffi.mjs", "do_init") 97 | fn do_init(_handler: fn(Uri) -> Nil) -> Nil { 98 | Nil 99 | } 100 | 101 | /// Initialise an advanced modem that lets you configure what types of links to 102 | /// intercept. Take a look at the [`Options`](#options) type for info on what 103 | /// can be configured. 104 | /// 105 | /// > **Note**: this effect is only meaningful in the browser. When executed in 106 | /// > a backend JavaScript environment or in Erlang this effect will always be 107 | /// > equivalent to `effect.none()` 108 | /// 109 | pub fn advanced(options: Options, handler: fn(Uri) -> msg) -> Effect(msg) { 110 | use dispatch <- effect.from 111 | use <- bool.guard(!lustre.is_browser(), Nil) 112 | use uri <- do_advanced(_, options) 113 | 114 | uri 115 | |> handler 116 | |> dispatch 117 | } 118 | 119 | @external(javascript, "./modem.ffi.mjs", "do_init") 120 | fn do_advanced(_handler: fn(Uri) -> Nil, _options: Options) -> Nil { 121 | Nil 122 | } 123 | 124 | /// Push a new relative route onto the browser's history stack. This will not 125 | /// trigger a full page reload. 126 | /// 127 | /// **Note**: if you push a new uri while the user has navigated using the back 128 | /// or forward buttons, you will clear any forward history in the stack! 129 | /// 130 | /// > **Note**: this effect is only meaningful in the browser. When executed in 131 | /// > a backend JavaScript environment or in Erlang this effect will always be 132 | /// > equivalent to `effect.none()` 133 | /// 134 | pub fn push( 135 | path: String, 136 | query: Option(String), 137 | fragment: Option(String), 138 | ) -> Effect(msg) { 139 | use _ <- effect.from 140 | use <- bool.guard(!lustre.is_browser(), Nil) 141 | 142 | do_push(Uri(..relative, path: path, query: query, fragment: fragment)) 143 | } 144 | 145 | @external(javascript, "./modem.ffi.mjs", "do_push") 146 | fn do_push(_uri: Uri) -> Nil { 147 | Nil 148 | } 149 | 150 | /// Replace the current uri in the browser's history stack with a new relative 151 | /// route. This will not trigger a full page reload. 152 | /// 153 | /// > **Note**: this effect is only meaningful in the browser. When executed in 154 | /// > a backend JavaScript environment or in Erlang this effect will always be 155 | /// > equivalent to `effect.none()` 156 | /// 157 | pub fn replace( 158 | path: String, 159 | query: Option(String), 160 | fragment: Option(String), 161 | ) -> Effect(msg) { 162 | use _ <- effect.from 163 | use <- bool.guard(!lustre.is_browser(), Nil) 164 | 165 | do_replace(Uri(..relative, path: path, query: query, fragment: fragment)) 166 | } 167 | 168 | @external(javascript, "./modem.ffi.mjs", "do_replace") 169 | fn do_replace(_uri: Uri) -> Nil { 170 | Nil 171 | } 172 | 173 | /// Load a new uri. This will always trigger a full page reload even if the uri 174 | /// is relative or the same as the current page. 175 | /// 176 | /// **Note**: if you load a new uri while the user has navigated using the back 177 | /// or forward buttons, you will clear any forward history in the stack! 178 | /// 179 | /// > **Note**: this effect is only meaningful in the browser. When executed in 180 | /// > a backend JavaScript environment or in Erlang this effect will always be 181 | /// > equivalent to `effect.none()` 182 | /// 183 | pub fn load(uri: Uri) -> Effect(msg) { 184 | use _ <- effect.from 185 | use <- bool.guard(!lustre.is_browser(), Nil) 186 | 187 | do_load(uri) 188 | } 189 | 190 | @external(javascript, "./modem.ffi.mjs", "do_load") 191 | fn do_load(_uri: Uri) -> Nil { 192 | Nil 193 | } 194 | 195 | /// The browser maintains a history stack of all the url's the user has visited. 196 | /// This function lets you move forward the given number of steps in that stack. 197 | /// If you reach the end of the stack, further attempts to go forward will do 198 | /// nothing (unfortunately time travel is not quite possible yet). 199 | /// 200 | /// **Note**: you can go _too far forward_ and end up navigating the user off your 201 | /// app if you're not careful. 202 | /// 203 | /// > **Note**: this effect is only meaningful in the browser. When executed in 204 | /// > a backend JavaScript environment or in Erlang this effect will always be 205 | /// > equivalent to `effect.none()` 206 | /// 207 | pub fn forward(steps: Int) -> Effect(msg) { 208 | use _ <- effect.from 209 | use <- bool.guard(!lustre.is_browser(), Nil) 210 | 211 | do_forward(steps) 212 | } 213 | 214 | @external(javascript, "./modem.ffi.mjs", "do_forward") 215 | fn do_forward(_steps: Int) -> Nil { 216 | Nil 217 | } 218 | 219 | /// The browser maintains a history stack of all the url's the user has visited. 220 | /// This function lets you move back the given number of steps in that stack. 221 | /// If you reach the beginning of the stack, further attempts to go back will do 222 | /// nothing (unfortunately time travel is not quite possible yet). 223 | /// 224 | /// **Note**: if you navigate back and then [`push`](#push) a new url, you will 225 | /// clear the forward history of the stack. 226 | /// 227 | /// **Note**: you can go _too far back_ and end up navigating the user off your 228 | /// app if you're not careful. 229 | /// 230 | /// > **Note**: this effect is only meaningful in the browser. When executed in 231 | /// > a backend JavaScript environment or in Erlang this effect will always be 232 | /// > equivalent to `effect.none()` 233 | /// 234 | pub fn back(steps: Int) -> Effect(msg) { 235 | use _ <- effect.from 236 | use <- bool.guard(!lustre.is_browser(), Nil) 237 | 238 | do_back(steps) 239 | } 240 | 241 | @external(javascript, "./modem.ffi.mjs", "do_back") 242 | fn do_back(_steps: Int) -> Nil { 243 | Nil 244 | } 245 | 246 | // 247 | 248 | /// Simulate a click on a link in the browser that would trigger a navigation. 249 | /// This will dispatch a message to the simulated application if the link's `href` 250 | /// is valid and would cause an internal navigation. 251 | /// 252 | /// The base URL is necessary to resolve relative links. It should be a full 253 | /// complete URL, typically the one you would use for the live version of your app. 254 | /// For example: 255 | /// 256 | /// - `https://lustre.build` 257 | /// 258 | /// - `http://localhost:1234` 259 | /// 260 | /// - `https://gleam.run/news` 261 | /// 262 | /// Modem can simulate links that are relative to that base URL such as `./wibble`, 263 | /// absolute paths like `/wobble`, or full URLs **as long as their origin matches 264 | /// the base URL**. 265 | /// 266 | /// External links will log a problem in the simulation's history. Links with an 267 | /// empty `href` attribute will be ignored. 268 | /// 269 | pub fn simulate( 270 | simulation: Simulation(model, msg), 271 | link query: Query, 272 | base route: String, 273 | on_url_change handler: fn(Uri) -> msg, 274 | ) -> Simulation(model, msg) { 275 | result.unwrap_both({ 276 | use base <- result.try(result.replace_error( 277 | uri.parse(route), 278 | lustre_simulate.problem( 279 | simulation, 280 | "ModemInvalidBaseURL", 281 | "`" <> route <> "` is not a valid base URL", 282 | ), 283 | )) 284 | 285 | use origin <- result.try(result.replace_error( 286 | uri.origin(base), 287 | lustre_simulate.problem( 288 | simulation, 289 | "ModemInvalidBaseURL", 290 | "`" <> route <> "` is not a valid base URL", 291 | ), 292 | )) 293 | 294 | // The following is a sequence of crimes that should *never* be performed in 295 | // a real application. Introspecting the vdom is not supported by Lustre and 296 | // is liable to break at any time: relying on internals exempts you from semver! 297 | // 298 | // If you need to do this for some reason, please open an issue on the Lustre 299 | // repo so we can learn about your user case: 300 | // 301 | // https://github.com/lustre-labs/lustre/issues/new 302 | // 303 | use target <- result.try(result.replace_error( 304 | query.find(in: lustre_simulate.view(simulation), matching: query), 305 | lustre_simulate.problem( 306 | simulation, 307 | name: "EventTargetNotFound", 308 | message: "No element matching " <> query.to_readable_string(query), 309 | ), 310 | )) 311 | 312 | use attributes <- result.try(case target { 313 | Element(tag: "a", attributes:, ..) -> Ok(attributes) 314 | _ -> 315 | Error(lustre_simulate.problem( 316 | simulation, 317 | name: "ModemInvalidTarget", 318 | message: "Target must be an tag", 319 | )) 320 | }) 321 | 322 | use href <- result.try(result.replace_error( 323 | list.find_map(attributes, fn(attribute) { 324 | case attribute { 325 | Attribute(name: "href", value:, ..) -> Ok(value) 326 | _ -> Error(Nil) 327 | } 328 | }), 329 | lustre_simulate.problem( 330 | simulation, 331 | name: "ModemMissingHref", 332 | message: "Target must have an `href` attribute", 333 | ), 334 | )) 335 | 336 | use relative <- result.try(result.replace_error( 337 | uri.parse(href), 338 | lustre_simulate.problem( 339 | simulation, 340 | name: "ModemInvalidHref", 341 | message: "`" <> href <> "` is not a valid URL", 342 | ), 343 | )) 344 | 345 | use _ <- result.try(case uri.origin(relative) { 346 | Ok(relative_origin) if origin != relative_origin -> 347 | Error(lustre_simulate.problem( 348 | simulation, 349 | name: "ModemExternalUrl", 350 | message: "`" <> href <> "` is an external URL and cannot be simulated", 351 | )) 352 | 353 | _ -> Ok(Nil) 354 | }) 355 | 356 | use resolved <- result.try(result.replace_error( 357 | uri.merge(base, relative), 358 | lustre_simulate.problem( 359 | simulation, 360 | name: "ModemInvalidBaseURL", 361 | message: "`" <> route <> "` is not a valid base URL", 362 | ), 363 | )) 364 | 365 | Ok(lustre_simulate.message(simulation, handler(resolved))) 366 | }) 367 | } 368 | -------------------------------------------------------------------------------- /test/apps/example.gleam: -------------------------------------------------------------------------------- 1 | import gleam/result 2 | import gleam/uri.{type Uri} 3 | import lustre/attribute 4 | import lustre/effect.{type Effect} 5 | import lustre/element.{type Element} 6 | import lustre/element/html 7 | import modem 8 | 9 | pub type Route { 10 | Wibble 11 | Wobble 12 | } 13 | 14 | pub fn init(_) -> #(Route, Effect(Msg)) { 15 | let route = 16 | modem.initial_uri() 17 | |> result.map(fn(uri) { uri.path_segments(uri.path) }) 18 | |> fn(path) { 19 | case path { 20 | Ok(["wibble"]) -> Wibble 21 | Ok(["wobble"]) -> Wobble 22 | _ -> Wibble 23 | } 24 | } 25 | 26 | #(route, modem.init(on_url_change)) 27 | } 28 | 29 | pub fn on_url_change(uri: Uri) -> Msg { 30 | case uri.path_segments(uri.path) { 31 | ["wibble"] -> OnRouteChange(Wibble) 32 | ["wobble"] -> OnRouteChange(Wobble) 33 | _ -> OnRouteChange(Wibble) 34 | } 35 | } 36 | 37 | pub type Msg { 38 | OnRouteChange(Route) 39 | } 40 | 41 | pub fn update(_, msg: Msg) -> #(Route, Effect(Msg)) { 42 | case msg { 43 | OnRouteChange(route) -> #(route, effect.none()) 44 | } 45 | } 46 | 47 | pub fn view(route: Route) -> Element(Msg) { 48 | html.div([], [ 49 | html.nav([], [ 50 | html.a([attribute.href("/wibble")], [element.text("Go to wibble")]), 51 | html.a([attribute.href("/wobble")], [element.text("Go to wobble")]), 52 | ]), 53 | case route { 54 | Wibble -> html.h1([], [element.text("You're on wibble")]) 55 | Wobble -> html.h1([], [element.text("You're on wobble")]) 56 | }, 57 | ]) 58 | } 59 | -------------------------------------------------------------------------------- /test/modem_test.gleam: -------------------------------------------------------------------------------- 1 | import gleeunit 2 | 3 | pub fn main() { 4 | gleeunit.main() 5 | } 6 | -------------------------------------------------------------------------------- /test/simulations/simulate_navigation_test.gleam: -------------------------------------------------------------------------------- 1 | // IMPORTS --------------------------------------------------------------------- 2 | 3 | import apps/example 4 | import birdie 5 | import gleam/string 6 | import lustre/dev/query 7 | import lustre/dev/simulate 8 | import lustre/element 9 | import modem 10 | 11 | // TESTS ----------------------------------------------------------------------- 12 | 13 | pub fn simulate_single_navigation_test() { 14 | let app = 15 | simulate.application(example.init, example.update, example.view) 16 | |> simulate.start(Nil) 17 | |> modem.simulate( 18 | link: query.element(query.attribute("href", "/wobble")), 19 | base: "http://localhost:1234", 20 | on_url_change: example.on_url_change, 21 | ) 22 | 23 | let assert Ok(el) = 24 | query.find( 25 | in: simulate.view(app), 26 | matching: query.element(matching: query.text("You're on wobble")), 27 | ) 28 | 29 | el 30 | |> element.to_readable_string 31 | |> birdie.snap("Simulate a single navigation") 32 | } 33 | 34 | pub fn simulate_invalid_base_url_test() { 35 | let app = 36 | simulate.application(example.init, example.update, example.view) 37 | |> simulate.start(Nil) 38 | |> modem.simulate( 39 | link: query.element(query.attribute("href", "/wobble")), 40 | base: "invalid-url", 41 | on_url_change: example.on_url_change, 42 | ) 43 | 44 | simulate.history(app) 45 | |> string.inspect 46 | |> birdie.snap("Simulate an invalid base URL") 47 | } 48 | --------------------------------------------------------------------------------