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.buildAstral is intentionally separate from Volt. Volt remains the Vite-like frontend toolchain; Astral is the site framework built on top.
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:
- Elixir
astral.config.exsinstead of JavaScript config objects. - Markdown pages rendered with MDEx and YAML frontmatter.
- EEx layouts with
@content,@page,@metadata,@route, and@siteassigns. - Per-page layout selection through frontmatter.
- Plain HTML pages for simple routes.
- Public static files copied as-is.
- TypeScript/CSS/assets built and served by Volt.
- A Plug/Bandit dev server with Volt HMR client injection and full reloads for pages/layouts/public files.
- Igniter-powered starter scaffolding.
- Volt-style Astral plugins for config, discovery, rendering, and build lifecycle hooks.
Astral is early, but the first release is useful for small static sites and documentation prototypes. Initial content collections and plugins have landed on master after v0.1.0; feeds, sitemap generation, and richer routing are intentionally left for follow-up releases. See ROADMAP.md for the planned path toward an Astro-class framework.
Install into an existing Mix project with Igniter:
mix igniter.install astralOr add the dependency manually:
def deps do
[
{:astral, "~> 0.1.0"}
]
endThen scaffold a starter site:
mix astral.newThe 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.
astral.config.exs
pages/
index.md
about.md
layouts/
default.html
assets/
app.ts
styles.css
public/
robots.txt
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
endAstral 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
endZoi 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)
endCollection 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.
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"}
]
enddefmodule 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
endAvailable 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"}
]
endPlugins 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
endAstral ships a small XML DSL backed by Saxy. It exists for feed/sitemap plugins today, but it is deliberately generic so it can later be extracted into a standalone XML package.
import Astral.XML
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
endThe DSL supports attributes, nested elements, loops, conditionals, comments, text nodes, and CDATA while Saxy handles XML escaping and encoding.
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
---
# AboutOutput 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 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:
@content— rendered page HTML.@page—%Astral.Content{}for the current page.@metadata— decoded frontmatter map.@route— route path such as/about/.@site— discovered%Astral.Site{}.@collections— collection entries grouped by collection name.@entry— current%Astral.Entry{}for collection entry pages, otherwisenil.@routes— generated%Astral.Route{}values.
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
endmix astral.dev
mix astral.dev --open
mix astral.dev --config astral.config.exs --port 4000The dev server:
- serves Astral routes,
- serves public files,
- delegates Volt asset/HMR routes to
Volt.DevServer, - injects Volt's HMR client into rendered HTML,
- watches pages/layouts/public files for full reloads,
- renders useful HTML error pages for Markdown/layout/config failures.
mix astral.buildExample 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.
A runnable example lives in examples/basic:
cd examples/basic
mix deps.get
mix astral.dev
mix astral.build
mix checkIt demonstrates Markdown, HTML pages, layouts, public files, Volt TypeScript/CSS assets, and Volt JS/TS formatting/linting.
Astral.build(config: "astral.config.exs")
Astral.dev(config: "astral.config.exs", port: 4000)
Astral.asset_path(site, "app.ts")mix deps.get
mix ciMIT © 2026 Danila Poyarkov