Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion guides/features/assets.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<script type="module" src="<%= Astral.asset_path(@site, "app.ts") %>"></script>
Expand Down
22 changes: 21 additions & 1 deletion guides/features/astral-templates.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,27 @@ Islands can receive static HEEx children through the default framework slot/chil
</.vue>
```

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

Expand Down
9 changes: 9 additions & 0 deletions guides/features/development-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
29 changes: 27 additions & 2 deletions guides/features/ui-and-browser-code.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<link rel="stylesheet" href={Astral.asset_path(@site, "styles.css")} />
```

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:

Expand Down Expand Up @@ -140,7 +161,11 @@ Nested islands can cross framework boundaries. The child island entry may execut
</.react>
```

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
<section class="dashboard-widgets">
Expand Down
2 changes: 1 addition & 1 deletion guides/introduction/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 32 additions & 1 deletion lib/astral/assets.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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))
Expand Down
16 changes: 16 additions & 0 deletions lib/astral/assets/sources.ex
Original file line number Diff line number Diff line change
@@ -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
40 changes: 40 additions & 0 deletions lib/astral/assets/stylesheets.ex
Original file line number Diff line number Diff line change
@@ -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))
"<!DOCTYPE html>" <> 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
2 changes: 1 addition & 1 deletion lib/astral/build_result.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading