Skip to main content

README.md

# Astral ✨

[![Hex.pm](https://img.shields.io/hexpm/v/astral.svg)](https://hex.pm/packages/astral) [![Documentation](https://img.shields.io/badge/documentation-gray)](https://hexdocs.pm/astral)

Volt-powered static site generation for Elixir. Astral owns site semantics — pages, routes, Markdown, frontmatter, layouts, public files, and static HTML output — while [Volt](https://hex.pm/packages/volt) handles TypeScript, CSS, assets, dev-server integration, and HMR.

```bash
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:

- Elixir `astral.config.exs` instead of JavaScript config objects.
- Markdown pages rendered with MDEx and YAML frontmatter.
- EEx layouts with `@content`, `@page`, `@metadata`, `@route`, and `@site` assigns.
- 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.

## 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`](ROADMAP.md) for the planned path toward an Astro-class framework.

## Installation

Install into an existing Mix project with Igniter:

```bash
mix igniter.install astral
```

Or add the dependency manually:

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

Then scaffold a starter site:

```bash
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

```text
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.

```text
components/
  pill.astral
pages/
  index.astral
layouts/
  default.astral
```

```astral
<!-- components/pill.astral -->
<div class="pill">
  {render_slot(@inner_block)}
</div>
```

```astral
<!-- pages/index.astral -->
---
assigns = assign(assigns, :title, "Home")
---

<h1>{@title}</h1>
<.pill>Elixir</.pill>
```

```astral
<!-- 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/`:

```elixir
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.

```elixir
# 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.

```elixir
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:

```elixir
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:

```eex
<%= 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`:

```eex
<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:

```elixir
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.

```elixir
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:

```text
/blog/
/blog/2/
/blog/3/
```

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

```eex
<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:

```elixir
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.

```elixir
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:

```eex
<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.

```elixir
# astral.config.exs
import Astral.Config

site do
  plugins [
    MySite.SEOPlugin,
    {MySite.AnalyticsPlugin, id: "G-XXXX"}
  ]
end
```

```elixir
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:

```elixir
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:

```elixir
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](../xm) for feed and sitemap XML. XM is a small Saxy-backed XML DSL extracted from Astral so XML generation stays generic and reusable.

```elixir
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:

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

# About
```

Output routes:

```text
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:

```html
<!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, otherwise `nil`.
- `@routes` — generated `%Astral.Route{}` values.

## Assets

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

```eex
<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:

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

## Development server

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

The 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.

## Static builds

```bash
mix astral.build
```

Example output:

```text
[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`](guides/deployment.md) for production asset behavior and deployment notes.

## Example site

A runnable example lives in `examples/basic`:

```bash
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

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

## Development

```bash
mix deps.get
mix ci
```

## License

MIT © 2026 Danila Poyarkov