├── .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 | [](https://hex.pm/packages/modem)
4 | [](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 | [](https://hex.pm/packages/app)
4 | [](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 |
--------------------------------------------------------------------------------