{"id":22726081,"url":"https://github.com/orus-io/elm-spa","last_synced_at":"2025-04-13T20:46:39.218Z","repository":{"id":40548762,"uuid":"404845167","full_name":"orus-io/elm-spa","owner":"orus-io","description":"Pure Elm library to easily build Single Page Applications","archived":false,"fork":false,"pushed_at":"2024-07-04T18:23:35.000Z","size":116,"stargazers_count":47,"open_issues_count":2,"forks_count":8,"subscribers_count":5,"default_branch":"master","last_synced_at":"2025-04-13T20:46:33.835Z","etag":null,"topics":["elm","single-page-app","spa"],"latest_commit_sha":null,"homepage":"https://package.elm-lang.org/packages/orus-io/elm-spa/latest/","language":"Elm","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/orus-io.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null}},"created_at":"2021-09-09T19:25:02.000Z","updated_at":"2024-09-28T09:45:37.000Z","dependencies_parsed_at":"2022-09-24T04:11:17.908Z","dependency_job_id":null,"html_url":"https://github.com/orus-io/elm-spa","commit_stats":null,"previous_names":[],"tags_count":6,"template":false,"template_full_name":null,"repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/orus-io%2Felm-spa","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/orus-io%2Felm-spa/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/orus-io%2Felm-spa/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/orus-io%2Felm-spa/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/orus-io","download_url":"https://codeload.github.com/orus-io/elm-spa/tar.gz/refs/heads/master","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":248782275,"owners_count":21160716,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["elm","single-page-app","spa"],"created_at":"2024-12-10T16:15:43.552Z","updated_at":"2025-04-13T20:46:39.199Z","avatar_url":"https://github.com/orus-io.png","language":"Elm","funding_links":[],"categories":[],"sub_categories":[],"readme":"# Orus.io Elm-Spa\n\nThis package provides tools to easily build Single Page Applications.\n\nIt provides the same features as [ryannhg/elm-spa](https://www.elm-spa.dev/)\nbut without any code generation.\n\nThe key idea to avoid code generation is taken from\n[insurello/elm-ui-explorer](https://package.elm-lang.org/packages/insurello/elm-ui-explorer/latest/)\n(see the aknowlegments).\n\n## Quickstart\n\nThe easiest way to start using Elm-Spa is to copy the one of the [example\napplications](https://github.com/orus-io/elm-spa/tree/master/example)\nand adapt it to your needs.\n\nTo better understand the example code, keep reading, we'll cover all the\nconcepts.\n\n## Running your application\n\nWhen developing, it is recommended to use ``elm-live`` to run the application:\n\n```sh\nelm-live --pushstate -- src/Main.elm\n```\n\n## In-depth\n\n- [App construction](#app-construction)\n- [Shared state](#shared-state)\n- [Routing](#routing)\n- [Identity management](#identity-management)\n- [Pages](#pages)\n  - [Adding pages](#adding-pages)\n  - [Effect](#effect)\n  - [Page constructor](#page-constructor)\n    - [static page](#static-page)\n    - [sandbox page](#sandbox-page)\n    - [element page](#element-page)\n- [Finalize](#finalize)\n\n### App construction\n\nSetting up the application consists in a pipeline that initialise the application,\nthen add pages to it, and finally build a record suitable for `Browser.application`\n\n```elm\nmain =\n    Spa.init2\n        { defaultView = View.defaultView\n        , extractIdentity = Shared.identity\n        }\n        |\u003e Spa.addPublicPage mappers Route.matchHome Home.page\n        |\u003e Spa.addPublicPage mappers Route.matchSignIn SignIn.page\n        |\u003e Spa.addProtectedPage mappers Route.matchCounter Counter.page\n        |\u003e Spa.addPublicPage mappers Route.matchTime Time.page\n        |\u003e Spa.application View.map\n            { toRoute = Route.toRoute\n            , init = Shared.init\n            , update = Shared.update\n            , subscriptions = Shared.subscriptions\n            , toDocument = toDocument\n            , protectPage = Route.toUrl \u003e\u003e Just \u003e\u003e Route.SignIn \u003e\u003e Route.toUrl\n            }\n        |\u003e Browser.application\n```\n\nIn the following sections we describe the different steps of this pipeline by\nexplaining the concepts.\n\n### Routing\n\nA hand-written Elm SPA application generally have a central `Route` type. The\nurls are parsed into a `Route` (there is a good example of that in the\n[documentation](https://package.elm-lang.org/packages/elm/url/latest/Url-Parser#parse)),\nwhich is in turn used for deciding which page should be currently displayed.\n\nOrus Elm-Spa allows to use such a type, and the first thing for that is to give\na `toRoute` function to `Spa.init2`, so it is capable to parse an incoming URL\ninto your own custom `Route` type.\n\n```elm\nmain =\n    -- ...\n        |\u003e Spa.application View.map\n            { toRoute = Route.toRoute\n            -- ...\n            }\n```\n\nBecause it doesn't generate any code, Orus Elm-Spa is not able to do a\n`case ... of` on the route, so you will need to provide a match function for\neach page, more on that a bit further but don't worry: it is dead easy and\neven provides a nice way to pass arguments of the route to your page.\n\n### Shared state\n\nThe whole application will share a single TEA component that we generally call\n`Shared`. It can be anything you want, as long as you provide `init`, and\n`update` and `subscriptions` functions.\n\nSo, given a simple `Shared` module exposing the shared model and its\ninit/update/subscriptions functions, this is how you plug your shared state\nin the application:\n\n```elm\nmain =\n    Spa.init2\n        { defaultView = View.defaultView\n        -- ...\n        }\n        -- |\u003e Spa.addXxxxPage\n        |\u003e Spa.application View.map\n            { -- ...\n            , init = Shared.init\n            , update = Shared.update\n            , subscriptions = Shared.subscriptions\n            -- ...\n            }\n```\n\nIf your application doesn't need a shared state, Elm-Spa provides an alternative\nconstructor that will produce a no-op shared state for you (`Spa.noSharedInit`).\n\nThe `defaultView` property is the default view that will be used when no other\npages could be viewed, which should be _never_ once your app is properly setup\n(more on that a little further).\n\n\n### Identity management\n\nThe pages of the application can be 'protected', which means they cannot be\naccessed unless the user is authenticated.\n\nFor that, Orus Elm-Spa needs two things:\n\n- a way to extract the current identity from the shared state:\n  `extractIdentity`. It is a simple function that returns a `Maybe identity`\n  from a `Shared` record. Note that the actual `identity` type can be anything\n  you want.\n\n- a fallback URL if the user attempt to access a protected page when\n  unauthenticated: `protectPage`. Its role is to return a new URL (as a string),\n  and is given the current route that the user attempted to access.\n  It allows to build a URL that contains the original route in a 'redirect'\n  query parameters, which is very useful.\n\n```elm\nmain =\n    Spa.init2\n        { -- ...\n        , extractIdentity = Shared.identity\n        }\n        -- |\u003e Spa.addXxxxPage\n        |\u003e Spa.application View.map\n            { -- ...\n            , protectPage = Route.toUrl \u003e\u003e Just \u003e\u003e Route.SignIn \u003e\u003e Route.toUrl\n            }\n```\n\n### Pages\n\nWe now have a inialised application, and we can add pages to it.\n\nA page is a small TEA app on its own, it has `Msg`, `Model`, `init`, `update`,\n`subscriptions` and `view`.\nIt differs from a normal application in a few different ways:\n\n- The page constructor is given the shared state, and optionnaly the identity if\n  required.\n\n- The `init` and `update` functions return `Effect Msg` instead of `Cmd Msg`.\n\n- The `init` function takes a `flags` only argument that is the output of the\n  page `match` function (see below).\n\n- The `view` function returns a `View Msg`, which can be whatever you define.\n\n#### Adding pages\n\nAdding a page to an application is done by calling the `Spa.addPublicPage`\nor the `Spa.addProtectedPage` function. It takes 3 arguments:\n\n- `mappers` is a Tuple of view mappers. For example, if the application view is\n  a `Html msg`, the mappers will be: `( Html.map, Html.map )`. The duplication\n  is for technical reasons (see the `addPage` function implementation).\n\n- `match` is a function that takes a route and returns the page flags if and\n  only if the route matches the page. This is the place were information can\n  be extracted from the route to be given to the page `init` function.\n\n  A simple match function can be:\n\n  ```elm\n  matchHome : Route -\u003e Maybe ()\n  matchHome route =\n      case route of\n          Home -\u003e\n              Just ()\n          _ -\u003e\n              Nothing\n  ```\n\n  A match function that extract information:\n\n  ```elm\n  matchSignIn : Route -\u003e Maybe (Maybe String)\n  matchSignIn route =\n      case route of\n          SignIn redirect -\u003e\n              Just redirect\n          _ -\u003e\n              Nothing\n  ```\n\n- `page` is a page constructor. A public page constructor is a function that\n  takes the shared state:\n\n  ```elm\n  page : shared -\u003e Page\n  ```\n\n  A protected page constructor takes the current\n  identity in addition to the shared state:\n\n  ```elm\n  page : shared -\u003e identity -\u003e Page\n  ```\n\n#### Effect\n\nAn [`Effect`](Effect#Effect) works the same as a `Cmd`, but can also carry\nmessages and commands of the `Shared` module when sent from a `Page`. It is the\nonly way for a page to interract with the shared state.\n\n#### Page constructor\n\nA public page constructor takes the shared state and returns init, update,\nsubscriptions and view functions.\n\nA protected page constructor takes both the shared state and the current\nidentity and returns the same thing as a public page constructor.\n\nFor pages that requires less (static pages, message-less pages), helpers\nprovide simple ways to build pages.\n\n##### Static page\n\nA static page has no internal state, only a static view:\n\n```elm\npage shared =\n    Spa.Page.static view\n```\n\nThe view function could take 'shared' and its only argument:\n\n```elm\npage shared =\n    Spa.Page.static (view shared)\n```\n\n##### Sandbox page\n\nA sandbox page has an internal state but no effects:\n\n```\n-- this is a protected page constructor, it takes 'identity' as its second parameter\npage shared identity =\n    Spa.Page.sandbox\n        { init = init\n        , update = update\n        , view = view\n        }\n```\n\n##### Element page\n\nA element page has a state, effects and subscriptions:\n\n```\npage shared =\n    Spa.Page.element\n        { init = init\n        , update = update\n        , view = view\n        , subscriptions = subscriptions\n        }\n```\n\n#### Page extra properties\n\n##### Page onNewFlags\n\nWhen a route change actually points to the same page as before, but with\ndifferent flags, the default behavior is to call the page 'init' function.\n\nIn some case it is sub-optimal. For example a page may use the query parameters\nto store the current query: changing it should not reload the page completely.\n\nFor such situations, the page can be sent a custom message that will inform it\nthat the flags have changed.\n\n```\n\ntype Flags\n    = String  -- or anything the route can produce\n\n\ntype Msg\n    = Noop\n    | OnNewFlags Flags\n    -- | ...\n\n\npage shared =\n    Spa.Page.element\n        { init = init\n        , update = update\n        , view = view\n        , subscriptions = subscriptions\n        }\n        |\u003e Spa.Page.onNewFlags OnNewFlags\n```\n\n#### View\n\nEach page returns a 'View msg', which can be anything you want as long as you\ncan provide a `map : (msg -\u003e msg1) -\u003e View msg -\u003e View msg1` function, and\na function to convert the View into a Document msg.\n\nA typical view type based on elm-ui can be:\n\n```elm\n{-| The application custom view type\n-}\ntype alias View msg =\n    { title : String\n    , body : Element msg\n    }\n\n\n{-| we must have a map function for it\n-}\nmap : (msg -\u003e msg1) -\u003e View msg -\u003e View msg1\nmap tomsg view =\n    { title = view.title\n    , body = Element.map tomsg view.body\n    }\n\n\n{-| change the view into a Document\n-}\ntoDocument : Shared -\u003e View msg -\u003e Document msg\ntoDocument _ view =\n    { title = view.title\n    , body = Element.layout [] view.body\n    }\n```\n\nNote that using elm-ui is not a requirement, you can totally use Html instead.\n\n### Finalize\n\nOnce all the pages are added to the application, we can change it into a record\nsuitable for the `Browser.application` function.\n\nThis operation is done by the `Spa.application` function, that takes the\nparameters we described earlier, along with the `toDocument` function.\n\nThe first parameter is the view mapper (again).\n\n```elm\n-- ...\n        |\u003e Spa.application View.map\n            { toRoute = Route.toRoute\n            , protectPage = Route.toUrl \u003e\u003e Just \u003e\u003e Route.SignIn \u003e\u003e Route.toUrl\n            , init = Shared.init\n            , update = Shared.update\n            , subscriptions = Shared.subscriptions\n            , toDocument = toDocument\n            }\n        |\u003e Browser.application\n```\n\n## Migrating an existing application\n\nWhen porting an application to orus-io/elm-spa, the general plan is:\n\n- add a standalone [PageStack](https://package.elm-lang.org/packages/orus-io/elm-spa/2.0.0/Spa-PageStack) in the root component of the application\n- move the pages to the stack, one by one\n- replace the root component with Spa.application\n\nThe details will vary depending on how the program is architecture, but\nif it follows the principles of\n[rtfeldman/elm-spa-example](https://github.com/rtfeldman/elm-spa-example/)\nit should go smoothly.\n\nTo make thinks easier for everyone, we demonstrate the incremental\nmigration of elm-spa-example in this\n[pull request](https://github.com/rtfeldman/elm-spa-example/pull/106).\n\n\n## Acknowlegments\n\nThis package borrows brilliant ideas and concepts from many packages, but most\nnotably:\n\n- [ryannhg/elm-spa](https://www.elm-spa.dev/), for the simple yet powerful idea\n  of a Shared state and a Effect module\n- [insurello/elm-ui-explorer](https://package.elm-lang.org/packages/insurello/elm-ui-explorer/latest/),\n  for the mind-blowing pattern for building a generic complex type layer by\n  layer, each layer nesting the model and message of the previous one.\n- [Janiczek/cmd-extra](https://package.elm-lang.org/packages/Janiczek/cmd-extra/latest/),\n  for the .withXXX and .addXXX API\n- [rtfeldman/elm-spa-example](https://github.com/rtfeldman/elm-spa-example/)\n  for being the general inspiration of a proper SPA architecture.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Forus-io%2Felm-spa","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Forus-io%2Felm-spa","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Forus-io%2Felm-spa/lists"}