diff --git a/AGENTS.md b/AGENTS.md
index 68652d4..1cface9 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -13,6 +13,8 @@ mix ci
- For Phoenix/web apps, keep Phoenix's generated guidance, but treat this VibeKit section as the final quality gate.
- For non-web Elixir projects, VibeKit is the default project baseline.
- Keep changes small, tested, and formatted.
+- Match primary Elixir module namespaces to source paths, allowing deliberate nested data modules, protocol implementations, conditional definitions, and acronym spellings.
+- Mirror source paths and namespaces in unit tests; keep cross-module and browser scenarios under their owning subsystem. Mix tasks remain `.ex`, ExUnit tests `_test.exs`, and shared support `.ex` under test-only `test/support/`.
## Astral architecture ideology
diff --git a/CHANGELOG.md b/CHANGELOG.md
index caa778c..91df659 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,34 @@
## Unreleased
+## 0.3.0 - 2026-09-15
+
+### Breaking changes
+
+- Require explicit `islands do component :vue, "path.vue" end` declarations for runtime-selected components that cannot be discovered from literal template references.
+
+### Changed
+
+- Replace generated TypeScript island files in `assets/.astral/islands` with shared virtual component entries; serialize instance props and hydration settings in HTML.
+- Compose development assets through one supervised Volt session and use Volt's complete production build API.
+- Normalize configured asset entries to lists and support multiple entries.
+- Build browser assets before rendering documents so asset URLs can be used in HTML, island props, and generated data routes without rendering pages twice.
+
+### Fixed
+
+- Include production island stylesheet dependencies in the active document head, including dependencies first encountered inside inert slot templates.
+- Mount late-appearing nested islands when another instance has already loaded their shared component entry.
+- Respect explicitly disabled Tailwind configuration during builds and development startup.
+
+### Security
+
+- Require Bandit 1.12.5 or later to address HTTP/2 header validation and connection-window starvation (CVE-2026-75484, CVE-2026-74836).
+- Require Igniter 0.8.4 or later to prevent terminal escape injection through package metadata in installer confirmation prompts (CVE-2026-82584).
+
+### Compatibility
+
+- Require Volt 0.18 for the shared build and supervised development-session APIs.
+
## 0.2.6 - 2026-09-04
### Fixed
diff --git a/guides/features/assets.md b/guides/features/assets.md
index 65c0039..93deae0 100644
--- a/guides/features/assets.md
+++ b/guides/features/assets.md
@@ -178,7 +178,10 @@ The source root is `assets/`; the browser URL prefix is `/assets`.
## Reference assets from layouts
-Use `Astral.asset_path/2` with the source entry name:
+Use `Astral.asset_path/2` with the source entry name. Astral builds browser assets
+before rendering documents, so the helper returns a resolved URL. Pass that URL
+to HEEx attributes, island props, or a JSON serializer; escaping belongs to the
+serializer for that context.
```eex
diff --git a/guides/features/astral-templates.md b/guides/features/astral-templates.md
index abb6571..a0fc803 100644
--- a/guides/features/astral-templates.md
+++ b/guides/features/astral-templates.md
@@ -170,7 +170,27 @@ Islands can receive static HEEx children through the default framework slot/chil
```
-Astral writes a generated island entry module and Volt compiles the imported framework component, so framework compilation remains Volt-owned. The initial implementation is client-only; SSR hydration can be layered on later.
+Astral discovers literal component references in `.astral` and Markdown sources
+without executing their setup or render code. Volt builds the corresponding
+virtual modules before document rendering. Each document includes the stylesheets
+required by its islands, with shared dependencies deduplicated.
+
+For components selected at render time, declare all possible entries explicitly:
+
+```elixir
+islands do
+ component :vue, "islands/Gallery.vue"
+ component :vue, "islands/CompactGallery.vue"
+end
+```
+
+Then a template can select one with `component={@gallery_component}`. The keyword
+configuration equivalent is `islands: [component: {:vue, "islands/Gallery.vue"}]`.
+Components invoked from arbitrary Elixir helpers also need explicit declarations.
+An undeclared runtime-selected component raises a build error.
+
+Islands are client-only. Keep essential static content outside the island so it
+remains available without JavaScript; slot templates are inert until mounting.
## Browser assets
diff --git a/guides/features/development-server.md b/guides/features/development-server.md
index 791c761..2396b21 100644
--- a/guides/features/development-server.md
+++ b/guides/features/development-server.md
@@ -38,6 +38,15 @@ Volt handles browser asset HMR. Astral triggers full reloads for site-layer file
Use plain browser JavaScript for static-site interactivity. `.astral` templates render static HTML; they do not imply LiveView server events.
+## Asset sessions and islands
+
+Astral supervises one Volt session and attaches its asset Plug to that session.
+The session owns compilation state, stylesheet workers, and filesystem watching;
+page rendering does not start another watcher or compile Tailwind.
+
+Island browser entries are virtual modules shared by component and adapter.
+Props and hydration directives belong to individual HTML instances.
+
## Build preview
`mix astral.dev` previews source files and updates as you edit. To check deploy output, run:
diff --git a/guides/features/ui-and-browser-code.md b/guides/features/ui-and-browser-code.md
index 43f3748..b3a2ce9 100644
--- a/guides/features/ui-and-browser-code.md
+++ b/guides/features/ui-and-browser-code.md
@@ -50,7 +50,28 @@ For public, unprocessed stylesheets, put files under `public/` and link them nor
## Tailwind, PostCSS, and CSS preprocessors
-Tailwind, PostCSS, Sass, Less, and similar tools belong to the Volt/browser asset layer. Add the npm packages your asset pipeline needs, import CSS from your Volt entry, and configure the tool in the ordinary browser-tooling files for that package.
+Tailwind belongs to Volt. Configure a stylesheet root in Elixir:
+
+```elixir
+config :volt, :tailwind,
+ css: Path.expand("../assets/styles.css", __DIR__),
+ name: "site",
+ dev_url: "/assets/site.css"
+```
+
+Reference the source stylesheet from an Astral layout:
+
+```astral
+
+```
+
+Astral supplies page, layout, component, collection, and asset source roots to Volt
+in development and production, preserving additional explicitly configured sources.
+The helper resolves the development URL or production manifest entry. No page-render
+compiler hook or site-specific Tailwind plugin is needed.
+
+PostCSS and preprocessors remain browser-tooling concerns; configure only integrations
+supported by the installed Volt version.
Astral does not have an `astro add tailwind` equivalent. Keep the split explicit:
@@ -140,7 +161,11 @@ Nested islands can cross framework boundaries. The child island entry may execut
```
-A page can also repeat the same framework and use different loading strategies for each island. Production island entries are ES modules, allowing Volt to extract shared runtime/framework chunks for repeated islands when multi-entry shared chunks are available:
+A page can also repeat the same framework and use different loading strategies for each island. Production island entries are ES modules, allowing Volt to extract shared runtime/framework chunks for repeated islands when multi-entry shared chunks are available. Nested instances activate when their parent exposes the slot content, even if another instance already loaded the shared entry.
+
+Astral collects island stylesheet dependencies across the page and its layout, including islands inside slot templates. After rendering, it emits deduplicated links in the active document head, in component-registration order with static dependency styles first. HTML documents requiring these links are parsed and serialized as HTML5 with an explicit doctype; fragments gain document structure. Non-HTML routes and documents with no island stylesheet dependencies are left untouched. Deduplication applies to collected dependencies, not author-supplied links, which may be conditional or disabled.
+
+For example:
```astral
diff --git a/guides/introduction/getting-started.md b/guides/introduction/getting-started.md
index 54d7443..c39d345 100644
--- a/guides/introduction/getting-started.md
+++ b/guides/introduction/getting-started.md
@@ -25,7 +25,7 @@ Or add Astral and Igniter manually:
```elixir
def deps do
[
- {:astral, "~> 0.2"},
+ {:astral, "~> 0.3"},
{:igniter, "~> 0.8", only: [:dev, :test]}
]
end
diff --git a/lib/astral/assets.ex b/lib/astral/assets.ex
index ce63bff..65abf1c 100644
--- a/lib/astral/assets.ex
+++ b/lib/astral/assets.ex
@@ -7,10 +7,32 @@ defmodule Astral.Assets do
@doc "Return the browser path for a source asset in an Astral site."
@spec path(Astral.Site.t() | Astral.Config.t(), String.t()) :: String.t()
- def path(%Astral.Site{config: config, mode: :dev}, source), do: source_path(config, source)
+ def path(%Astral.Site{mode: :dev}, "astral:islands/entry/" <> _ = source),
+ do: "/@volt/virtual/" <> Volt.JS.Vendor.encode_specifier(source)
+
+ def path(%Astral.Site{config: config, mode: :dev}, source) do
+ case tailwind_root(config, source) do
+ nil -> source_path(config, source)
+ root -> root.dev_url
+ end
+ end
+
def path(%Astral.Site{config: config}, source), do: path(config, source)
+ def path(%Astral.Config{} = config, "astral:islands/entry/" <> _ = source),
+ do: path(config, Path.basename(source))
+
def path(%Astral.Config{} = config, source) do
+ resolve_path(config, source)
+ end
+
+ defp resolve_path(config, source) do
+ source =
+ case tailwind_root(config, source) do
+ nil -> source
+ root -> root.name <> ".css"
+ end
+
Volt.static_path(nil, browser_path(config, source),
root: config.assets,
entry: config.asset_entry,
@@ -19,6 +41,15 @@ defmodule Astral.Assets do
)
end
+ defp tailwind_root(config, source) do
+ options = Volt.Config.tailwind()
+
+ if Volt.Config.Tailwind.enabled?(options) do
+ root = Volt.Config.Tailwind.new(options)
+ if root.css == Path.expand(source, config.assets), do: root
+ end
+ end
+
defp browser_path(config, source) do
config.asset_url_prefix
|> Path.join(output_name(source))
diff --git a/lib/astral/assets/sources.ex b/lib/astral/assets/sources.ex
new file mode 100644
index 0000000..3e02dd8
--- /dev/null
+++ b/lib/astral/assets/sources.ex
@@ -0,0 +1,16 @@
+defmodule Astral.Assets.Sources do
+ @moduledoc "Site source specifications supplied to Volt's stylesheet compiler."
+
+ @doc "Combine site sources with explicitly configured Volt sources."
+ def tailwind(config, configured \\ []) do
+ roots = [
+ config.pages,
+ config.layouts,
+ config.components,
+ config.assets
+ | Enum.map(config.collections, & &1.dir)
+ ]
+
+ Enum.uniq(Enum.map(roots, &%{base: &1, pattern: "**/*"}) ++ configured)
+ end
+end
diff --git a/lib/astral/assets/stylesheets.ex b/lib/astral/assets/stylesheets.ex
new file mode 100644
index 0000000..b3f6cf0
--- /dev/null
+++ b/lib/astral/assets/stylesheets.ex
@@ -0,0 +1,40 @@
+defmodule Astral.Assets.Stylesheets do
+ @moduledoc "Emit collected island styles into the active head of an HTML5 document."
+
+ @doc "Finalize document styles after all page and layout islands have rendered."
+ @spec finalize(String.t(), [String.t()], String.t()) :: String.t()
+ def finalize(html, styles, content_type \\ "text/html")
+ def finalize(html, [], _content_type), do: html
+
+ def finalize(html, styles, content_type) do
+ if html?(content_type), do: finalize_html(html, styles), else: html
+ end
+
+ defp html?(content_type) do
+ content_type |> String.split(";", parts: 2) |> hd() |> String.trim() |> String.downcase() ==
+ "text/html"
+ end
+
+ defp finalize_html(html, styles) do
+ document = LazyHTML.from_document(html)
+
+ links =
+ for href <- Enum.uniq(styles),
+ do: {"link", [{"rel", "stylesheet"}, {"href", href}], []}
+
+ tree = document |> LazyHTML.to_tree() |> Enum.map(&append_styles(&1, links))
+ "" <> LazyHTML.Tree.to_html(tree)
+ end
+
+ defp append_styles({"html", attrs, children}, links) do
+ children =
+ Enum.map(children, fn
+ {"head", attrs, children} -> {"head", attrs, children ++ links}
+ node -> node
+ end)
+
+ {"html", attrs, children}
+ end
+
+ defp append_styles(node, _links), do: node
+end
diff --git a/lib/astral/build_result.ex b/lib/astral/build_result.ex
index f73cf6e..891a84e 100644
--- a/lib/astral/build_result.ex
+++ b/lib/astral/build_result.ex
@@ -5,7 +5,7 @@ defmodule Astral.BuildResult do
@type t :: %__MODULE__{
site: Astral.Site.t(),
- assets: term() | nil
+ assets: Volt.Build.Result.t() | nil
}
defstruct site: nil,
diff --git a/lib/astral/builder.ex b/lib/astral/builder.ex
index 2cb5021..7e42ae9 100644
--- a/lib/astral/builder.ex
+++ b/lib/astral/builder.ex
@@ -26,10 +26,10 @@ defmodule Astral.Builder do
{:ok, site} <- Astral.Discovery.discover(config),
:ok <- prepare_outdir(config),
:ok <- copy_public(config),
- {:ok, assets} <- build_assets(config),
- {:ok, islands} <- render_site(site),
- {:ok, assets} <- maybe_build_island_assets(config, islands, assets),
- {:ok, _islands} <- maybe_render_final_site(site, islands) do
+ {:ok, assets} <- build_assets(config, Astral.Islands.Discovery.entries(config)),
+ site = %{site | asset_manifest: if(assets, do: assets.manifest, else: %{})},
+ {:ok, documents} <- render_site(site),
+ :ok <- write_documents(documents) do
result = %Astral.BuildResult{site: site, assets: assets}
with :ok <- Astral.PluginRunner.build_done(config.plugins, result) do
@@ -66,14 +66,22 @@ defmodule Astral.Builder do
:ok
end
- defp build_assets(config, island_entries \\ []) do
- entries = asset_entries(config) ++ island_entries
+ defp build_assets(config, island_entries) do
+ entries = Enum.uniq(asset_entries(config) ++ island_entries)
- if entries == [] do
+ tailwind = Volt.Config.tailwind()
+
+ if entries == [] and not Volt.Config.Tailwind.enabled?(tailwind) do
{:ok, nil}
else
- Volt.Builder.build(
+ Volt.build(
entry: entries,
+ output_layout: :flat,
+ assets_dir: "",
+ public_dir: false,
+ tailwind: tailwind,
+ tailwind_sources:
+ Astral.Assets.Sources.tailwind(config, Volt.Config.Tailwind.new(tailwind).sources),
outdir: config.asset_outdir,
asset_url_prefix: config.asset_url_prefix,
root: config.root,
@@ -82,7 +90,7 @@ defmodule Astral.Builder do
format: if(island_entries == [], do: :iife, else: :esm),
plugins: [
Astral.Template.AssetPlugin,
- Astral.Islands.RuntimePlugin,
+ {Astral.Islands.RuntimePlugin, assets: config.assets},
Astral.Islands.SolidPlugin
]
)
@@ -90,13 +98,7 @@ defmodule Astral.Builder do
end
defp asset_entries(config) do
- []
- |> maybe_add_asset_entry(config)
- |> Kernel.++(template_asset_entries(config))
- end
-
- defp maybe_add_asset_entry(entries, config) do
- if File.regular?(config.asset_entry), do: [config.asset_entry | entries], else: entries
+ Enum.filter(config.asset_entry, &File.regular?/1) ++ template_asset_entries(config)
end
defp template_asset_entries(config) do
@@ -114,25 +116,26 @@ defmodule Astral.Builder do
|> Enum.any?()
end
- defp maybe_build_island_assets(_config, [], assets), do: {:ok, assets}
-
- defp maybe_build_island_assets(config, islands, _assets) do
- island_entries = Enum.map(islands, & &1.entry_path)
- build_assets(config, island_entries)
+ defp write_documents(documents) do
+ Enum.reduce_while(documents, :ok, fn {path, body, _content_type}, :ok ->
+ with :ok <- File.mkdir_p(Path.dirname(path)),
+ :ok <- File.write(path, body) do
+ {:cont, :ok}
+ else
+ {:error, _} = error -> {:halt, error}
+ end
+ end)
end
- defp maybe_render_final_site(_site, []), do: {:ok, []}
- defp maybe_render_final_site(site, _islands), do: render_site(site)
-
defp render_site(site) do
Astral.Image.Registry.start(site)
Astral.Islands.Registry.start(site)
try do
- with :ok <- render_pages(site),
- :ok <- render_routes(site),
+ with {:ok, pages} <- render_pages(site),
+ {:ok, routes} <- render_routes(site),
:ok <- Astral.Image.Builder.build(site) do
- {:ok, Astral.Islands.Registry.islands()}
+ {:ok, pages ++ routes}
end
after
Astral.Image.Registry.stop()
@@ -141,20 +144,22 @@ defmodule Astral.Builder do
end
defp render_pages(site) do
- Enum.reduce_while(site.pages, :ok, fn page, :ok ->
+ Enum.reduce_while(site.pages, {:ok, []}, fn page, {:ok, documents} ->
case render_page(page, site) do
- :ok -> {:cont, :ok}
+ {:ok, document} -> {:cont, {:ok, [document | documents]}}
{:error, _} = error -> {:halt, error}
end
end)
+ |> reverse_documents()
end
defp render_page(page, site) do
+ Astral.Islands.Registry.start_document()
+
with :ok <- validate_output_path(page.output_path, site.config),
- {:ok, html} <- Astral.Renderer.render_page(site, page),
- :ok <- File.mkdir_p(Path.dirname(page.output_path)),
- :ok <- File.write(page.output_path, html) do
- :ok
+ {:ok, html} <- Astral.Renderer.render_page(site, page) do
+ html = Astral.Assets.Stylesheets.finalize(html, Astral.Islands.Registry.stylesheets())
+ {:ok, {page.output_path, html, "text/html"}}
else
{:error, {:missing_layout, _path, _layout} = reason} -> {:error, reason}
{:error, reason} -> {:error, {:render_failed, page.source_path, reason}}
@@ -162,26 +167,37 @@ defmodule Astral.Builder do
end
defp render_routes(site) do
- Enum.reduce_while(site.routes, :ok, fn route, :ok ->
+ Enum.reduce_while(site.routes, {:ok, []}, fn route, {:ok, documents} ->
case render_route(route, site) do
- :ok -> {:cont, :ok}
+ {:ok, document} -> {:cont, {:ok, [document | documents]}}
{:error, _reason} = error -> {:halt, error}
end
end)
+ |> reverse_documents()
end
defp render_route(route, site) do
+ Astral.Islands.Registry.start_document()
+
with :ok <- validate_output_path(route.output_path, site.config),
- {:ok, body} <- render_route_body(site.config.plugins, route, site),
- :ok <- File.mkdir_p(Path.dirname(route.output_path)),
- :ok <- File.write(route.output_path, body) do
- :ok
+ {:ok, body, content_type} <- render_route_body(site.config.plugins, route, site) do
+ body =
+ Astral.Assets.Stylesheets.finalize(
+ IO.iodata_to_binary(body),
+ Astral.Islands.Registry.stylesheets(),
+ content_type
+ )
+
+ {:ok, {route.output_path, body, content_type}}
else
nil -> {:error, {:missing_route_renderer, route.path}}
{:error, reason} -> {:error, {:route_render_failed, route.path, reason}}
end
end
+ defp reverse_documents({:ok, documents}), do: {:ok, Enum.reverse(documents)}
+ defp reverse_documents(error), do: error
+
defp validate_output_path(path, config) when is_binary(path) do
if Volt.Path.inside?(path, config.outdir) do
:ok
@@ -194,8 +210,8 @@ defmodule Astral.Builder do
defp render_route_body(plugins, route, site) do
case Astral.PluginRunner.render_route(plugins, route, site) do
- {:ok, body, _content_type} -> {:ok, body}
- {:ok, body, _content_type, _headers} -> {:ok, body}
+ {:ok, body, content_type} -> {:ok, body, content_type}
+ {:ok, body, content_type, _headers} -> {:ok, body, content_type}
other -> other
end
end
diff --git a/lib/astral/components.ex b/lib/astral/components.ex
index bfce64a..9aaad48 100644
--- a/lib/astral/components.ex
+++ b/lib/astral/components.ex
@@ -137,6 +137,8 @@ defmodule Astral.Components do
")
+ assert {:ok, _} = Astral.build(root: root, layout: false)
+ end
+end
diff --git a/test/astral/assets/stylesheets_test.exs b/test/astral/assets/stylesheets_test.exs
new file mode 100644
index 0000000..7797a82
--- /dev/null
+++ b/test/astral/assets/stylesheets_test.exs
@@ -0,0 +1,49 @@
+defmodule Astral.Assets.StylesheetsTest do
+ use ExUnit.Case, async: true
+
+ alias Astral.Assets.Stylesheets
+
+ test "places dependencies in the active head, not a template, and preserves HTML semantics" do
+ html = """
+ </title><script>unsafe</script>
+