Astral ✨

Hex.pmDocumentation

Volt-powered static site generation for Elixir. Astral owns site semantics — pages, routes, Markdown, frontmatter, layouts, public files, and static HTML output — while Volt handles TypeScript, CSS, assets, dev-server integration, and HMR.

mix igniter.install astral
mix astral.dev
mix astral.build

Astral is intentionally separate from Volt. Volt remains the Vite-like frontend toolchain; Astral is the site framework built on top.

Why Astral

Static site generators often force site configuration, content rules, and frontend tooling into JavaScript. Astral keeps the site layer in ordinary Elixir while reusing Volt's BEAM-native asset pipeline.

You get:

Status

Astral is early, but the first release is useful for small static sites and documentation prototypes. Content collections, plugin-generated routes, feed/sitemap plugins, and collection pagination have landed on master after v0.1.0. See ROADMAP.md for the planned path toward an Astro-class framework.

Installation

Install into an existing Mix project with Igniter:

mix igniter.install astral

Or add the dependency manually:

def deps do
[
{:astral, "~> 0.1.0"}
]
end

Then scaffold a starter site:

mix astral.new

The scaffold creates astral.config.exs, starter Markdown pages, an EEx layout, TypeScript/CSS assets, public files, tsconfig.json, and Volt JS/TS formatting/linting configuration.

Project layout

astral.config.exs
pages/
index.md
about.md
layouts/
default.html
assets/
app.ts
styles.css
public/
robots.txt

.astral HEEx templates

Astral can render .astral pages, layouts, and local components. The format is HEEx-first: interpolation, attributes, :if/:for, function components, and slots use Phoenix's HEEx semantics, but the output is static HTML for SSG.

components/
pill.astral
pages/
index.astral
layouts/
default.astral
<!-- components/pill.astral -->
<div class="pill">
{render_slot(@inner_block)}
</div>
<!-- pages/index.astral -->
---
assigns = assign(assigns, :title, "Home")
---
<h1>{@title}</h1>
<.pill>Elixir</.pill>
<!-- layouts/default.astral -->
<!doctype html>
<html lang="en">
<body>
<main data-route={@route}>{@content}</main>
</body>
</html>

Configure the component directory if you do not use the default components/:

site do
components "ui"
layout "default.astral"
end

Configuration

Astral config is real Elixir and returns an %Astral.Config{} struct. No global app env is required for site settings.

# astral.config.exs
import Astral.Config
site do
root "."
outdir "dist"
pages "pages"
public "public"
layouts "layouts" do
default "default.html"
end
assets "assets" do
entry "app.ts"
url_prefix "/assets"
end
end

Content collections

Astral collections group Markdown entries such as posts, docs, changelog items, or authors. JSONSpec-style typespec maps are the preferred schema definition style, with Zoi also supported.

import Astral.Config
site do
collections do
collection :posts, "content/posts" do
permalink "/blog/:slug/"
layout "post.html"
schema %{
required(:title) => String.t(),
required(:date) => String.t(),
optional(:draft) => boolean(),
optional(:tags) => [String.t()]
}
end
end
end

Zoi schemas can be used when runtime transformations or refinements are useful:

collection :posts, "content/posts" do
schema Zoi.map(%{title: Zoi.string(), tags: Zoi.array(Zoi.string()) |> Zoi.optional()}, coerce: true)
end

Collection entries are validated, exposed to layouts as @collections, and rendered as static pages at their collection permalink:

<%= for post <- @collections.posts do %>
<a href={post.route_path}><%= post.data.title %></a>
<% end %>

post.metadata keeps the original string-keyed frontmatter. post.data contains schema-normalized data. Entry layouts also receive @entry for the current collection entry.

Markdown pages also expose headings for table-of-contents layouts. Astral renders stable heading anchors and stores heading metadata on @page.headings:

<nav>
<%= for heading <- @page.headings do %>
<a href="#<%= heading.id %>"><%= heading.text %></a>
<% end %>
</nav>

