• Stars
    star
    60
  • Rank 485,525 (Top 10 %)
  • Language
    Elixir
  • License
    MIT License
  • Created over 1 year ago
  • Updated 12 months ago

Reviews

There are no reviews yet. Be the first to send feedback to the community and the maintainers!

Repository Details

Blogs, docs, and static pages in Phoenix

Phoenix Pages

Add blogs, documentation, and other static pages to Phoenix apps. This library integrates seamlessly into your router and comes with built-in support for rendering markdown with frontmatter, syntax highlighting, compile-time caching, and more.

Installation

def deps do
  [
    {:phoenix_pages, "~> 0.1"}
  ]
end

The recommended way to install into your Phoenix application is to add this to your router function in lib/myapp_web.ex, replacing myapp with the name of your application:

def router do
  quote do
    use Phoenix.Router, helpers: false
    use PhoenixPages, otp_app: :myapp

    # ...
  end
end

Getting Started

Now you can add a new route using the pages/4 macro:

scope "/", MyAppWeb do
  pipe_through :browser

  get "/", PageController, :home
  pages "/:page", PageController, :show, from: "priv/pages/**/*.md"
end

This will read all the markdown files from priv/pages and create a new GET route for each one. The :page segment will be replaced with the path and filename (without the extension) relative to the base directory (see Defining Paths).

You'll also need to add the :show handler to lib/myapp_web/controllers/page_controller.ex:

defmodule MyAppWeb.PageController do
  use MyAppWeb, :controller

  # ...

  def show(conn, _params) do
    render(conn, "show.html")
  end
end

Lastly, add a template at lib/myapp_web/controllers/page_html/show.html.heex. The page's rendered markdown will be available in the inner_content assign:

<main>
  <%= @inner_content %>
</main>

That's it! Now try creating a file at priv/pages/hello.md and visiting /hello.

Formatting

To prevent mix format from adding parenthesis to the pages macro similar to the other Phoenix Router macros, add :phoenix_pages to .formatter.exs:

[
  import_deps: [:ecto, :ecto_sql, :phoenix, :phoenix_pages]
]

Frontmatter

Frontmatter allows page-specific variables to be included at the top of a markdown file using the YAML format. If you're setting frontmatter variables (which is optional), they must be the first thing in the file and must be set between triple-dashed lines:

---
title: Hello World
---
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut
labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris
nisi ut aliquip ex ea commodo consequat.

To specify which frontmatter values are expected in each page, set the attrs option:

pages "/:page", PageController, :show,
  from: "priv/pages/**/*.md",
  attrs: [:title, author: nil]

Atom values will be considered required, and a compilation error will be thrown if missing from any of the pages. Key-values must come last in the list, and will be considered optional by defining a default value. Any frontmatter values not defined in the attributes list will be silently discarded.

Valid attribute values will be available in the assigns:

<main>
  <h1><%= @title %></h1>
  <h2 :if={@author}><%= @author %></h2>

  <%= @inner_content %>
</main>

Syntax Highlighting

Phoenix Pages uses the Makeup project for syntax highlighting. To enable, add a lexer for your specific language(s) to the project dependencies. Phoenix Pages will pick up the new dependency and start highlighting your code blocks without any further configuration. No lexers are included by default.

Lexers
  • C - `{:makeup_c, "~> 0.0"}`
  • Diff - `{:makeup_diff, "~> 0.0"}`
  • Elixir - `{:makeup_elixir, "~> 0.0"}`
  • Erlang - `{:makeup_erlang, "~> 0.0"}`
  • GraphQL - `{:makeup_graphql, "~> 0.0"}`
  • (H)EEx - `{:makeup_eex, "~> 0.0"}`
  • HTML - `{:makeup_html, "~> 0.0"}`
  • Javascript - `{:makeup_js, "~> 0.0"}`
  • JSON - `{:makeup_json, "~> 0.0"}`
  • Rust - `{:makeup_rust, "~> 0.0"}`
  • SQL - `{:makeup_sql, "~> 0.0"}`

If your language of choice isn't supported, consider writing a new Makeup lexer to contribute to the community. Otherwise, you can use a JS-based syntax highlighter such as highlight.js by setting code_class_prefix: "language-" and syntax_highlighting: false in render_options.

Next, import a theme listed below into your CSS bundle. The specifics of doing this highly depend on your CSS configuration, but a few examples are included below. In most cases, you will need to import phoenix_pages/css/monokai.css (or whatever theme you choose) into your bundle and ensure deps is included as a vendor directory.

Themes

ESBuild Example

