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
- Getting Started
- Frontmatter
- Syntax Highlighting
- Index Pages
- Defining Paths
- Extended Markdown
- Local Development
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
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
.
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 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>
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
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";
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";
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";
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.
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.
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"
.
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.
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}
.
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)$",
# ...
]
]