Each heading is an %Astral.Heading{level, id, text}.

Collection helpers are available for layouts and plugins:

posts =
@site
|> Astral.Collection.entries(:posts)
|> Astral.Collection.published()
|> Astral.Collection.sort_by_date(:desc)
tags = Astral.Collection.tags(posts)

Collection pagination

Astral includes a small collection pagination plugin built from generic route and pagination primitives. It keeps tags/categories userland instead of inventing a taxonomy API.

site do
plugins [
{Astral.Plugin.CollectionPages,
collection: :posts,
pattern: "/blog/*page",
page_size: 10,
layout: "blog.html"}
]
end

The *page route parameter omits page one, producing routes such as:

/blog/
/blog/2/
/blog/3/

The pagination layout receives @page, @collection, @site, @collections, @routes, and @route assigns:

<h1>Blog</h1>
<%= for entry <- @page.entries do %>
<article>
<h2><a href="<%= entry.route_path %>"><%= entry.data.title %></a></h2>
</article>
<% end %>
<nav>
<%= if @page.urls.previous do %>
<a href="<%= @page.urls.previous %>">Previous</a>
<% end %>
<%= if @page.urls.next do %>
<a href="<%= @page.urls.next %>">Next</a>
<% end %>
</nav>

For custom generated indexes, use the lower-level helpers directly:

entries
|> Astral.Pagination.pages(pattern: "/blog/*page", page_size: 10)
|> Astral.Pagination.routes(site.config, assigns: %{collection: :posts})

Userland tag pages

Astro treats tag pages as userland dynamic routes. Astral follows the same approach: use ordinary Elixir with the pagination primitives instead of a built-in taxonomy abstraction.

defmodule MySite.TagPages do
@behaviour Astral.Plugin
def name, do: "tag-pages"
def routes(site) do
entries =
site
|> Astral.Collection.entries(:posts)
|> Astral.Collection.published()
|> Astral.Collection.sort_by_date(:desc)
entries
|> all_tags()
|> Enum.flat_map(fn tag ->
tagged_entries = Enum.filter(entries, &(tag in Map.get(&1.data, :tags, [])))
tagged_entries
|> Astral.Pagination.pages(
pattern: "/tags/:tag/*page",
params: %{tag: tag},
page_size: 10
)
|> Astral.Pagination.routes(site.config,
kind: :tag_pages,
assigns: %{tag: tag, collection: :posts}
)
end)
end
def render_route(%Astral.Route{kind: :tag_pages} = route, site) do
layout = Map.fetch!(site.layouts, "tag.html")
Astral.Layout.render_route("", layout, route, site)
end
def render_route(_route, _site), do: nil
defp all_tags(entries) do
entries
|> Enum.flat_map(&Map.get(&1.data, :tags, []))
|> Enum.uniq()
|> Enum.sort()
end
end

A tag layout can use both @tag and the normal pagination assigns:

<h1>Posts tagged <%= @tag %></h1>
<%= for entry <- @page.entries do %>
<a href="<%= entry.route_path %>"><%= entry.data.title %></a>
<% end %>

Plugins

Astral plugins mirror Volt's plugin shape: implement Astral.Plugin, configure modules or {module, opts} tuples, and optionally return :pre or :post from enforce/0 to control ordering.

# astral.config.exs
import Astral.Config
site do
plugins [
MySite.SEOPlugin,
{MySite.AnalyticsPlugin, id: "G-XXXX"}
]
end
defmodule MySite.AnalyticsPlugin do
@behaviour Astral.Plugin
@impl true
def name, do: "analytics"
@impl true
def render_page(html, _page, _site, opts) do
id = Keyword.fetch!(opts, :id)
{:ok, String.replace(html, "</body>", ~s(<script data-id="#{id}"></script></body>))}
end
end

Available hooks include config/1, build_start/1, site_discovered/1, routes/1, render_route/2, render_page/3, and build_done/1. Tuple options are passed to callbacks that define one extra argument, such as render_page/4.