Using the ESBuild installer, add the env option to config/config.exs:

config :esbuild,
  version: "0.17.18",
  default: [
    cd: Path.expand("../assets", __DIR__),
    env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)},
    args: ~w(--bundle --outdir=../priv/static/assets js/app.js)
  ]

Then in app.js:

import "phoenix_pages/css/monokai.css";

Sass Example

Using the Sass installer, add the --load-path flag to config/config.exs:

config :dart_sass,
  version: "1.62.0",
  default: [
    cd: Path.expand("../assets", __DIR__),
    args: ~w(--load-path=../deps css/app.scss ../priv/static/assets/app.css)
  ]

Then in app.scss:

@import "phoenix_pages/css/monokai";

Tailwind Example

Install the postcss-import plugin as described here and add the following to assets/postcss.config.js:

module.exports = {
  plugins: {
    "postcss-import": {}
  }
}

Then in app.css:

@import "../../deps/phoenix_pages/css/monokai";

Index Pages

To create an index page with links to all the other pages, create a normal GET route and use the id option alongside get_pages/1 and get_pages!/1 in your router:

get "/blog", BlogController, :index

pages "/blog/:page", BlogController, :show,
  id: :blog,
  from: "priv/blog/**/*.md",
  attrs: [:title, :author, :date]
defmodule MyAppWeb.BlogController do
  use MyAppWeb, :controller

  def index(conn, _params) do
    pages = MyAppWeb.Router.get_pages!(:blog)

    conn
    |> assign(:pages, pages)
    |> render("index.html")
  end

  def show(conn, _params) do
    render(conn, "show.html")
  end
end
<.link :for={page <- @pages} navigate={page.path}>
  <%= page.assigns.title %>
</.link>

All the page files are read and cached during compilation, so the get_pages functions will not actually read anything from the filesystemโ€”making them very performant.

Sorting

The pages returned from the get_pages functions will be sorted by filename. If you want to specify a different order during compilation rather than in the controller on every page load, use the sort option:

pages "/blog/:page", BlogController, :show,
  id: :blog,
  from: "priv/blog/**/*.md",
  attrs: [:title, :author, :date],
  sort: {:date, :desc}

Any attribute value from the frontmatter can be defined as the sort value.

Defining Paths

When defining the pages path, the :page segment will be replaced for each generated page during compilation with the values derived from ** and *. This is different than segments in regular routes, which are parsed during runtime into the params attribute of the controller function.

For example, let's say you have the following file structure:

โ”Œโ”€โ”€ priv/
โ”‚  โ”Œโ”€โ”€ pages/
โ”‚  โ”‚  โ”Œโ”€โ”€ foo.md
โ”‚  โ”‚  โ”œโ”€โ”€ bar/
โ”‚  โ”‚  โ”‚  โ”Œโ”€โ”€ baz.md

Defining pages "/:page", from: "priv/pages/**/*.md" in your router will create two routes: get "/foo" and get "/bar/baz". You can even put the :page segment somewhere else in the path, such as /blog/:page, and it will work as expected creating get "/blog/foo" and get "/blog/bar/baz".

Capture Groups

For complex scenarios, you have the option of using capture group variables instead of the :page segment.

Let's say you have the same file structure as above, but don't want the baz path to be nested under /bar. You could define pages "/$2", from: "priv/pages/**/*.md", using $2 instead of :page. This will create two routes: get "/foo" and get "/bar".

Capture group variables will contain the value of the ** and * chunks in order, starting at $1. Keep in mind that ** will match all files and zero or more directories and subdirectories, and * will match any number of characters up to the end of the filename, the next dot, or the next slash.

For more info on the wildcard patterns, check out Path.wildcard/2.

Extended Markdown

In addition to the customizable markdown options, markdown rendering also supports IAL attributes by default. Meaning you can add HTML attributes to any block-level element using the syntax {:attr}.

For example, to create a rendered output of <h1 class="foobar">Header</h1>:

# Header{:.foobar}

Attributes can be one of the following:

  • {:#id} to define an ID
  • {:.className} to define a class name
  • {:name=value}, {:name="value"}, or {:name='value'} to define any other attribute

To define multiple attributes, separate them with spaces: {:#id name=value}.

Local Development

If you add, remove, or change pages while running mix phx.server, they will automatically be replaced in the cache and you don't have to restart for them to take effect. To live reload when a page changes, add to the patterns list of the Endpoint config in config/dev.exs:

config :myapp, MyAppWeb.Endpoint,
  live_reload: [
    patterns: [
      # ...
      ~r"priv/pages/.*(md)$",
      # ...
    ]
  ]