diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml new file mode 100644 index 0000000..c93df66 --- /dev/null +++ b/.github/workflows/publish.yaml @@ -0,0 +1,22 @@ + +name: "Publish" +on: + # Run only when pushing to master branch + push: + branches: + - master +jobs: + neuron: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Build emanote site 🔧 + run: | + mkdir -p output + docker run -v $PWD:/data sridca/emanote emanote -C /data/docs gen /data/output + - name: Deploy to gh-pages 🚀 + uses: peaceiris/actions-gh-pages@v3 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./output/ + cname: ema.srid.ca \ No newline at end of file diff --git a/.gitignore b/.gitignore index f84fac6..2b845e4 100644 --- a/.gitignore +++ b/.gitignore @@ -23,4 +23,4 @@ cabal.project.local~ .ghc.environment.* result result-* -docs-output/ \ No newline at end of file +output \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index dbd571b..7c2d082 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased (0.2.0.0) +- `routeUrl`: now returns relative URLs (ie. without the leading `/`) + - Use the `` tag to specify an explicit prefix for relative URLs in generated HTML. This way hosting on GitHub Pages without CNAME will continue to have functional links. - `Ema.Slug` - Add `Ord`, `Generic`, `Data` and Aeson instances to `Slug` - Unicode normalize slugs using NFC @@ -21,7 +23,7 @@ - add wikilink helpers - TODO(doc) Add `Ema.Helper.PathTree` - Examples - - Remove Ex03_Documentation.hs (moved to separate repo, `ema-docs`) + - ~~Remove Ex03_Documentation.hs (moved to separate repo, `ema-docs`)~~ Back to ./docs, but using Emanote. - Add Ex03_Basic.hs example ## 0.1.0.0 -- 2021-04-26 diff --git a/README.md b/README.md index 60564b9..44a6e25 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # ema - + [![Hackage](https://img.shields.io/hackage/v/ema.svg?logo=haskell)](https://hackage.haskell.org/package/ema) [![FAIR](https://img.shields.io/badge/FAIR-pledge-blue)](https://www.fairforall.org/about/) diff --git a/docs/concepts.md b/docs/concepts.md new file mode 100644 index 0000000..8cdc297 --- /dev/null +++ b/docs/concepts.md @@ -0,0 +1,11 @@ +--- +order: 9 +--- + +# Concepts + +* [Hot Reload](concepts/hot-reload.md) +* [LVar](concepts/lvar.md) +* [Slug](concepts/slug.md) +* [CLI](concepts/cli.md) +* [Logging](concepts/logging.md) \ No newline at end of file diff --git a/docs/concepts/cli.md b/docs/concepts/cli.md new file mode 100644 index 0000000..330c8d8 --- /dev/null +++ b/docs/concepts/cli.md @@ -0,0 +1,11 @@ +--- +order: 4 +--- +# CLI + +Ema apps have a basic CLI argument structure that takes two kinds of input: + +1. `-C `: specifies the "input directory" (current working directory by default) +2. `gen` subcommand: generates the static site, instead of starting up the dev server + +Ema (`runEma`) will change the [current working directory](https://hackage.haskell.org/package/directory-1.3.6.1/docs/System-Directory.html#v:getCurrentDirectory) to the "input directory" before running your application code. It, along with the "gen" subcommand (if used), is passed as the `Ema.CLI.Action` type to your `render` function. You can also use `runEmaWith` if you are manually handling the CLI arguments yourself. diff --git a/docs/concepts/hot-reload.md b/docs/concepts/hot-reload.md new file mode 100644 index 0000000..0ae9ae2 --- /dev/null +++ b/docs/concepts/hot-reload.md @@ -0,0 +1,30 @@ +--- +order: 1 +--- +# Hot Reload + +**Hot Reloading** is a feature of Ema's dev server wherein any changes to your Haskell source or data files (such as Markdown files or HTML templates) _propagate instantly_ to the web browser without requiring any manual intervention like a full browser refresh. In practice, this is a such a delightful feature to work with. Imagine changing CSS style of an element, and see it reflect on your site in a split second. + +## How Ema implements hot reload + +### Websocket + +The Ema dev server uses websockets to keep a bi-directional connection open between the web browser and the backend server. When you click on a link or when something changes in the backend, they are communicated via this connection. In a statically generated site, however, no such activity happens - and a link click behaves like a normal link, in that the browser makes a full HTTP request to the linked page. + +### DOM patching + +When switching to a new route or when receiving the new HTML, Ema uses [morphdom](https://github.com/patrick-steele-idem/morphdom) to _patch_ the existing DOM tree rather than replace it in its entirety. This, in addition to use of websockets, makes it possible to support **instant** hot reload with nary a delay. + +### Haskell reload + +Finally, hot reload on _code_ changes are supported via [ghcid](https://github.com/ndmitchell/ghcid). The [template repo](https://github.com/srid/ema-template)'s `bin/run` script uses ghcid underneath. Any HTML DSL (like blaze-html -- as used by the [Tailwind helper](guide/helpers/tailwind.md)) or CSS DSL automatically gets supported for hot-reload. If you choose to use a file-based HTML template language, you can enable hot-reload on template change using the [FileSystem helper](guide/helpers/filesystem.md). + +Note that if your application makes use of threads, it is important to setup cleanup handlers so that `ghcid` doesn't leave [ghost](https://stackoverflow.com/q/24999636/55246) processes behind. Helpers like [`race_`](https://hackage.haskell.org/package/async-2.2.3/docs/Control-Concurrent-Async.html#v:race_) will do this automatically (incidentally it is used by `runEma` for running the user IO action). + +### Data reload + +For anything outside of the Haskell code, your code becomes responsible for monitoring and updating the model [LVar](concepts/lvar.md). The [filesystem helper](guide/helpers/filesystem.md) already provides utilities to facilitate this for monitoring changes to files and directories. + +## Handling errors + +If your code throws a Haskell exception, they will be gracefully handled and displayed in the browser, allowing you to recover without breaking hot-reload flow. diff --git a/docs/concepts/logging.md b/docs/concepts/logging.md new file mode 100644 index 0000000..740113d --- /dev/null +++ b/docs/concepts/logging.md @@ -0,0 +1,10 @@ +--- +order: 5 +--- +# Logging + +`runEma`'s action monad supports the `MonadLoggerIO` constraint, as defined by [monad-logger](https://hackage.haskell.org/package/monad-logger). This means that you can use any of the logging functions from `monad-logger` to add logging to your application. [monad-logger-extras](https://hackage.haskell.org/package/monad-logger-extras) is used to colorize the logs. + +```haskell +logInfoNS "myapp" "This is an info message" +logDebugNS "myapp" "This is a debug message info" \ No newline at end of file diff --git a/docs/concepts/lvar.md b/docs/concepts/lvar.md new file mode 100644 index 0000000..85899aa --- /dev/null +++ b/docs/concepts/lvar.md @@ -0,0 +1,8 @@ +--- +order: 2 +--- +# LVar + +If you are familiar with Haskell's `stm` package, a `LVar` is essentially a [`TMVar`](https://hackage.haskell.org/package/stm-2.5.0.0/docs/Control-Concurrent-STM-TMVar.html) but with an extra ability for other threads to observe changes. Ema uses it for [hot reload](concepts/hot-reload.md), and your application code is expected to set and update its [model](guide/model.md) through the LVar. + +Documentation on `LVar` is available [on Hackage](https://hackage.haskell.org/package/lvar). \ No newline at end of file diff --git a/docs/concepts/slug.md b/docs/concepts/slug.md new file mode 100644 index 0000000..d6d11be --- /dev/null +++ b/docs/concepts/slug.md @@ -0,0 +1,16 @@ +--- +order: 3 +--- +# Slug + +A slug is a component of a URL or file path. In an _URL_ like `/foo/bar`, there are two _slugs_: "foo" and "bar". URLs (as well as filepaths) can therefore be represented as lists of slugs (`[Slug]`). + +```haskell +import Ema (Slug) + +type URL = [Slug] +``` + +Slugs are integral to Ema's routing system. When defining [route](guide/routes.md) encoders and decoders (via [Ema class instance](guide/class.md)) you are effectively writing functions that convert back and forth between your route type and `[Slug]`. These functions are ultimately used to determine the *filename* of the statically generated HTML (i.e., `./foo/bar.html`) as well as the linking URL in the rendered HTML (i.e., `/foo/bar`). + +Slugs are also automatically [unicode normalized](https://www.unicode.org/faq/normalization.html) to NFC to ensure that route links work reliably regardless of the underlying representation of any non-ascii link text. \ No newline at end of file diff --git a/docs/favicon.svg b/docs/favicon.svg new file mode 100644 index 0000000..fcfc3c7 --- /dev/null +++ b/docs/favicon.svg @@ -0,0 +1,114 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/guide.md b/docs/guide.md new file mode 100644 index 0000000..0113b53 --- /dev/null +++ b/docs/guide.md @@ -0,0 +1,13 @@ +--- +order: 2 +--- + +# Guide + +After having familiarized yourself with Ema by following the [earlier section](start/tutorial.md), you are now ready to dive deep into learning how to achieve specific things. + +* [Defining your model](guide/model.md) -- [Define your site model such that it supports hot reload]{.item-intro} +* [Working with routes](guide/routes.md) -- [Unless you site has a single page (`index.html`), you will need to manage a set of routes]{.item-intro} +* [Defining Ema instance](guide/class.md) -- [Constrain your `model` and `route` to work with static sites]{.item-intro} +* [Rendering HTML](guide/render.md) -- [You could use plain strings to build HTML, or use templates, or use one of the delightful Haskell DSLs]{.item-intro} +* [Helpers](guide/helpers.md) -- [Bring Your Own Libraries, or choose from existing helpers]{.item-intro} diff --git a/docs/guide/class.md b/docs/guide/class.md new file mode 100644 index 0000000..867edac --- /dev/null +++ b/docs/guide/class.md @@ -0,0 +1,52 @@ +--- +order: 3 +--- +# Defining Ema instance + +Once you have [model](guide/model.md) and [route](guide/routes.md) types in place, we must tell the Haskell compiler that they are suitable for generating static sites. We do this by creating an instance of the `Ema` typeclass. + +Using some `MyModel` and the route `Route` shown in the [previous](guide/routes.md) section, we can create an instance as follows: + +```haskell +class Ema MyModel Route where + -- Convert our route to browser URL, represented as a list of slugs + encodeRoute = \case + Index -> [] -- An empty slug represents the index route: index.html + About -> ["about"] + + -- Convert back the browser URL, represented as a list of slugs, to our route + decodeRoute = \case + [] -> Just Index + ["about"] -> Just About + _ -> Nothing + + -- The third method is optional; and used during static site generation. + -- This tells Ema which routes to generate .html files for. + -- By default, Enum & Bounded will be used to determine this list. + staticRoutes model = + [Index, About] + + -- The fourth method is also optional; if you have static assets to serve, specify + -- them here. Paths are relative to current working directory. + staticAssets Proxy = + ["css", "images", "favicon.ico", "resume.pdf"] +``` + +The `Ema` typeclass has four methods, with the last two of them being optional with default implementations: + +1. Define `encodeRoute` that converts our route type to a browser URL [slug](concepts/slug.md) path representing relative URLs like `/foo/bar` +2. Define `decodeRoute` that does the *reverse* converstion (the conversion must be isomorphic) +3. _Optionally_, define `staticRoutes` indicating the routes to statically generate +4. _Optionally_, define the list of static assets to copy over as-is during static generation + +## `runEma` + +The `Ema` constraint is used by the `runEma` function that acts as the main entry point to your static site generator. It takes two arguments: + +1. `render` function that renders your HTML (we'll go over this in [the next](guide/render.md) section) +2. an IO action that takes [`LVar model`](guide/model.md) as an argument. + +This IO action is expected to be a long-running one, wherein you have full control over setting the value of the model over time. + +{.last} +[Next]{.next}, with our model and routes in place constrained by `Ema` type class, [we will define the HTML for our site](guide/render.md) using Ema. diff --git a/docs/guide/helpers.md b/docs/guide/helpers.md new file mode 100644 index 0000000..8b1d2ea --- /dev/null +++ b/docs/guide/helpers.md @@ -0,0 +1,10 @@ +--- +order: 5 +--- +# Helpers + +Beyond the model and route types, Ema leaves it up to you as to how to develop your site. The following are not *required* when using Ema; nevertheless they are useful inasmuch as they capture common patterns in writing a good static site: + +* [Tailwind + Blaze](guide/helpers/tailwind.md) -- [We recommend--but not mandate--Tailwind for CSS and blaze-html as HTML DSL]{.item-intro} +* [Working with files](guide/helpers/filesystem.md) -- [Ema provides a helper to support hot-reload on files]{.item-intro} +* [Converting Markdown](guide/helpers/markdown.md) -- [Pointers on how to work with Markdown files]{.item-intro} \ No newline at end of file diff --git a/docs/guide/helpers/filesystem.md b/docs/guide/helpers/filesystem.md new file mode 100644 index 0000000..f71483e --- /dev/null +++ b/docs/guide/helpers/filesystem.md @@ -0,0 +1,56 @@ +--- +order: 2 +--- +# Working with files + +If your static site is generated depending on local files on disk, the general flow of things is as follows: + +```haskell +runEma render $ \model -> do + -- Load everything on launch + initialModel <- loadFilesAndBuildModel + LVar.set model initialModel + -- Continue to monitor and update the model + observeFileSystem $ \action -> + LVar.modify model $ applyAction action +``` + +For monitoring local files on disk you would typically use something like [fsnotify](https://hackage.haskell.org/package/fsnotify) in place of `observeFileSystem`. What is the point of doing this? To support [hot reload](concepts/hot-reload.md) on _data_ change. Imagine that your static site is generated based on Markdown files as well as HTML templates on disk. If either the Markdown file, or a HTML template file is modified, we want the web browser to hot reload the updated HTML *instantly*. This is enabled by storing both these kinds of files in the application [model](guide/model.md) and using [LVar](concepts/lvar.md) to update it *over time*. + +For filesystem changes, Ema provides a helper based on `fsnotify` in the `Ema.Helper.FileSystem` module. You can use it as follows + +```haskell +import qualified Ema.Helper.FileSystem as FileSystem + +type Model = Map FilePath Text + +Ema.runEma render $ \model -> do + LVar.set model =<< do + mdFiles <- FileSystem.filesMatching "." ["**/*.md"] + forM mdFiles readFileText + <&> Map.fromList + FileSystem.onChange "." $ \fp -> \case + FileSystem.Update -> + when (takeExtension fp == ".md") $ do + log $ "Update: " <> fp + s <- readFileText fp + LVar.modify model $ Map.insert fp s + FileSystem.Delete -> + whenJust (takeExtension fp == ".md") $ do + log $ "Delete: " <> fp + LVar.modify model $ Map.delete fp +``` + +In most cases, however, you probably want to use the higher level function `mountOnLVar`. It "mounts" the files you specify onto the [model LVar](concepts/lvar.md) such that any changes to them are *automatically* reflected in your [model](guide/model.md) value. + +```haskell +Ema.runEma render $ \model -> do + FileSystem.mountOnLVar "." ["**/*.md"] model $ \fp -> \case + FileSystem.Update -> do + s <- readFileText fp + pure $ Map.insert fp s + FileSystem.Delete -> + pure $ Map.delete fp +``` + +[Full example here](https://github.com/srid/ema-template/blob/master/src/Main.hs). \ No newline at end of file diff --git a/docs/guide/helpers/markdown.md b/docs/guide/helpers/markdown.md new file mode 100644 index 0000000..7d764cc --- /dev/null +++ b/docs/guide/helpers/markdown.md @@ -0,0 +1,33 @@ +--- +order: 3 +--- + +# Using Markdown + +There are quite a few packages to convert Markdown to HTML, + +- [Pandoc](https://hackage.haskell.org/package/pandoc) -- [Supports formats other than Markdown]{.item-intro} +- [commonmark-hs](https://github.com/jgm/commonmark-hs) -- [Lightweight parser by the same author of Pandoc]{.item-intro} +- [mmark](https://github.com/mmark-md/mmark) -- [*Strict* Markdown parser]{.item-intro} + +## Helper + +Ema provides a helper to parse Markdown files with YAML frontmatter, using commonmark-hs. If you are parsing front matter, you can use any type that has a [`FromYAML`](https://hackage.haskell.org/package/HsYAML-0.2.1.0/docs/Data-YAML.html#t:FromYAML) instance. + +```haskell +import qualified Ema.Helper.Markdown as Markdown + +-- Front matter metadata can be any type with a `FromYAML` instance +-- +-- Using a `Map` is a lazy way to capture metadata, but in real code we +-- generally define a sum type and manually derive `FromYAML` for it. +type Metadata = Map Text Text + +-- Returns `Either Text (Metadata, Pandoc)` +Markdown.parseMarkdownWithFrontMatter @Metadata + "test.md" "Hello *world*" +``` + +The template repo, as well as [Emanote](https://github.com/srid/emanote) (used to generate this site), uses this helper to parse Markdown files into Pandoc AST. Consult [the template repo's source code](https://github.com/srid/ema-template/blob/master/src/Main.hs) for details. + +Note that with Ema you can get [hot reload](concepts/hot-reload.md) support for your Markdown files using [filesystem notifications](guide/helpers/filesystem.md). diff --git a/docs/guide/helpers/tailwind.md b/docs/guide/helpers/tailwind.md new file mode 100644 index 0000000..f08e04a --- /dev/null +++ b/docs/guide/helpers/tailwind.md @@ -0,0 +1,19 @@ +--- +order: 1 +--- +# Using Tailwind CSS + +The `Ema.Helper.Tailwind` module provides a `layout` function that uses [twind](https://twind.dev/) shim that is used in the statically generated site, and otherwise uses Tailwind CSS from CDN in the dev server mode. This helper is for those that **use [Tailwind CSS](https://tailwindcss.com/) in conjunction with [blaze-html](https://hackage.haskell.org/package/blaze-html) DSL**. + +To use the layout helper in your [render](guide/render.md) function: + +```haskell +render :: Ema.CLI.Action -> MyModel -> MyRoute -> LByteString +render emaAction model route = do + Tailwind.layout emaAction (H.title "My site") $ do + H.p "Hello world" +``` + +The very site you are viewing (ema.srid.ca) is a live demonstration of this helper. + +**Note** that because the [twind JS shim](https://twind.dev/handbook/the-shim.html) is used to support Tailwind styles your site will not render properly on web browsers with JavaScript disabled if you use this helper; it might also have trouble interoperating with other JS initializers on the site. See [this issue](https://github.com/srid/ema/issues/20) for upcoming alternatives. diff --git a/docs/guide/model.md b/docs/guide/model.md new file mode 100644 index 0000000..6f03230 --- /dev/null +++ b/docs/guide/model.md @@ -0,0 +1,33 @@ +--- +order: 1 +--- +# Defining your model + +A "*model*" in Ema represents the state to use to generate your site. It could be as simple as a variable, or it could be a list of parsed Markdown files (as in the case of a weblog). Ema's model is also conceptually similar to [Elm](https://guide.elm-lang.org/architecture/)'s model, in that - changing the model [automatically](concepts/hot-reload.md) changes the [view](guide/render.md). + +Here's an example model: + +```haskell +newtype BlogPosts = BlogPosts (Map Slug Text} +``` + +Here `BlogPosts` is the model type. If we are generating a weblog site, then all the "data" we need is loaded into memory as a value of `BlogPosts`. + +## Modifying the model + +Ema's dev server supports [hot reload](concepts/hot-reload.md); it will observe changes to your model, in addition to code. To facilitate this you will manage your model as a [LVar](concepts/lvar.md). The `runEma` function ([described here](guide/class.md)) takes an IO action that gets `LVar model` as an argument. + +For example, + +```haskell +runEma render $ \model -> + forever $ do + LVar.set model =<< liftIO getCurrentTime + liftIO $ threadDelay $ 1 * 1000000 +``` + +In this contrived example ([full code here](https://github.com/srid/ema/blob/master/src/Ema/Example/Ex02_Clock.hs)), we are using `UTCTime` as the model. We set the initial value using `LVar.set`, and then continually update the current time every second. Every time the model gets updated, the web browser will [hot reload](concepts/hot-reload.md) to display the up to date value. For the `BlogPosts` model, you would typically use [fsnotify](https://hackage.haskell.org/package/fsnotify) to monitor changes to the underlying Markdown files, but note that Ema provides [a helper](guide/helpers/filesystem.md) for that. + + +{.last} +[Next]{.next}, we will [talk about routes](guide/routes.md). \ No newline at end of file diff --git a/docs/guide/render.md b/docs/guide/render.md new file mode 100644 index 0000000..a185a78 --- /dev/null +++ b/docs/guide/render.md @@ -0,0 +1,44 @@ +--- +order: 4 +--- +# Rendering HTML + +Once you have [model](guide/model.md) and [routes](guide/routes.md) in place and [constrained](guide/class.md), the last piece of the puzzle is to write a function that takes both as arguments and returns the HTML string (lazy bytestring, to be exact). This function can be as simple as the following: + +```haskell +render :: MyModel -> Route -> ByteString +render model route = + "Hello, world!" +``` + +Of course we want it to be real, by using our model value, as well as generate the HTML based on the route. We will also use the [blaze-html](https://hackage.haskell.org/package/blaze-html) library to make writing HTML in Haskell palatable (see also [the layout helper](guide/helpers/tailwind.md)). A more realistic starting point (if not the finishing product) would be: + +```haskell +render :: MyModel -> Route -> ByteString +render model route = Blaze.renderHtml $ + H.html $ do + H.head $ do + H.title "My site" + H.body $ do + H.h1 "My site" + case route of + Index -> + H.h1 "Welcome to my website!" + H.p $ do + "Checkout the" + H.a ! A.href (H.toValue $ Ema.routeUrl About) $ "About" + " page." + About -> + H.div $ H.p "This website is managed by yours truly" + H.footer $ do + A.a ! A.href "https://github.com/user/repo" $ "Source on GitHub" +``` + +Note that Ema provides a `routeUrl` helper function that serializes your route to the final URL (here, `/about`) for linking to. + +Spend a few moments trying to appreciate how this is *much simpler* to write than dealing with HTML template files spread across the disk as is the case with traditional static site generators. If you [choose](https://vrom911.github.io/blog/html-libraries) to go the DSL route, Haskell's type-safety now applies to your HTML as well. On top of it, Ema's [hot reload](concepts/hot-reload.md) will instantly update the dev server's browser view whenever you change your HTML (or any of the Haskell source code). + +Of course when using Ema nothing prevents you from choosing to use traditional HTML templates, and you can get [hot reload](concepts/hot-reload.md) on them too with [a little bit of plumbing](guide/helpers/filesystem.md). + +{.last} +[Next]{.next}, you might want to peruse [the helper topics](guide/helpers.md) if you need some extra functionality provided. diff --git a/docs/guide/routes.md b/docs/guide/routes.md new file mode 100644 index 0000000..a3918c7 --- /dev/null +++ b/docs/guide/routes.md @@ -0,0 +1,40 @@ +--- +order: 2 +--- +# Working with routes + +Ema gives you the freedom to use any Haskell type for representing your site routes. You don't need complicated rewrite rules. Routes are best represented using what are known as *sum types* (or ADT, short for *Abstract Data Type*). Here's an example of a route type: + +```haskell +data Route + = Index + | About +``` + +This type represents two routes pointing to -- the index page (`/`) and the about page (`/about`). Designing the route type is only half the job; you will also need to tell Ema how to convert it to / from the browser URL. We will explain this in the next section. + +## Advanced example + +Here's one possible way to design the route type for a weblog site, + +```haskell +data Route + = Home + | Blog BlogRoute + | Tag TagRoute + +data BlogRoute + = BlogIndex + | BlogPost Ema.Slug + +data TagRoute + = TagListing + | Tag Tag + +newtype Tag = Tag Text +``` + +Defining *hierarchical routes* like this is useful if you want to render *parts* of your HTML as being common to only a subset of your site, such as adding a blog header to all blog pages, but not to tag pages. + +{.last} +[Next]{.next}, with our model and routes in place, [we will make them work with Ema](guide/class.md) by defining their static site behaviour. \ No newline at end of file diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..375dd35 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,28 @@ +# Ema + +:::{.avatar} +![](/favicon.svg){.w-32 .h-32 .float-right} +::: + +{.text-xl .mb-8} +[Ema](https://github.com/srid/ema) is a next-gen **Haskell** library toolkit for building [jamstack-style](https://jamstack.org/) static sites. Ema sites are *change-aware*; in addition to good ol' static site generation, it provides a live server supporting **fast hot-reload** in the browser on code *or* data change. + +{.text-gray-600} +The ultimate goal of ema is to facilitate creating with ease your own [neuron](https://neuron.zettel.page/), or just about any app that creates a browser view of arbitrarily changing data (on disk, database, or whatever). Ema is designed to facilitate creation of apps whose data is normally *edited* via traditional mechanisms (eg: text editor) but *rendered* as a delightful web page - so as to provide an economical read-only view of your data on desktop & mobile. + +:::{.my-8} +* [Getting Started](start.md) +* [Guide](guide.md) +* [Concepts](concepts.md) +* [Ema News](https://notes.srid.ca/ema) ([RSS Feed](https://notes.srid.ca/ema.xml)) +::: + +:::{.flex .justify-center .items-center .mb-8} +```{=video} +/static/ema-demo.mp4 +``` +::: + +:::{.flex .justify-center .items-center} +[![FAIR](https://img.shields.io/badge/FAIR-pledge-blue)](https://www.fairforall.org/about/) +::: \ No newline at end of file diff --git a/docs/index.yaml b/docs/index.yaml new file mode 100644 index 0000000..1ba17f6 --- /dev/null +++ b/docs/index.yaml @@ -0,0 +1,28 @@ +template: + theme: pink + iconUrl: /favicon.svg + # Disable collapsing of folders since we don't have large number of notes. + sidebar: + collapsed: false + +pandoc: + rewriteClass: + # The "Next" navigation in series + last: mt-8 border-t-2 border-pink-500 pb-1 pl-1 bg-gray-50 rounded + next: py-2 text-xl italic font-bold + # For description of items in guide listing + item-intro: text-gray-500 + +# Put page-specific metadata here. Override them in Markdown frontmatter or +# per-folder YAML as necessary. +page: + siteName: Ema + siteTitle: Ema + description: | + Ema static site generator (Jamstack) in Haskell with live server and reload. + headHtml: | + + + + + \ No newline at end of file diff --git a/docs/start.md b/docs/start.md new file mode 100644 index 0000000..008539d --- /dev/null +++ b/docs/start.md @@ -0,0 +1,17 @@ +--- +order: 1 +--- + +# Getting Started + +As first steps, perform the following before proceeding to the tutorial section below: + +1. [Install Nix](https://nixos.org/download.html) (see [platform-specific notes here](https://neuron.zettel.page/install)) +1. [Enable Flakes](https://nixos.wiki/wiki/Flakes#Installing_flakes) +1. Clone [the template repository](https://github.com/srid/ema-template) locally +1. Run `bin/run` and access the site at + +That should start the Ema dev server displaying a simple website. Go ahead and try modifying either the Markdown content in `./content` or the Haskell source in `./src/Main.hs`, and observe how the web view updates [instantly](concepts/hot-reload.md). + +{.last} +[Next]{.next}, [in the tutorial](start/tutorial.md) let's try using this template repo to create a basic website. diff --git a/docs/start/tutorial.md b/docs/start/tutorial.md new file mode 100644 index 0000000..ec4a1e7 --- /dev/null +++ b/docs/start/tutorial.md @@ -0,0 +1,120 @@ +# Tutorial + +Make sure that you have have followed [the previous section](start.md) in order to have the [template repo](https://github.com/srid/ema-template) checked out and running locally. Here, **our goal** is to replace the source code of the template repo and write a basic site from scratch. + +1. Follow the template repo's [README](https://github.com/srid/ema-template#getting-started) and have it open in Visual Studio Code while running the dev server. Your website should be viewable at +1. Open `src/Main.hs` +1. Delete everything in it, and replace it with the following + +```haskell +module Main where + +import qualified Ema + +main :: IO () +main = do + let speaker :: Text = "Ema" + Ema.runEmaPure $ \_ -> + encodeUtf8 $ "Hello, from " <> speaker +``` + +This is the *minimum* amount of code necessary to run an Ema site. Notice that as you replace and save this file, your browser (which is at ) will [hot reload](concepts/hot-reload.md) to display "Hello, Ema". Congratulations, you just created your first website! + +## Expanding on Hello World + +Okay, but that's just *one* page. But we want to add a second page. And might as well add more content than "Hello, Ema". Let's do that next. The first step is define the [route](guide/routes.md) type that corresponds to our site's pages. Add the following: + +```haskell +data Route + = Index -- Corresponds to / + | About -- Corresponds to /about + deriving (Bounded, Enum, Show) +``` + +Next, let's define a [model](guide/model.md). A model will hold the state of our website used to render its HTML. Let's put the `speaker` variable in it, as that's all we are using: + +```haskell +data Model = Model { speaker :: Text } +``` + +We should now tell Ema how to convert our `Route` to actual URL paths. Let's do that by making an instance of the `Ema` [class](guide/class.md): + +```haskell +import Ema (Ema (..)) + +instance Ema Model Route where + encodeRoute = \case + Index -> [] -- To / + About -> ["about"] -- To /about + decodeRoute = \case + [] -> Just Index -- From / + ["about"] -> Just About -- From /about + _ -> Nothing -- Everything else, are bad routes +``` + +Now, we write the `main` entry point: + +```haskell +import Control.Concurrent (threadDelay) +import qualified Data.LVar as LVar + +main :: IO () +main = do + Ema.runEma render $ \model -> do + LVar.set model $ Model "Ema" + liftIO $ threadDelay maxBound +``` + +The `runEma` function is explained [here](guide/class.md), but in brief: it takes a render function (see below) as well as an IO action that allows us to create and update the model [lvar](concepts/lvar.md). Note that `threadDelay maxBound` here? That is because our IO action must not exit; in the dev server mode of real-world websites, you would continue to monitor the external world (such as Markdown files) and update the model, to facilitate [hot reload](concepts/hot-reload.md) of data used by your site. + +On final piece of the puzzle is to write the aforementioned `render` function: + +```haskell +import qualified Ema.CLI +import qualified Ema.Helper.Tailwind as Tailwind +import Text.Blaze.Html5 ((!)) +import qualified Text.Blaze.Html5 as H +import qualified Text.Blaze.Html5.Attributes as A +import qualified Text.Blaze.Html.Renderer.Utf8 as RU + +render :: Ema.CLI.Action -> Model -> Route -> LByteString +render _emaAction model r = RU.renderHtml $ + H.html $ do + H.head $ do + H.title "Basic site" + H.body $ do + H.div ! A.class_ "container" $ do + case r of + Index -> do + H.toHtml $ + "You are on the index page. The name is " <> speaker model + routeElem About "Go to About" + About -> do + "You are on the about page. " + routeElem Index "Go to Index" + where + routeElem targetRoute w = + H.a + ! A.style "text-decoration: underline" + ! A.href (H.toValue $ Ema.routeUrl targetRoute) $ w +``` + +If everything compiles, you should see the site update in the web browser. A couple of quick points about the `render` function: + +1. It should return the raw HTML as a `ByteString`. Here, we use [blaze-html](https://hackage.haskell.org/package/blaze-html) as HTML DSL. You can also use your own HTML templates of course. +1. It uses `Ema.routeUrl` function to create a URL out of our `Route` type. This function uses the [`Ema` typeclass](guide/class.md), so it uses the `encodeRoute` function defined further above. + +On final note, you will note that nothing is actually *generated* so far. This is because Ema has been running in the dev server mode, which is quite useful during development. To actually generate the files, you can use the `gen` command when running the [CLI](concepts/cli.md): + +```sh +mkdir ./output +nix run . -- -C ./content gen ./output +``` + +## Exercises + +1. Figure out how to use static assets (images, files) in your static sites (hint: the typeclass) +2. What happens if you `throw` an exception or use `error` in the `render` function? + +{.last} +[Next]{.next}, checkout the [Guide](guide.md) series for information on specific topics. diff --git a/docs/static/ema-demo.mp4 b/docs/static/ema-demo.mp4 new file mode 100644 index 0000000..80ec27c Binary files /dev/null and b/docs/static/ema-demo.mp4 differ diff --git a/docs/static/manifest.json b/docs/static/manifest.json new file mode 100644 index 0000000..577feb6 --- /dev/null +++ b/docs/static/manifest.json @@ -0,0 +1,10 @@ +{ + "short_name": "Ema", + "name": "Ema static site generator", + "description": "Ema static site generator (Jamstack) in Haskell", + "start_url": "/", + "background_color": "#DB2777", + "display": "standalone", + "scope": "/", + "theme_color": "#DB2777" +} \ No newline at end of file diff --git a/flake.nix b/flake.nix index 7ad8288..7eff8dd 100644 --- a/flake.nix +++ b/flake.nix @@ -47,12 +47,5 @@ # Used by `nix develop` devShell = emaProject true; - - # Used by `nix run` (for docs) - apps.${name} = flake-utils.lib.mkApp { - drv = ema; - exePath = "/bin/ema-docs"; - }; - defaultApp = apps.${name}; }); } diff --git a/hie.yaml b/hie.yaml index 566ec27..08ab24f 100644 --- a/hie.yaml +++ b/hie.yaml @@ -2,5 +2,3 @@ cradle: cabal: - path: "./src" component: "library:ema" - - path: "./docs" - component: "exe:ema-docs" diff --git a/src/Ema/Route.hs b/src/Ema/Route.hs index 60adccf..268aab3 100644 --- a/src/Ema/Route.hs +++ b/src/Ema/Route.hs @@ -17,12 +17,16 @@ import Ema.Route.Slug (Slug (unSlug), decodeSlug, encodeSlug) import Ema.Route.UrlStrategy ( UrlStrategy (..), slugFileWithStrategy, - slugUrlWithStrategy, + slugRelUrlWithStrategy, ) +-- | Return the relative URL of the given route +-- +-- As the returned URL is relative, you will have to either make it absolute (by +-- prepending with `/`) or set the `` URL in your HTML head element. routeUrl :: forall a r. Ema a r => r -> Text routeUrl r = - slugUrlWithStrategy def (encodeRoute @a r) + slugRelUrlWithStrategy def (encodeRoute @a r) routeFile :: forall a r. Ema a r => r -> FilePath routeFile r = diff --git a/src/Ema/Route/UrlStrategy.hs b/src/Ema/Route/UrlStrategy.hs index 63eaec3..b331e81 100644 --- a/src/Ema/Route/UrlStrategy.hs +++ b/src/Ema/Route/UrlStrategy.hs @@ -19,17 +19,17 @@ data UrlStrategy instance Default UrlStrategy where def = UrlStrategy_HtmlOnlySansExt -slugUrlWithStrategy :: UrlStrategy -> [Slug] -> Text -slugUrlWithStrategy strat slugs = +slugRelUrlWithStrategy :: UrlStrategy -> [Slug] -> Text +slugRelUrlWithStrategy strat slugs = case strat of UrlStrategy_FolderOnly -> - "/" <> T.intercalate "/" (encodeSlug <$> slugs) + T.intercalate "/" (encodeSlug <$> slugs) UrlStrategy_HtmlOnlySansExt -> case nonEmpty slugs of Nothing -> - "/" + "" Just (removeLastIf (decodeSlug "index") -> slugsWithoutIndex) -> - "/" <> T.intercalate "/" (encodeSlug <$> slugsWithoutIndex) + T.intercalate "/" (encodeSlug <$> slugsWithoutIndex) where removeLastIf :: Eq a => a -> NonEmpty a -> [a] removeLastIf x xs =