Astral includes plugin-shaped feed and sitemap generators:

site do
plugins [
{Astral.Plugin.Feed,
site_url: "https://example.com",
title: "My Blog",
author: "Astral",
collection: :posts},
{Astral.Plugin.Sitemap,
site_url: "https://example.com",
changefreq: :weekly,
priority: fn page -> if page.route_path == "/", do: 1.0, else: 0.7 end}
]
end

Plugins can add generated routes for feeds, sitemaps, pagination, or tag pages:

defmodule MySite.FeedPlugin do
@behaviour Astral.Plugin
@impl true
def name, do: "feed"
@impl true
def routes(site) do
[Astral.Route.new("/feed.xml", site.config, content_type: "application/atom+xml")]
end
@impl true
def render_route(%Astral.Route{path: "/feed.xml"}, site) do
{:ok, MySite.Feed.render(site.entries.posts)}
end
def render_route(_route, _site), do: nil
end

XML DSL

Astral uses XM for feed and sitemap XML. XM is a small Saxy-backed XML DSL extracted from Astral so XML generation stays generic and reusable.

import XM
document do
urlset xmlns: "http://www.sitemaps.org/schemas/sitemap/0.9" do
for page <- pages do
url do
loc site_url <> page.route_path
lastmod page.date
end
end
end
end

XM supports attributes, nested elements, dynamic tag "name" nodes, loops, conditionals, comments, text nodes, CDATA, binary rendering, and iodata rendering while Saxy handles XML escaping and encoding.

Pages and frontmatter

Markdown pages are rendered with MDEx. YAML frontmatter is extracted by MDEx and decoded with YamlElixir:

---
title: About Astral
permalink: /about-us/
layout: default.html
---
# About

Output routes:

pages/index.md -> dist/index.html
pages/about.md -> dist/about/index.html
pages/blog/post.html -> dist/blog/post/index.html

permalink overrides the default route. layout selects a layout from the layouts directory. Use layout: false to render without a layout.

Plain .html files in pages/ are supported too.

Layouts

Layouts are EEx templates. Use @content where page HTML should be inserted:

<!doctype html>
<html lang="en">
<head>
<title><%= @page.title || "Astral" %></title>
<script type="module" src="<%= Astral.asset_path(@site, "app.ts") %>"></script>
</head>
<body>
<main data-route="<%= @route %>">
<%= @content %>
</main>
</body>
</html>

Available assigns:

Assets

Astral delegates assets to Volt. Reference source assets from layouts with Astral.asset_path/2:

<script type="module" src="<%= Astral.asset_path(@site, "app.ts") %>"></script>

In development this returns the source path served by Volt, for example /assets/app.ts. In static builds it reads Volt's manifest and returns the emitted file, for example /assets/app-5e6f7a8b.js.

Volt content hashes are enabled by default. For examples or prototypes that need stable filenames:

assets "assets" do
entry "app.ts"
url_prefix "/assets"
hash false
end

Development server

mix astral.dev
mix astral.dev --open
mix astral.dev --config astral.config.exs --port 4000

The dev server:

Static builds

mix astral.build

Example output:

[Astral] Built 2 page(s) into dist
Routes:
/ dist/index.html
/about/ dist/about/index.html
Assets:
dist/assets/manifest.json

Upload dist/ to any static host or CDN. See guides/deployment.md for production asset behavior and deployment notes.

Example site

A runnable example lives in examples/basic:

cd examples/basic
mix deps.get
mix astral.dev
mix astral.build
mix check

It demonstrates Markdown, HTML pages, HEEx-first .astral pages/layouts/components, public files, Volt TypeScript/CSS assets, and Volt JS/TS formatting/linting.

Programmatic API

Astral.build(config: "astral.config.exs")
Astral.dev(config: "astral.config.exs", port: 4000)
Astral.asset_path(site, "app.ts")

Development

mix deps.get
mix ci

License

MIT © 2026 Danila Poyarkov