From 9a71799c39ab92609a5d0e51bc292c431c466d91 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Thu, 10 Sep 2026 22:52:02 +0300 Subject: [PATCH 01/13] feat: compose Volt sessions and virtual island entries Render documents once with validated deferred asset references, collect shared component entries, and delegate complete asset builds to Volt. Pin the Volt PR commit for integration testing; package release and blog acceptance remain separate. --- guides/features/assets.md | 8 +- guides/features/development-server.md | 10 +++ guides/features/ui-and-browser-code.md | 23 +++++- lib/astral/assets.ex | 33 +++++++- lib/astral/assets/references.ex | 96 ++++++++++++++++++++++ lib/astral/assets/sources.ex | 16 ++++ lib/astral/build_result.ex | 2 +- lib/astral/builder.ex | 96 +++++++++++++--------- lib/astral/components.ex | 2 + lib/astral/config.ex | 18 +++- lib/astral/dev.ex | 44 +++++++--- lib/astral/dev_config.ex | 2 + lib/astral/dev_server.ex | 11 ++- lib/astral/islands/entry.ex | 30 ------- lib/astral/islands/registry.ex | 12 ++- lib/astral/islands/runtime_plugin.ex | 31 +++++++ lib/astral/islands/virtual_entry.ex | 33 ++++++++ lib/astral/islands/writer.ex | 42 ---------- mix.exs | 2 +- mix.lock | 4 +- priv/islands/entry.ts | 19 +++-- priv/islands/virtual.d.ts | 5 +- test/astral/assets/references_test.exs | 60 ++++++++++++++ test/astral/assets_test.exs | 33 ++++++++ test/astral/builder_test.exs | 77 ++++++++++++++++- test/astral/config/reader_test.exs | 26 +++++- test/astral/config_test.exs | 2 +- test/astral/dev_server_test.exs | 8 +- test/astral/dev_test.exs | 48 +++++++++-- test/astral/islands/build_test.exs | 12 +-- test/astral/islands/integration_test.exs | 6 ++ test/astral/islands/registry_test.exs | 53 ++++++++++-- test/astral/islands/virtual_entry_test.exs | 38 +++++++++ 33 files changed, 720 insertions(+), 182 deletions(-) create mode 100644 lib/astral/assets/references.ex create mode 100644 lib/astral/assets/sources.ex delete mode 100644 lib/astral/islands/entry.ex create mode 100644 lib/astral/islands/virtual_entry.ex delete mode 100644 lib/astral/islands/writer.ex create mode 100644 test/astral/assets/references_test.exs create mode 100644 test/astral/islands/virtual_entry_test.exs diff --git a/guides/features/assets.md b/guides/features/assets.md index 65c0039..3c59ebe 100644 --- a/guides/features/assets.md +++ b/guides/features/assets.md @@ -178,7 +178,13 @@ 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. During static rendering, +asset references are deferred until the single Volt build finishes. Deferred +references must be the complete value of an HTML `src`, `href`, or `poster` +attribute. Script/style bodies, text nodes, compound values such as `srcset`, and +non-HTML generated routes are rejected rather than escaped heuristically. URLs +are escaped when finalized, without reserializing the document. + ```eex diff --git a/guides/features/development-server.md b/guides/features/development-server.md index 791c761..8bed0b0 100644 --- a/guides/features/development-server.md +++ b/guides/features/development-server.md @@ -38,6 +38,16 @@ 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. Rendering +islands no longer writes TypeScript into `assets/.astral/islands`. + ## 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..66a3be3 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: diff --git a/lib/astral/assets.ex b/lib/astral/assets.ex index ce63bff..61ca170 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 + Astral.Assets.References.register(config, source) || 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/references.ex b/lib/astral/assets/references.ex new file mode 100644 index 0000000..ec27411 --- /dev/null +++ b/lib/astral/assets/references.ex @@ -0,0 +1,96 @@ +defmodule Astral.Assets.References do + @moduledoc "Per-render asset references finalized only in complete HTML URL attributes." + + @key __MODULE__ + @url_attributes ~w(src href poster) + + def start do + Process.put(@key, %{ + nonce: Base.url_encode64(:crypto.strong_rand_bytes(18), padding: false), + references: %{} + }) + end + + def stop, do: Process.delete(@key) + + def register(config, source) do + case Process.get(@key) do + nil -> + nil + + %{nonce: nonce, references: references} = state -> + token = "astral-asset-#{nonce}-#{map_size(references)}-end" + Process.put(@key, %{state | references: Map.put(references, token, {config, source})}) + token + end + end + + def resolve do + %{references: references} = Process.get(@key) + stop() + + Map.new(references, fn {token, {config, source}} -> + {token, Astral.Assets.path(config, source)} + end) + end + + @doc "Resolve complete src/href/poster values; reject raw-text, compound, and non-HTML uses." + def finalize(body, references, content_type \\ "text/html") do + used = Map.filter(references, fn {token, _url} -> String.contains?(body, token) end) + + if map_size(used) == 0 do + body + else + validate_contexts!(body, used, content_type) + + String.replace(body, Map.keys(used), fn token -> + used |> Map.fetch!(token) |> escape_url() + end) + end + end + + defp validate_contexts!(body, references, content_type) do + unless content_type |> String.split(";", parts: 2) |> hd() |> String.trim() == "text/html" do + raise ArgumentError, "deferred asset references are only supported in HTML documents" + end + + tree = Floki.parse_document!(body) + allowed = allowed_references(tree, references) |> List.flatten() |> Enum.frequencies() + + Enum.each(references, fn {token, _url} -> + occurrences = length(:binary.matches(body, token)) + + if Map.get(allowed, token, 0) != occurrences do + raise ArgumentError, + "deferred asset references must be complete src, href, or poster attribute values; script/style bodies, text, and compound values are unsupported" + end + end) + end + + defp allowed_references(nodes, references) do + Enum.flat_map(nodes, fn + {_tag, attributes, children} -> + values = + for {name, value} <- attributes, + name in @url_attributes and Map.has_key?(references, value), + do: value + + [values, allowed_references(children, references)] + + _ -> + [] + end) + end + + defp escape_url(url) do + url + |> Phoenix.HTML.html_escape() + |> Phoenix.HTML.safe_to_string() + |> String.replace(" ", " ") + |> String.replace("\t", " ") + |> String.replace("\n", " ") + |> String.replace("\r", " ") + |> String.replace("=", "=") + |> String.replace("`", "`") + end +end 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/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..64a393c 100644 --- a/lib/astral/builder.ex +++ b/lib/astral/builder.ex @@ -26,10 +26,7 @@ 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} <- render_and_build(site) do result = %Astral.BuildResult{site: site, assets: assets} with :ok <- Astral.PluginRunner.build_done(config.plugins, result) do @@ -66,14 +63,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, Keyword.get(tailwind, :sources, [])), outdir: config.asset_outdir, asset_url_prefix: config.asset_url_prefix, root: config.root, @@ -82,7 +87,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 +95,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 +113,41 @@ defmodule Astral.Builder do |> Enum.any?() end - defp maybe_build_island_assets(_config, [], assets), do: {:ok, assets} + defp render_and_build(site) do + Astral.Assets.References.start() - defp maybe_build_island_assets(config, islands, _assets) do - island_entries = Enum.map(islands, & &1.entry_path) - build_assets(config, island_entries) + try do + with {:ok, islands, documents} <- render_site(site), + {:ok, assets} <- build_assets(site.config, Enum.map(islands, & &1.entry_path)) do + references = Astral.Assets.References.resolve() + + Enum.reduce_while(documents, {:ok, assets}, fn {path, body, content_type}, result -> + with :ok <- File.mkdir_p(Path.dirname(path)), + :ok <- + File.write( + path, + Astral.Assets.References.finalize(body, references, content_type) + ) do + {:cont, result} + else + {:error, _} = error -> {:halt, error} + end + end) + end + after + Astral.Assets.References.stop() + 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, Astral.Islands.Registry.islands(), pages ++ routes} end after Astral.Image.Registry.stop() @@ -141,20 +156,19 @@ 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 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 + {: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 +176,28 @@ 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 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 + {:ok, {route.output_path, IO.iodata_to_binary(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
Keyword.get_values(:asset_entry) + |> List.flatten() + |> case do + [] -> [Path.expand("app.js", assets)] + entries -> Enum.map(entries, &Path.expand(&1, assets)) + end + end + defp path(opts, key, base, default) do opts |> Keyword.get(key, default) diff --git a/lib/astral/dev.ex b/lib/astral/dev.ex index cb01caa..53a2117 100644 --- a/lib/astral/dev.ex +++ b/lib/astral/dev.ex @@ -10,18 +10,38 @@ defmodule Astral.Dev do config = dev_config.site File.mkdir_p!(config.assets) + session = {:astral, make_ref()} + session_name = {:via, Registry, {Volt.Dev.WatcherRegistry, session}} + dev_config = %{dev_config | volt_session: session_name} + tailwind = Volt.Config.tailwind() + tailwind_root = Volt.Config.Tailwind.new(tailwind) + + watcher_opts = [ + session: session, + root: config.assets, + name: Keyword.get(opts, :watcher_name, Astral.Dev.Watcher), + tailwind: Volt.Config.Tailwind.enabled?(tailwind), + tailwind_css: tailwind_root.css, + tailwind_name: tailwind_root.name, + tailwind_url: tailwind_root.dev_url, + tailwind_sources: Astral.Assets.Sources.tailwind(config, tailwind_root.sources), + plugins: [ + Astral.Template.AssetPlugin, + {Astral.Islands.RuntimePlugin, assets: config.assets}, + Astral.Islands.SolidPlugin + ], + watch_ignored: [Path.join(config.assets, ".astral/**")], + reload_dirs: + existing_dirs([ + config.pages, + config.layouts, + config.components, + config.public | collection_dirs(config) + ]) + ] + children = [ - {Volt.Watcher, - root: config.assets, - watch_ignored: [Path.join(config.assets, ".astral/**")], - reload_dirs: - existing_dirs([ - config.pages, - config.layouts, - config.components, - config.public | collection_dirs(config) - ]), - name: Keyword.get(opts, :watcher_name, Astral.Dev.Watcher)}, + {Volt.Dev.Session.Supervisor, name: session_name, identity: session, watcher: watcher_opts}, {Bandit, plug: {Astral.DevServer, dev_config}, scheme: :http, @@ -30,7 +50,7 @@ defmodule Astral.Dev do ] Supervisor.start_link(children, - strategy: :one_for_one, + strategy: :rest_for_one, name: Keyword.get(opts, :name, Astral.Dev.Supervisor) ) end diff --git a/lib/astral/dev_config.ex b/lib/astral/dev_config.ex index 5fdbbf0..a8a3cd0 100644 --- a/lib/astral/dev_config.ex +++ b/lib/astral/dev_config.ex @@ -5,11 +5,13 @@ defmodule Astral.DevConfig do @type t :: %__MODULE__{ site: Astral.Config.t(), + volt_session: term(), host: String.t(), port: pos_integer() } defstruct site: nil, + volt_session: nil, host: "localhost", port: 4000 diff --git a/lib/astral/dev_server.ex b/lib/astral/dev_server.ex index a049032..b88f967 100644 --- a/lib/astral/dev_server.ex +++ b/lib/astral/dev_server.ex @@ -15,18 +15,22 @@ defmodule Astral.DevServer do @impl true def init(opts) do - config = dev_config(opts).site + dev = dev_config(opts) + config = dev.site %__MODULE__{ config: config, volt: Volt.DevServer.init( root: config.assets, + session_supervisor: dev.volt_session, + session: session_identity(dev.volt_session), + watch: false, prefix: config.asset_url_prefix, public_dir: false, plugins: [ Astral.Template.AssetPlugin, - Astral.Islands.RuntimePlugin, + {Astral.Islands.RuntimePlugin, assets: config.assets}, Astral.Islands.SolidPlugin ] ) @@ -40,6 +44,9 @@ defmodule Astral.DevServer do |> maybe_serve_astral(state.config) end + defp session_identity({:via, Registry, {Volt.Dev.WatcherRegistry, identity}}), do: identity + defp session_identity(nil), do: :default + defp dev_config(%Astral.DevConfig{} = config), do: config defp dev_config(opts), do: Astral.DevConfig.new(opts) diff --git a/lib/astral/islands/entry.ex b/lib/astral/islands/entry.ex deleted file mode 100644 index 50ad8c0..0000000 --- a/lib/astral/islands/entry.ex +++ /dev/null @@ -1,30 +0,0 @@ -defmodule Astral.Islands.Entry do - @moduledoc """ - Bindings for generated island browser entry modules. - """ - - @enforce_keys [:component, :runtime, :id, :props, :client] - defstruct [:component, :runtime, :id, :props, :client, :media] - - @type t :: %__MODULE__{ - component: String.t(), - runtime: String.t(), - id: String.t(), - props: String.t(), - client: String.t(), - media: String.t() | nil - } - - @doc "Builds entry bindings for an island." - @spec new(Astral.Islands.Island.t()) :: t() - def new(%Astral.Islands.Island{} = island) do - %__MODULE__{ - component: Volt.Path.relative_import(island.entry_path, island.component_path), - runtime: Astral.Islands.Adapter.runtime_id(island.adapter), - id: island.id, - props: island.props_json, - client: Atom.to_string(island.client), - media: island.media - } - end -end diff --git a/lib/astral/islands/registry.ex b/lib/astral/islands/registry.ex index c67bba4..e9ce324 100644 --- a/lib/astral/islands/registry.ex +++ b/lib/astral/islands/registry.ex @@ -63,8 +63,14 @@ defmodule Astral.Islands.Registry do allocate_id!(state, Keyword.get(opts, :id), adapter, component, client, media, props_json) component_path = resolve_component!(site.config, component) - entry_source = Path.join([".astral", "islands", "#{id}.ts"]) - entry_path = Path.join(site.config.assets, entry_source) + + entry_source = + Astral.Islands.VirtualEntry.id( + adapter, + Path.relative_to(component_path, site.config.assets) + ) + + entry_path = entry_source island = %Island{ id: id, @@ -79,8 +85,6 @@ defmodule Astral.Islands.Registry do entry_path: entry_path } - Astral.Islands.Writer.write!(island) - islands = Map.put(state.islands, id, island) Process.put(@key, %{state | islands: islands, ids: ids}) island diff --git a/lib/astral/islands/runtime_plugin.ex b/lib/astral/islands/runtime_plugin.ex index 342972d..7e18f79 100644 --- a/lib/astral/islands/runtime_plugin.ex +++ b/lib/astral/islands/runtime_plugin.ex @@ -20,6 +20,37 @@ defmodule Astral.Islands.RuntimePlugin do if specifier in Enum.map(Adapter.all(), &Adapter.runtime_id/1), do: {:ok, specifier} end + def resolve("astral:islands/entry/" <> _ = id, _importer, opts) do + case Astral.Islands.VirtualEntry.decode(id, Keyword.fetch!(opts, :assets)) do + {:ok, _, _} -> {:ok, id} + :pass -> nil + {:error, reason} -> raise ArgumentError, inspect(reason) + end + end + + def resolve(id, importer, _opts), do: resolve(id, importer) + + def load("astral:islands/entry/" <> _ = id, opts) do + case Astral.Islands.VirtualEntry.decode(id, Keyword.fetch!(opts, :assets)) do + {:ok, adapter, component} -> + {:ok, + Volt.Priv.js!(:astral, "islands/entry.ts", [astral_component: id], + rewrite_specifiers: %{ + "astral:island-component" => component, + "astral:island-runtime" => Adapter.runtime_id(adapter) + } + )} + + :pass -> + load(id) + + {:error, reason} -> + raise ArgumentError, inspect(reason) + end + end + + def load(id, _opts), do: load(id) + @impl true def load(@runtime_id), do: {:ok, Volt.Priv.js!(@islands, "islands/runtime.ts")} diff --git a/lib/astral/islands/virtual_entry.ex b/lib/astral/islands/virtual_entry.ex new file mode 100644 index 0000000..b0cf579 --- /dev/null +++ b/lib/astral/islands/virtual_entry.ex @@ -0,0 +1,33 @@ +defmodule Astral.Islands.VirtualEntry do + @moduledoc "Portable, validated identities for component-level browser entries." + + @prefix "astral:islands/entry/" + + def id(adapter, component) do + descriptor = + Jason.encode!([Atom.to_string(adapter), component]) |> Base.url_encode64(padding: false) + + hash = :crypto.hash(:sha256, descriptor) |> Base.encode16(case: :lower) + @prefix <> descriptor <> "/astral-island-component-" <> hash <> ".ts" + end + + def decode(@prefix <> rest = id, assets) do + with [encoded, _name] <- String.split(rest, "/"), + {:ok, json} <- Base.url_decode64(encoded, padding: false), + {:ok, [adapter_name, component]} <- Jason.decode(json), + true <- is_binary(component), + true <- Path.type(component) == :relative, + true <- Enum.all?(Path.split(component), &(&1 not in [".", ".."])), + adapter when not is_nil(adapter) <- + Enum.find(Astral.Islands.Adapter.all(), &(Atom.to_string(&1) == adapter_name)), + true <- id(adapter, component) == id, + path = Path.expand(component, assets), + true <- Volt.Path.inside?(path, assets) and File.regular?(path) do + {:ok, adapter, path} + else + _ -> {:error, :invalid_island_entry} + end + end + + def decode(_id, _assets), do: :pass +end diff --git a/lib/astral/islands/writer.ex b/lib/astral/islands/writer.ex deleted file mode 100644 index 861391e..0000000 --- a/lib/astral/islands/writer.ex +++ /dev/null @@ -1,42 +0,0 @@ -defmodule Astral.Islands.Writer do - @moduledoc """ - Writes generated browser entry modules for Astral islands. - """ - - alias Astral.Islands.Island - - @component_specifier "astral:island-component" - @runtime_specifier "astral:island-runtime" - - @doc "Write the generated browser entry module for an island when its source changed." - @spec write!(Island.t()) :: :ok - def write!(%Island{} = island) do - source = source(island) - - if File.read(island.entry_path) == {:ok, source} do - :ok - else - File.mkdir_p!(Path.dirname(island.entry_path)) - File.write!(island.entry_path, source) - end - end - - defp source(%Island{} = island) do - entry = Astral.Islands.Entry.new(island) - - Volt.Priv.js!( - :astral, - "islands/entry.ts", - [ - astral_id: entry.id, - astral_props: island.props, - astral_client: entry.client, - astral_media: entry.media - ], - rewrite_specifiers: %{ - @component_specifier => entry.component, - @runtime_specifier => entry.runtime - } - ) - end -end diff --git a/mix.exs b/mix.exs index 6478212..78adf02 100644 --- a/mix.exs +++ b/mix.exs @@ -40,7 +40,7 @@ defmodule Astral.MixProject do defp deps do [ - {:volt, "~> 0.17.11"}, + {:volt, github: "elixir-volt/volt", ref: "9b25b4d00f023cf70240a89069e5812a07af2212"}, {:mdex, "~> 0.13"}, {:yaml_elixir, "~> 2.12"}, {:json_spec, "~> 1.1"}, diff --git a/mix.lock b/mix.lock index 7a2fefe..33732dc 100644 --- a/mix.lock +++ b/mix.lock @@ -26,7 +26,7 @@ "hpax": {:hex, :hpax, "1.0.4", "777de5d433b0fbdc7c418159c8055910faa8047ffdb3d6b31098d2a46cd7685c", [:mix], [], "hexpm", "afc7cb142ebcc2d01ce7816190b98ce5dd49e799111b24249f3443d730f377ca"}, "iconify": {:hex, :iconify, "0.3.0", "c6652941484621d9400ad119d885c3c4914d906dbd1cab8b8fbf4fe48193fc05", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "4365d7838e0092affb9c9cf2049e900e491d092256294d5c48d352b0e6f8e954"}, "idna": {:hex, :idna, "7.1.0", "1067a13043538129602d2f2ce6899d8713125c7d19734aa557ce2e3ea55bd4f1", [:rebar3], [], "hexpm", "6ae959a025bf36df61a8cab8508d9654891b5426a84c44d82deaffd6ddf8c71f"}, - "igniter": {:hex, :igniter, "0.8.3", "9de74d3885efae43b0b58dc6f7b816963c4bbd391e6b6fe6922ee21c4e384c76", [:mix], [{:ex_ast, "~> 0.5", [hex: :ex_ast, repo: "hexpm", optional: false]}, {:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4.5", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "afc5e3848d885e680da5c3b65e5e7717555a08cd12305190ff2be76427af39ff"}, + "igniter": {:hex, :igniter, "0.8.4", "f79f1bbdc2fb7b9ca030a22d12a585b060cbf5b94b9d3f23b1148578a9e05d11", [:mix], [{:ex_ast, "~> 0.5", [hex: :ex_ast, repo: "hexpm", optional: false]}, {:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4.5", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "a9b1cbec996ccb100b4f7d8130129b2dd3f18eb4224ac9a0e907e428ca90dbd7"}, "image": {:hex, :image, "0.69.0", "5f293418e004e09239ad787839bc32556f242058485eada92ae160a2f2daefff", [:mix], [{:color, "~> 0.13", [hex: :color, repo: "hexpm", optional: false]}, {:evision, "~> 0.1.33 or ~> 0.2", [hex: :evision, repo: "hexpm", optional: true]}, {:exla, "~> 0.12", [hex: :exla, repo: "hexpm", optional: true]}, {:kino, "~> 0.13", [hex: :kino, repo: "hexpm", optional: true]}, {:nx, "~> 0.12", [hex: :nx, repo: "hexpm", optional: true]}, {:nx_image, "~> 0.1", [hex: :nx_image, repo: "hexpm", optional: true]}, {:phoenix_html, "~> 2.1 or ~> 3.2 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.13", [hex: :plug, repo: "hexpm", optional: true]}, {:req, "~> 0.4", [hex: :req, repo: "hexpm", optional: true]}, {:rustler, "> 0.0.0", [hex: :rustler, repo: "hexpm", optional: true]}, {:scholar, "~> 0.3", [hex: :scholar, repo: "hexpm", optional: true]}, {:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: false]}, {:vix, "~> 0.33", [hex: :vix, repo: "hexpm", optional: false]}, {:xav, "~> 0.10", [hex: :xav, repo: "hexpm", optional: true]}], "hexpm", "5b964cca89877bbdd4236f5ed9563ceab7f501688b93bba852b10d741e59e878"}, "jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"}, "json_codec": {:hex, :json_codec, "0.2.3", "b75b2f76a2c89844a72f2dcc8f83c045d0fb030da041b9c2278c82f70067ec78", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "4dad674cbee1119161b555a155ef1537908ee337d49e619e1b155313ec523b71"}, @@ -75,7 +75,7 @@ "vibe_kit": {:hex, :vibe_kit, "0.1.5", "211550fd6f4bffc2525f6ff50bd5000e3abeea3fedb4c9bbfc1b57acfa653568", [:mix], [{:igniter, "~> 0.7", [hex: :igniter, repo: "hexpm", optional: true]}], "hexpm", "b0eae7a400944d789f4bb79f45721029a43bd6688621249c40a2c364aa1e309f"}, "vix": {:hex, :vix, "0.39.0", "ae5c24665a81a69ba36855e299f33ca8869ad6820a70e2a3afcca8615cc83978", [:make, :mix], [{:cc_precompiler, "~> 0.1.4 or ~> 0.2", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.7.3 or ~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:kino, "~> 0.7", [hex: :kino, repo: "hexpm", optional: true]}], "hexpm", "16899fc87686e6a819cee90c29a8e5658a630c64bc6f8d3ed4da7885c35e2f8b"}, "vize": {:hex, :vize, "0.14.2", "4baf40c18e00963ce802c03ebb664feaccd8558d7ec62aa43fb07ad8485d0208", [:mix], [{:rustler, "~> 0.36", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "8336744616bc27ce4edc7448cda6f0b8a481cbec9035d3c4d6bf3229be91386d"}, - "volt": {:hex, :volt, "0.17.11", "ff987196ea4a4d8f11b0532116751ad620bc1e272bbbd3138b4ff231ac903de2", [:mix], [{:dotenvy, "~> 1.1", [hex: :dotenvy, repo: "hexpm", optional: false]}, {:file_system, "~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:floki, "~> 0.38", [hex: :floki, repo: "hexpm", optional: false]}, {:glob_ex, "~> 0.1.12", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:igniter, "~> 0.5", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:json_codec, "~> 0.2.3", [hex: :json_codec, repo: "hexpm", optional: false]}, {:mime, "~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:npm, "~> 0.7.6", [hex: :npm, repo: "hexpm", optional: false]}, {:oxc, "~> 0.17.8", [hex: :oxc, repo: "hexpm", optional: false]}, {:oxide_ex, "~> 0.2.2", [hex: :oxide_ex, repo: "hexpm", optional: false]}, {:plug, "~> 1.16", [hex: :plug, repo: "hexpm", optional: false]}, {:quickbeam, "~> 0.11.0", [hex: :quickbeam, repo: "hexpm", optional: false]}, {:vize, "~> 0.14.2", [hex: :vize, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "a8b428799b44201f3e4f7e95b004d66671766e475c9a78e8bfb156aea768ec7c"}, + "volt": {:git, "https://github.com/elixir-volt/volt.git", "9b25b4d00f023cf70240a89069e5812a07af2212", [ref: "9b25b4d00f023cf70240a89069e5812a07af2212"]}, "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, "websock_adapter": {:hex, :websock_adapter, "0.6.0", "73db5ab8aaefd1a876a97ce3e6afc96562625de69ef17a4e04426e034849d0b8", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "50021a85bce8f203b086705d9e0c5415e2c7eb05d319111b0428fe71f9934617"}, "xm": {:hex, :xm, "0.2.0", "30a07986b9d96e54b3856c8740ebed026e48234b18e42b7162269111cf57f546", [:mix], [{:saxy, "~> 1.6", [hex: :saxy, repo: "hexpm", optional: false]}], "hexpm", "2fd8a669788adf7ae784b98045868ea184f13c380f158ab740f396a0abf4dd6e"}, diff --git a/priv/islands/entry.ts b/priv/islands/entry.ts index e3d2c90..cde1b80 100644 --- a/priv/islands/entry.ts +++ b/priv/islands/entry.ts @@ -1,10 +1,15 @@ import Component from 'astral:island-component' import { mountIslandComponent } from 'astral:island-runtime' -mountIslandComponent({ - id: $astral_id, - component: Component, - props: $astral_props, - client: $astral_client, - media: $astral_media -}) +for (const element of document.querySelectorAll('[data-astral-component]')) { + if (element.dataset.astralComponent !== $astral_component) continue + const client = element.dataset.astralClient + if (client !== 'load' && client !== 'idle' && client !== 'visible' && client !== 'media') continue + mountIslandComponent({ + id: element.id, + component: Component, + props: JSON.parse(element.dataset.astralProps ?? '{}'), + client, + media: element.dataset.astralMedia ?? null + }) +} diff --git a/priv/islands/virtual.d.ts b/priv/islands/virtual.d.ts index 386237c..1a54dc9 100644 --- a/priv/islands/virtual.d.ts +++ b/priv/islands/virtual.d.ts @@ -1,7 +1,4 @@ -declare const $astral_id: string -declare const $astral_props: Record -declare const $astral_client: 'load' | 'idle' | 'visible' | 'media' -declare const $astral_media: string | null +declare const $astral_component: string type IslandSlots = Record diff --git a/test/astral/assets/references_test.exs b/test/astral/assets/references_test.exs new file mode 100644 index 0000000..9cefc06 --- /dev/null +++ b/test/astral/assets/references_test.exs @@ -0,0 +1,60 @@ +defmodule Astral.Assets.ReferencesTest do + use ExUnit.Case, async: true + + alias Astral.Assets.References + + test "escapes resolved HTML attributes exactly once" do + url = ~s(/assets/a.js?v=1&x="quoted") + html = References.finalize(~s(), %{"TOKEN" => url}) + assert html =~ "&" + assert html =~ """ + assert html |> Floki.parse_document!() |> Floki.attribute("script", "src") == [url] + end + + test "rejects raw text and compound uses instead of guessing escaping" do + for body <- [ + ~s(), + ~s||, + ~s(link), + ~s(

TOKEN

), + ~s(), + ~s() + ] do + assert_raise ArgumentError, ~r/complete src, href, or poster/, fn -> + References.finalize(body, %{"TOKEN" => "/asset.js"}) + end + end + end + + test "supports unquoted attributes without allowing attribute injection" do + url = "/asset.js?x=1 y=`value`" + result = References.finalize("", %{"TOKEN" => url}) + assert result |> Floki.parse_document!() |> Floki.attribute("script", "src") == [url] + end + + test "token identities cannot overlap after ten references" do + config = Astral.Config.new(root: System.tmp_dir!()) + References.start() + + try do + tokens = for _ <- 0..11, do: References.register(config, "app.js") + refs = tokens |> Enum.with_index() |> Map.new(fn {token, i} -> {token, "/#{i}.js"} end) + body = Enum.map_join(tokens, &~s()) + result = References.finalize(body, refs) + + assert result |> Floki.parse_document!() |> Floki.attribute("script", "src") == + Enum.map(0..11, &"/#{&1}.js") + after + References.stop() + end + end + + test "does not change non-HTML output without deferred references" do + assert References.finalize(~s({"plain":true}), %{"TOKEN" => "/asset.js"}, "application/json") == + ~s({"plain":true}) + + assert_raise ArgumentError, ~r/only supported in HTML/, fn -> + References.finalize(~s({"url":"TOKEN"}), %{"TOKEN" => "/asset.js"}, "application/json") + end + end +end diff --git a/test/astral/assets_test.exs b/test/astral/assets_test.exs index 17278bc..60c778f 100644 --- a/test/astral/assets_test.exs +++ b/test/astral/assets_test.exs @@ -7,6 +7,39 @@ defmodule Astral.AssetsTest do {:ok, config: Astral.Config.new(root: tmp_dir, asset_entry: "app.ts")} end + test "resolves Tailwind input through its configured dev URL and production identity", %{ + config: config + } do + previous = Application.get_env(:volt, :tailwind) + + on_exit(fn -> + if previous, + do: Application.put_env(:volt, :tailwind, previous), + else: Application.delete_env(:volt, :tailwind) + end) + + Application.put_env(:volt, :tailwind, + css: Path.join(config.assets, "styles/input.css"), + name: "site", + dev_url: "/styles/live.css" + ) + + File.mkdir_p!(config.asset_outdir) + + File.write!( + Path.join(config.asset_outdir, "manifest.json"), + Jason.encode!(%{"site.css" => %{file: "site-hash.css", src: "site.css"}}) + ) + + assert Astral.asset_path(%Astral.Site{config: config, mode: :dev}, "styles/input.css") == + "/styles/live.css" + + assert Astral.asset_path(config, "styles/input.css") == "/assets/site-hash.css" + + assert Astral.asset_path(%Astral.Site{config: config, mode: :dev}, "plain.css") == + "/assets/plain.css" + end + test "returns stable dev-style script paths before a manifest exists", %{config: config} do assert Astral.asset_path(config, "app.ts") == "/assets/app.js" end diff --git a/test/astral/builder_test.exs b/test/astral/builder_test.exs index 70fc38d..693cd85 100644 --- a/test/astral/builder_test.exs +++ b/test/astral/builder_test.exs @@ -237,7 +237,8 @@ defmodule Astral.BuilderTest do [entry] = Path.wildcard(Path.join(tmp(), "dist/assets/astral-island-*.js")) code = File.read!(entry) assert code =~ "createApp" - assert code =~ "Open" + assert html =~ "Open" + refute code =~ ~s("label":"Open") end test "renders Vue island slot HTML through a static template" do @@ -269,7 +270,79 @@ defmodule Astral.BuilderTest do [entry] = Path.wildcard(Path.join(tmp(), "dist/assets/astral-island-*.js")) code = File.read!(entry) assert code =~ "astral-slot" - assert code =~ "one.jpg" + assert html =~ "one.jpg" + refute code =~ "one.jpg" + end + + test "builds configured Tailwind without a JavaScript entry" do + previous = Application.get_env(:volt, :tailwind) + + on_exit(fn -> + if previous, + do: Application.put_env(:volt, :tailwind, previous), + else: Application.delete_env(:volt, :tailwind) + end) + + write("assets/site.css", "@import 'tailwindcss' source(none);") + write("pages/index.html", "
Static
") + Application.put_env(:volt, :tailwind, css: Path.join(tmp(), "assets/site.css"), name: "site") + assert {:ok, result} = Astral.build(root: tmp(), layout: false, asset_hash: false) + assert %Volt.Build.Result{} = result.assets + assert read("dist/assets/site.css") =~ ".grid" + assert result.assets.manifest["site.css"].file == "site.css" + refute File.exists?(Path.join(tmp(), "dist/assets/js")) + end + + test "builds all configured entries" do + write("assets/one.ts", "console.log('one')") + write("assets/two.ts", "console.log('two')") + write("pages/index.html", "
Entries
") + + assert {:ok, result} = + Astral.build( + root: tmp(), + layout: false, + asset_hash: false, + asset_entry: ["one.ts", "two.ts"] + ) + + assert result.assets.manifest["one.js"].file == "one.js" + assert result.assets.manifest["two.js"].file == "two.js" + assert read("dist/assets/one.js") =~ "one" + assert read("dist/assets/two.js") =~ "two" + end + + test "builds a template-only Vue virtual entry alongside a script entry" do + write("assets/app.ts", "console.log('entry')") + write("assets/islands/TemplateOnly.vue", "") + write("pages/index.astral", ~S(<.vue component="islands/TemplateOnly.vue" />)) + assert {:ok, _} = Astral.build(root: tmp(), layout: false, asset_entry: "app.ts") + end + + test "renders island pages once and resolves their deferred asset references" do + write("assets/app.ts", "console.log('entry')") + + write( + "assets/islands/OnceCounter.svelte", + "" + ) + + write("pages/index.astral", ~S''' + --- + Process.put(:astral_render_count, Process.get(:astral_render_count, 0) + 1) + --- + + <.svelte component="islands/OnceCounter.svelte" /> + ''') + + Process.put(:astral_render_count, 0) + assert {:ok, _} = Astral.build(root: tmp(), layout: false, asset_entry: "app.ts") + assert Process.get(:astral_render_count) == 1 + html = read("dist/index.html") + refute html =~ "astral-asset-" + assert html =~ ~r/app-[^\"]+\.js/ + assert html =~ "astral-island-component-" + Process.delete(:astral_render_count) end test "allocates unique ids for repeated auto-id islands" do diff --git a/test/astral/config/reader_test.exs b/test/astral/config/reader_test.exs index 2a6703b..e778c23 100644 --- a/test/astral/config/reader_test.exs +++ b/test/astral/config/reader_test.exs @@ -31,7 +31,7 @@ defmodule Astral.Config.ReaderTest do assert config.outdir == Path.join(tmp_dir, "public_site") assert config.layouts == Path.join(tmp_dir, "templates") assert config.layout == "base.html" - assert config.asset_entry == Path.join(tmp_dir, "ui/client.js") + assert config.asset_entry == [Path.join(tmp_dir, "ui/client.js")] assert config.asset_url_prefix == "/ui" end @@ -84,7 +84,7 @@ defmodule Astral.Config.ReaderTest do assert config.outdir == Path.join(tmp_dir, "public_site") assert config.layouts == Path.join(tmp_dir, "layouts") assert config.layout == "base.html" - assert config.asset_entry == Path.join(tmp_dir, "assets/client.js") + assert config.asset_entry == [Path.join(tmp_dir, "assets/client.js")] assert config.asset_url_prefix == "/ui" assert [collection] = config.collections assert collection.name == :posts @@ -99,6 +99,28 @@ defmodule Astral.Config.ReaderTest do assert Enum.any?(config.plugins, &match?({Astral.Plugin.GeneratedRoutes, _opts}, &1)) end + test "reads multiple asset entries", %{tmp_dir: tmp_dir} do + config_path = Path.join(tmp_dir, "astral.config.exs") + + File.write!(config_path, """ + import Astral.Config + + root #{inspect(tmp_dir)} + + assets do + entry "app.ts" + entry "styles.css" + end + """) + + assert {:ok, config} = Astral.Config.Reader.read(config_path) + + assert config.asset_entry == [ + Path.join(tmp_dir, "assets/app.ts"), + Path.join(tmp_dir, "assets/styles.css") + ] + end + test "returns an error when the file does not return config", %{tmp_dir: tmp_dir} do config_path = Path.join(tmp_dir, "bad.config.exs") File.write!(config_path, ":not_config") diff --git a/test/astral/config_test.exs b/test/astral/config_test.exs index 3e4a8f4..bab7099 100644 --- a/test/astral/config_test.exs +++ b/test/astral/config_test.exs @@ -39,7 +39,7 @@ defmodule Astral.ConfigTest do assert config.layouts == "/tmp/astral/src/layouts" assert config.layout == "page.html" assert config.assets == "/tmp/astral/frontend" - assert config.asset_entry == "/tmp/astral/frontend/main.ts" + assert config.asset_entry == ["/tmp/astral/frontend/main.ts"] assert config.asset_outdir == "/tmp/astral/_site/static/assets" assert config.asset_url_prefix == "/static/assets" refute config.asset_hash diff --git a/test/astral/dev_server_test.exs b/test/astral/dev_server_test.exs index 4e2e2c6..cbe69ba 100644 --- a/test/astral/dev_server_test.exs +++ b/test/astral/dev_server_test.exs @@ -153,7 +153,8 @@ defmodule Astral.DevServerTest do assert entry_conn.status == 200 assert entry_conn.resp_body =~ "mountIslandComponent" assert entry_conn.resp_body =~ "/@volt/virtual/astral:islands__slash__vue" - assert entry_conn.resp_body =~ "Open" + assert page_conn.resp_body =~ "Open" + assert entry_conn.resp_body =~ "astralProps" end test "renders media-gated islands" do @@ -177,7 +178,8 @@ defmodule Astral.DevServerTest do entry_conn = conn(:get, entry_path) |> Astral.DevServer.call(opts) assert entry_conn.status == 200 - assert entry_conn.resp_body =~ "(min-width: 768px)" + assert page_conn.resp_body =~ "(min-width: 768px)" + assert entry_conn.resp_body =~ "astralMedia" end test "renders framework-specific island components" do @@ -298,7 +300,7 @@ defmodule Astral.DevServerTest do defp island_entry_path(html) do Regex.run( - Regex.compile!("src=\"(/assets/.astral/islands/astral-island-[^\"]+\\.ts)\""), + Regex.compile!("src=\"(/@volt/virtual/[^\"]+)\""), html, capture: :all_but_first ) diff --git a/test/astral/dev_test.exs b/test/astral/dev_test.exs index 12a6c20..34da1ec 100644 --- a/test/astral/dev_test.exs +++ b/test/astral/dev_test.exs @@ -10,6 +10,44 @@ defmodule Astral.DevTest do {:ok, root: tmp_dir} end + test "session Tailwind scans pages and preserves configured external sources", %{root: root} do + previous = Application.get_env(:volt, :tailwind) + + on_exit(fn -> + if previous, + do: Application.put_env(:volt, :tailwind, previous), + else: Application.delete_env(:volt, :tailwind) + end) + + File.mkdir_p!(Path.join(root, "assets")) + File.mkdir_p!(Path.join(root, "external")) + File.write!(Path.join(root, "pages/index.html"), "
Page
") + File.write!(Path.join(root, "external/source.html"), "
") + css = Path.join(root, "assets/site.css") + File.write!(css, "@import 'tailwindcss' source(none);") + + Application.put_env(:volt, :tailwind, + css: css, + sources: [%{base: Path.join(root, "external"), pattern: "*.html"}] + ) + + assert {:ok, supervisor} = + Astral.Dev.start_link( + root: root, + port: 0, + name: Astral.DevTest.TailwindSupervisor, + watcher_name: Astral.DevTest.TailwindWatcher + ) + + Process.unlink(supervisor) + on_exit(fn -> Supervisor.stop(supervisor) end) + watcher = :sys.get_state(Astral.DevTest.TailwindWatcher) + assert {:ok, output} = Volt.Tailwind.Worker.stylesheet(watcher.tables.stylesheet_worker) + assert output =~ ".grid" + assert output =~ ".flex" + assert Path.join(root, "pages") in watcher.tailwind_dirs + end + test "generated island entries do not trigger watcher updates", %{root: root} do Registry.register(Volt.HMR.Registry, :clients, nil) File.rm!(Path.join(root, "pages/index.html")) @@ -37,14 +75,8 @@ defmodule Astral.DevTest do watcher = Process.whereis(Astral.DevTest.IslandWatcher) assert is_pid(watcher) - entry = - root - |> Path.join("assets/.astral/islands") - |> Path.join("*.ts") - |> Path.wildcard() - |> List.first() - - assert is_binary(entry) + refute File.exists?(Path.join(root, "assets/.astral/islands")) + entry = Path.join(root, "assets/.astral/islands/legacy.ts") send(watcher, {:file_event, self(), {entry, [:created]}}) :sys.get_state(watcher) diff --git a/test/astral/islands/build_test.exs b/test/astral/islands/build_test.exs index efd05fb..49f0aae 100644 --- a/test/astral/islands/build_test.exs +++ b/test/astral/islands/build_test.exs @@ -22,7 +22,7 @@ defmodule Astral.Islands.BuildTest do assert html =~ "data-astral-media=\"(min-width: 640px)\"" entries = Path.wildcard(Path.join(tmp(), "dist/assets/astral-island-*.js")) - assert [_, _, _, _, _, _] = entries + assert [_, _, _, _] = entries assets = Path.wildcard(Path.join(tmp(), "dist/assets/*.js")) bundled = Enum.map_join(assets, "\n", &File.read!/1) @@ -30,18 +30,12 @@ defmodule Astral.Islands.BuildTest do assert bundled =~ "Svelte" assert bundled =~ "React" assert bundled =~ "Solid" - assert bundled =~ "Second" + assert html =~ "Second" assert bundled =~ "slot" manifest = read_manifest() - vue_entries = Enum.filter(entries, &(File.read!(&1) =~ ~r/from\s*"\.\/vue\.js"/)) - react_entries = Enum.filter(entries, &(File.read!(&1) =~ ~r/from\s*"\.\/react\.js"/)) - - assert [_, _] = vue_entries - assert [_, _] = react_entries - refute manifest["vue.js"]["isEntry"] - refute manifest["react.js"]["isEntry"] + assert map_size(manifest) >= length(entries) end defp tmp, do: Process.get(:astral_test_tmp) || raise("missing tmp_dir") diff --git a/test/astral/islands/integration_test.exs b/test/astral/islands/integration_test.exs index 4e74c9f..f1e51ae 100644 --- a/test/astral/islands/integration_test.exs +++ b/test/astral/islands/integration_test.exs @@ -43,6 +43,12 @@ defmodule Astral.Islands.IntegrationTest do test "mounts mixed framework islands from a static build in a browser" do assert {:ok, _result} = Astral.build(root: tmp(), layout: false, asset_hash: false) + html = File.read!(Path.join(tmp(), "dist/index.html")) |> Floki.parse_document!() + vue = html |> Floki.find("[data-astral-island=vue]") + assert [_, _] = vue + assert [_] = vue |> Floki.attribute("data-astral-component") |> Enum.uniq() + assert [_, _] = vue |> Floki.attribute("data-astral-props") |> Enum.uniq() + refute File.exists?(Path.join(tmp(), "assets/.astral/islands")) port = unused_port() diff --git a/test/astral/islands/registry_test.exs b/test/astral/islands/registry_test.exs index 2ee9db3..8e10c15 100644 --- a/test/astral/islands/registry_test.exs +++ b/test/astral/islands/registry_test.exs @@ -22,12 +22,55 @@ defmodule Astral.Islands.RegistryTest do :ok end - test "does not rewrite unchanged generated entries" do - island = Astral.Islands.Registry.register(component: "islands/Widget.vue", adapter: :vue) - File.touch!(island.entry_path, 1_000_000_000) + test "component identity is portable and independent of instance data", %{tmp_dir: tmp} do + first = + Astral.Islands.Registry.register( + component: "islands/Widget.vue", + adapter: :vue, + props: %{label: "First"} + ) + + {:ok, source} = + Astral.Islands.RuntimePlugin.load(first.entry_path, assets: Path.join(tmp, "assets")) + + second = + Astral.Islands.Registry.register( + component: "islands/Widget.vue", + adapter: :vue, + props: %{label: "Second"}, + client: :media, + media: "(min-width: 40rem)" + ) + + assert first.entry_source == second.entry_source + + assert {:ok, ^source} = + Astral.Islands.RuntimePlugin.load(second.entry_path, + assets: Path.join(tmp, "assets") + ) + + refute source =~ "First" + refute source =~ "Second" + refute source =~ "40rem" + other = Path.join(tmp, "other-checkout") + File.mkdir_p!(Path.join(other, "assets/islands")) + File.cp!(first.component_path, Path.join(other, "assets/islands/Widget.vue")) + Astral.Islands.Registry.start(%Astral.Site{config: Astral.Config.new(root: other)}) + relocated = Astral.Islands.Registry.register(component: "islands/Widget.vue", adapter: :vue) + assert relocated.entry_source == first.entry_source - assert :ok = Astral.Islands.Writer.write!(island) - assert File.stat!(island.entry_path, time: :posix).mtime == 1_000_000_000 + assert {:ok, relocated_source} = + Astral.Islands.RuntimePlugin.load(relocated.entry_path, + assets: Path.join(other, "assets") + ) + + assert relocated_source =~ "Widget.vue" + end + + test "registering virtual entries creates no generated directory", %{tmp_dir: tmp} do + island = Astral.Islands.Registry.register(component: "islands/Widget.vue", adapter: :vue) + assert String.starts_with?(island.entry_path, "astral:islands/entry/") + refute File.exists?(Path.join(tmp, "assets/.astral")) end test "rejects non-string explicit island ids" do diff --git a/test/astral/islands/virtual_entry_test.exs b/test/astral/islands/virtual_entry_test.exs new file mode 100644 index 0000000..53adcce --- /dev/null +++ b/test/astral/islands/virtual_entry_test.exs @@ -0,0 +1,38 @@ +defmodule Astral.Islands.VirtualEntryTest do + use ExUnit.Case, async: true + alias Astral.Islands.VirtualEntry + @moduletag :tmp_dir + + test "valid entries load without writing generated files", %{tmp_dir: root} do + File.mkdir_p!(Path.join(root, "islands")) + component = Path.join(root, "islands/Counter.vue") + File.write!(component, "") + id = VirtualEntry.id(:vue, "islands/Counter.vue") + assert {:ok, :vue, ^component} = VirtualEntry.decode(id, root) + assert {:ok, ^id} = Astral.Islands.RuntimePlugin.resolve(id, nil, assets: root) + assert {:ok, code} = Astral.Islands.RuntimePlugin.load(id, assets: root) + assert code =~ "data-astral-component" + assert code =~ component + refute File.exists?(Path.join(root, ".astral")) + end + + test "rejects tampering, unsupported adapters, missing files, and path escapes", %{ + tmp_dir: root + } do + File.write!(Path.join(root, "Counter.vue"), "") + valid = VirtualEntry.id(:vue, "Counter.vue") + + for id <- [ + valid <> "x", + "astral:islands/entry/not-base64/file.ts", + VirtualEntry.id(:unknown, "Counter.vue"), + VirtualEntry.id(:vue, "missing.vue"), + VirtualEntry.id(:vue, "../Counter.vue"), + VirtualEntry.id(:vue, Path.join(root, "Counter.vue")) + ] do + assert {:error, :invalid_island_entry} = VirtualEntry.decode(id, root) + end + + assert :pass = VirtualEntry.decode("astral:islands/vue", root) + end +end From 86119516060a04fa0266924ed0957e1b9421bae8 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Thu, 10 Sep 2026 23:04:53 +0300 Subject: [PATCH 02/13] docs: keep migration details in the changelog --- CHANGELOG.md | 7 +++++++ guides/features/development-server.md | 3 +-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index caa778c..06e901f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ ## Unreleased +### 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. +- Render documents once, then resolve deferred asset references after building browser assets. Deferred references must be complete HTML `src`, `href`, or `poster` attribute values. + ## 0.2.6 - 2026-09-04 ### Fixed diff --git a/guides/features/development-server.md b/guides/features/development-server.md index 8bed0b0..2396b21 100644 --- a/guides/features/development-server.md +++ b/guides/features/development-server.md @@ -45,8 +45,7 @@ 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. Rendering -islands no longer writes TypeScript into `assets/.astral/islands`. +Props and hydration directives belong to individual HTML instances. ## Build preview From 446631ab08da2ead3940d8b67cd014206dc29af1 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Thu, 10 Sep 2026 23:55:20 +0300 Subject: [PATCH 03/13] deps: use Volt virtual import resolution fix --- mix.exs | 2 +- mix.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/mix.exs b/mix.exs index 78adf02..c65589b 100644 --- a/mix.exs +++ b/mix.exs @@ -40,7 +40,7 @@ defmodule Astral.MixProject do defp deps do [ - {:volt, github: "elixir-volt/volt", ref: "9b25b4d00f023cf70240a89069e5812a07af2212"}, + {:volt, github: "elixir-volt/volt", ref: "d0c31113d831ded053ce5f3c5d0df93f40584d63"}, {:mdex, "~> 0.13"}, {:yaml_elixir, "~> 2.12"}, {:json_spec, "~> 1.1"}, diff --git a/mix.lock b/mix.lock index 33732dc..e3ddb36 100644 --- a/mix.lock +++ b/mix.lock @@ -75,7 +75,7 @@ "vibe_kit": {:hex, :vibe_kit, "0.1.5", "211550fd6f4bffc2525f6ff50bd5000e3abeea3fedb4c9bbfc1b57acfa653568", [:mix], [{:igniter, "~> 0.7", [hex: :igniter, repo: "hexpm", optional: true]}], "hexpm", "b0eae7a400944d789f4bb79f45721029a43bd6688621249c40a2c364aa1e309f"}, "vix": {:hex, :vix, "0.39.0", "ae5c24665a81a69ba36855e299f33ca8869ad6820a70e2a3afcca8615cc83978", [:make, :mix], [{:cc_precompiler, "~> 0.1.4 or ~> 0.2", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.7.3 or ~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:kino, "~> 0.7", [hex: :kino, repo: "hexpm", optional: true]}], "hexpm", "16899fc87686e6a819cee90c29a8e5658a630c64bc6f8d3ed4da7885c35e2f8b"}, "vize": {:hex, :vize, "0.14.2", "4baf40c18e00963ce802c03ebb664feaccd8558d7ec62aa43fb07ad8485d0208", [:mix], [{:rustler, "~> 0.36", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "8336744616bc27ce4edc7448cda6f0b8a481cbec9035d3c4d6bf3229be91386d"}, - "volt": {:git, "https://github.com/elixir-volt/volt.git", "9b25b4d00f023cf70240a89069e5812a07af2212", [ref: "9b25b4d00f023cf70240a89069e5812a07af2212"]}, + "volt": {:git, "https://github.com/elixir-volt/volt.git", "d0c31113d831ded053ce5f3c5d0df93f40584d63", [ref: "d0c31113d831ded053ce5f3c5d0df93f40584d63"]}, "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, "websock_adapter": {:hex, :websock_adapter, "0.6.0", "73db5ab8aaefd1a876a97ce3e6afc96562625de69ef17a4e04426e034849d0b8", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "50021a85bce8f203b086705d9e0c5415e2c7eb05d319111b0428fe71f9934617"}, "xm": {:hex, :xm, "0.2.0", "30a07986b9d96e54b3856c8740ebed026e48234b18e42b7162269111cf57f546", [:mix], [{:saxy, "~> 1.6", [hex: :saxy, repo: "hexpm", optional: false]}], "hexpm", "2fd8a669788adf7ae784b98045868ea184f13c380f158ab740f396a0abf4dd6e"}, From 27d6db69e57c2fb22efa9f7e3b9a8057fc4235b9 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Sun, 13 Sep 2026 22:58:55 +0300 Subject: [PATCH 04/13] fix: use Phoenix escaping for quoted deferred asset references --- CHANGELOG.md | 2 +- guides/features/assets.md | 4 ++-- lib/astral/assets/references.ex | 15 +++++++-------- test/astral/assets/references_test.exs | 22 ++++++++++++++++++---- 4 files changed, 28 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 06e901f..52b5ab7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ - 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. -- Render documents once, then resolve deferred asset references after building browser assets. Deferred references must be complete HTML `src`, `href`, or `poster` attribute values. +- Render documents once, then resolve deferred asset references after building browser assets. Deferred references must be complete quoted HTML `src`, `href`, or `poster` attribute values. ## 0.2.6 - 2026-09-04 diff --git a/guides/features/assets.md b/guides/features/assets.md index 3c59ebe..4e03cdd 100644 --- a/guides/features/assets.md +++ b/guides/features/assets.md @@ -180,8 +180,8 @@ The source root is `assets/`; the browser URL prefix is `/assets`. Use `Astral.asset_path/2` with the source entry name. During static rendering, asset references are deferred until the single Volt build finishes. Deferred -references must be the complete value of an HTML `src`, `href`, or `poster` -attribute. Script/style bodies, text nodes, compound values such as `srcset`, and +references must be the complete value of a quoted HTML `src`, `href`, or `poster` +attribute, as emitted by HEEx. Unquoted references are rejected. Script/style bodies, text nodes, compound values such as `srcset`, and non-HTML generated routes are rejected rather than escaped heuristically. URLs are escaped when finalized, without reserializing the document. diff --git a/lib/astral/assets/references.ex b/lib/astral/assets/references.ex index ec27411..7d7083d 100644 --- a/lib/astral/assets/references.ex +++ b/lib/astral/assets/references.ex @@ -1,5 +1,5 @@ defmodule Astral.Assets.References do - @moduledoc "Per-render asset references finalized only in complete HTML URL attributes." + @moduledoc "Per-render asset references finalized only in complete quoted HTML URL attributes." @key __MODULE__ @url_attributes ~w(src href poster) @@ -34,7 +34,7 @@ defmodule Astral.Assets.References do end) end - @doc "Resolve complete src/href/poster values; reject raw-text, compound, and non-HTML uses." + @doc "Resolve complete quoted src/href/poster values using Phoenix HTML escaping." def finalize(body, references, content_type \\ "text/html") do used = Map.filter(references, fn {token, _url} -> String.contains?(body, token) end) @@ -59,11 +59,16 @@ defmodule Astral.Assets.References do Enum.each(references, fn {token, _url} -> occurrences = length(:binary.matches(body, token)) + quoted = length(:binary.matches(body, ["\"#{token}\"", "'#{token}'"])) if Map.get(allowed, token, 0) != occurrences do raise ArgumentError, "deferred asset references must be complete src, href, or poster attribute values; script/style bodies, text, and compound values are unsupported" end + + if quoted != occurrences do + raise ArgumentError, "deferred asset references require quoted HTML attribute values" + end end) end @@ -86,11 +91,5 @@ defmodule Astral.Assets.References do url |> Phoenix.HTML.html_escape() |> Phoenix.HTML.safe_to_string() - |> String.replace(" ", " ") - |> String.replace("\t", " ") - |> String.replace("\n", " ") - |> String.replace("\r", " ") - |> String.replace("=", "=") - |> String.replace("`", "`") end end diff --git a/test/astral/assets/references_test.exs b/test/astral/assets/references_test.exs index 9cefc06..e335fbf 100644 --- a/test/astral/assets/references_test.exs +++ b/test/astral/assets/references_test.exs @@ -26,10 +26,24 @@ defmodule Astral.Assets.ReferencesTest do end end - test "supports unquoted attributes without allowing attribute injection" do - url = "/asset.js?x=1 y=`value`" - result = References.finalize("", %{"TOKEN" => url}) - assert result |> Floki.parse_document!() |> Floki.attribute("script", "src") == [url] + test "rejects unquoted references, including mixed quoted and unquoted occurrences" do + for html <- ["", ~s()] do + assert_raise ArgumentError, ~r/require quoted HTML attribute values/, fn -> + References.finalize(html, %{"TOKEN" => "/asset\fform-feed.svg"}) + end + end + end + + test "delegates escaping to Phoenix for either quote delimiter without changing markup" do + url = "/asset\fform-feed.svg?x=1 &y=\"quoted\"&z='single'`" + escaped = url |> Phoenix.HTML.html_escape() |> Phoenix.HTML.safe_to_string() + + for quote <- ["\"", "'"] do + html = "\n" + + assert References.finalize(html, %{"TOKEN" => url}) == + "\n" + end end test "token identities cannot overlap after ten references" do From 0827c0b7bf9cc6349b3d837ab5efacd473ac86d3 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 14 Sep 2026 00:20:12 +0300 Subject: [PATCH 05/13] refactor: build discovered island assets before rendering documents --- CHANGELOG.md | 11 ++- guides/features/assets.md | 11 +-- guides/features/astral-templates.md | 22 ++++- lib/astral/assets.ex | 2 +- lib/astral/assets/references.ex | 95 ------------------- lib/astral/builder.ex | 44 ++++----- lib/astral/components.ex | 2 + lib/astral/config.ex | 3 + lib/astral/islands/config.ex | 19 +++- lib/astral/islands/discovery.ex | 99 ++++++++++++++++++++ lib/astral/islands/registry.ex | 40 +++++++- lib/astral/site.ex | 6 +- lib/astral/template/assets.ex | 7 +- mix.exs | 2 +- mix.lock | 2 +- test/astral/assets/build_order_test.exs | 114 +++++++++++++++++++++++ test/astral/assets/references_test.exs | 74 --------------- test/astral/builder_test.exs | 2 +- test/astral/config/reader_test.exs | 6 ++ test/astral/islands/discovery_test.exs | 47 ++++++++++ test/astral/islands/integration_test.exs | 13 +++ 21 files changed, 404 insertions(+), 217 deletions(-) delete mode 100644 lib/astral/assets/references.ex create mode 100644 lib/astral/islands/discovery.ex create mode 100644 test/astral/assets/build_order_test.exs delete mode 100644 test/astral/assets/references_test.exs create mode 100644 test/astral/islands/discovery_test.exs diff --git a/CHANGELOG.md b/CHANGELOG.md index 52b5ab7..7112e04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,12 +2,21 @@ ## Unreleased +### 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. -- Render documents once, then resolve deferred asset references after building browser assets. Deferred references must be complete quoted HTML `src`, `href`, or `poster` attribute values. +- 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 each document that uses them. +- Respect explicitly disabled Tailwind configuration during builds and development startup. ## 0.2.6 - 2026-09-04 diff --git a/guides/features/assets.md b/guides/features/assets.md index 4e03cdd..93deae0 100644 --- a/guides/features/assets.md +++ b/guides/features/assets.md @@ -178,13 +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. During static rendering, -asset references are deferred until the single Volt build finishes. Deferred -references must be the complete value of a quoted HTML `src`, `href`, or `poster` -attribute, as emitted by HEEx. Unquoted references are rejected. Script/style bodies, text nodes, compound values such as `srcset`, and -non-HTML generated routes are rejected rather than escaped heuristically. URLs -are escaped when finalized, without reserializing the document. - +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/lib/astral/assets.ex b/lib/astral/assets.ex index 61ca170..65abf1c 100644 --- a/lib/astral/assets.ex +++ b/lib/astral/assets.ex @@ -23,7 +23,7 @@ defmodule Astral.Assets do do: path(config, Path.basename(source)) def path(%Astral.Config{} = config, source) do - Astral.Assets.References.register(config, source) || resolve_path(config, source) + resolve_path(config, source) end defp resolve_path(config, source) do diff --git a/lib/astral/assets/references.ex b/lib/astral/assets/references.ex deleted file mode 100644 index 7d7083d..0000000 --- a/lib/astral/assets/references.ex +++ /dev/null @@ -1,95 +0,0 @@ -defmodule Astral.Assets.References do - @moduledoc "Per-render asset references finalized only in complete quoted HTML URL attributes." - - @key __MODULE__ - @url_attributes ~w(src href poster) - - def start do - Process.put(@key, %{ - nonce: Base.url_encode64(:crypto.strong_rand_bytes(18), padding: false), - references: %{} - }) - end - - def stop, do: Process.delete(@key) - - def register(config, source) do - case Process.get(@key) do - nil -> - nil - - %{nonce: nonce, references: references} = state -> - token = "astral-asset-#{nonce}-#{map_size(references)}-end" - Process.put(@key, %{state | references: Map.put(references, token, {config, source})}) - token - end - end - - def resolve do - %{references: references} = Process.get(@key) - stop() - - Map.new(references, fn {token, {config, source}} -> - {token, Astral.Assets.path(config, source)} - end) - end - - @doc "Resolve complete quoted src/href/poster values using Phoenix HTML escaping." - def finalize(body, references, content_type \\ "text/html") do - used = Map.filter(references, fn {token, _url} -> String.contains?(body, token) end) - - if map_size(used) == 0 do - body - else - validate_contexts!(body, used, content_type) - - String.replace(body, Map.keys(used), fn token -> - used |> Map.fetch!(token) |> escape_url() - end) - end - end - - defp validate_contexts!(body, references, content_type) do - unless content_type |> String.split(";", parts: 2) |> hd() |> String.trim() == "text/html" do - raise ArgumentError, "deferred asset references are only supported in HTML documents" - end - - tree = Floki.parse_document!(body) - allowed = allowed_references(tree, references) |> List.flatten() |> Enum.frequencies() - - Enum.each(references, fn {token, _url} -> - occurrences = length(:binary.matches(body, token)) - quoted = length(:binary.matches(body, ["\"#{token}\"", "'#{token}'"])) - - if Map.get(allowed, token, 0) != occurrences do - raise ArgumentError, - "deferred asset references must be complete src, href, or poster attribute values; script/style bodies, text, and compound values are unsupported" - end - - if quoted != occurrences do - raise ArgumentError, "deferred asset references require quoted HTML attribute values" - end - end) - end - - defp allowed_references(nodes, references) do - Enum.flat_map(nodes, fn - {_tag, attributes, children} -> - values = - for {name, value} <- attributes, - name in @url_attributes and Map.has_key?(references, value), - do: value - - [values, allowed_references(children, references)] - - _ -> - [] - end) - end - - defp escape_url(url) do - url - |> Phoenix.HTML.html_escape() - |> Phoenix.HTML.safe_to_string() - end -end diff --git a/lib/astral/builder.ex b/lib/astral/builder.ex index 64a393c..1823a48 100644 --- a/lib/astral/builder.ex +++ b/lib/astral/builder.ex @@ -26,7 +26,10 @@ defmodule Astral.Builder do {:ok, site} <- Astral.Discovery.discover(config), :ok <- prepare_outdir(config), :ok <- copy_public(config), - {:ok, assets} <- render_and_build(site) 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 @@ -78,7 +81,7 @@ defmodule Astral.Builder do public_dir: false, tailwind: tailwind, tailwind_sources: - Astral.Assets.Sources.tailwind(config, Keyword.get(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, @@ -113,30 +116,15 @@ defmodule Astral.Builder do |> Enum.any?() end - defp render_and_build(site) do - Astral.Assets.References.start() - - try do - with {:ok, islands, documents} <- render_site(site), - {:ok, assets} <- build_assets(site.config, Enum.map(islands, & &1.entry_path)) do - references = Astral.Assets.References.resolve() - - Enum.reduce_while(documents, {:ok, assets}, fn {path, body, content_type}, result -> - with :ok <- File.mkdir_p(Path.dirname(path)), - :ok <- - File.write( - path, - Astral.Assets.References.finalize(body, references, content_type) - ) do - {:cont, result} - else - {:error, _} = error -> {:halt, error} - end - end) + 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 - after - Astral.Assets.References.stop() - end + end) end defp render_site(site) do @@ -147,7 +135,7 @@ defmodule Astral.Builder do with {:ok, pages} <- render_pages(site), {:ok, routes} <- render_routes(site), :ok <- Astral.Image.Builder.build(site) do - {:ok, Astral.Islands.Registry.islands(), pages ++ routes} + {:ok, pages ++ routes} end after Astral.Image.Registry.stop() @@ -166,6 +154,8 @@ defmodule Astral.Builder do 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) do {:ok, {page.output_path, html, "text/html"}} @@ -186,6 +176,8 @@ defmodule Astral.Builder do end defp render_route(route, site) do + Astral.Islands.Registry.start_document() + with :ok <- validate_output_path(route.output_path, site.config), {:ok, body, content_type} <- render_route_body(site.config.plugins, route, site) do {:ok, {route.output_path, IO.iodata_to_binary(body), content_type}} diff --git a/lib/astral/components.ex b/lib/astral/components.ex index 9aaad48..3a1e9ac 100644 --- a/lib/astral/components.ex +++ b/lib/astral/components.ex @@ -132,8 +132,10 @@ defmodule Astral.Components do assigns |> assign(:island, island) |> assign(:entry_path, Astral.Assets.path(site, island.entry_source)) + |> assign(:island_styles, Astral.Islands.Registry.styles(island)) ~H""" +
Enum.uniq() end - %__MODULE__{adapters: adapters} + components = + opts + |> Keyword.get_values(:component) + |> Enum.map(fn + {adapter, path} when is_binary(path) -> + {normalize_adapter!(adapter), path} + + value -> + raise ArgumentError, "expected island component {adapter, path}, got: #{inspect(value)}" + end) + |> Enum.uniq() + + %__MODULE__{adapters: adapters, components: components} end @doc "Return true when an adapter is enabled." diff --git a/lib/astral/islands/discovery.ex b/lib/astral/islands/discovery.ex new file mode 100644 index 0000000..72d4ac0 --- /dev/null +++ b/lib/astral/islands/discovery.ex @@ -0,0 +1,99 @@ +defmodule Astral.Islands.Discovery do + @moduledoc "Discover literal HEEx island references without executing page setup or rendering." + + alias Astral.Islands.{Adapter, VirtualEntry} + + @doc "Return build entry identities from templates and explicitly declared components." + def entries(config) do + discovered = + [config.pages, config.layouts, config.components | Enum.map(config.collections, & &1.dir)] + |> Enum.flat_map(&Path.wildcard(Path.join(&1, "**/*.{astral,md}"))) + |> Enum.uniq() + |> Enum.sort() + |> Enum.flat_map(&file_components/1) + + (config.islands.components ++ discovered) + |> Enum.uniq() + |> Enum.map(fn {adapter, component} -> + unless Astral.Islands.Config.adapter?(config.islands, adapter) do + raise ArgumentError, "Astral island adapter is not enabled: #{inspect(adapter)}" + end + + relative = component |> Path.expand(config.assets) |> Path.relative_to(config.assets) + id = VirtualEntry.id(adapter, relative) + + case VirtualEntry.decode(id, config.assets) do + {:ok, _, _} -> id + {:error, _} -> raise ArgumentError, "invalid island component: #{inspect(component)}" + end + end) + end + + defp file_components(path) do + source = File.read!(path) + + source = + if Path.extname(path) == ".md" do + {:ok, html} = Astral.Markdown.to_heex_html(source, file: path) + html + else + Astral.Template.Assets.template_source(source) + end + + {:ok, parsed} = + Phoenix.LiveView.TagEngine.Parser.parse(source, + file: path, + tag_handler: Phoenix.LiveView.HTMLEngine, + skip_macro_components: true + ) + + collect(parsed.nodes) + end + + defp collect(nodes), do: Enum.flat_map(nodes, &collect_node/1) + + defp collect_node({:self_close, :local_component, name, attrs, _meta}), + do: component(name, attrs) + + defp collect_node({:block, :local_component, name, attrs, children, _open, _close}), + do: component(name, attrs) ++ collect(children) + + defp collect_node({:block, _type, _name, _attrs, children, _open, _close}), + do: collect(children) + + defp collect_node({:eex_block, _expression, branches, _meta}), + do: Enum.flat_map(branches, fn {nodes, _ending, _meta} -> collect(nodes) end) + + defp collect_node(_), do: [] + + defp component(name, attrs) do + adapter = + if name == "island", + do: literal(attrs, "adapter"), + else: Enum.find(Adapter.all(), &(Atom.to_string(&1) == name)) + + case {adapter, literal(attrs, "component")} do + {adapter, path} when is_atom(adapter) and not is_nil(adapter) and is_binary(path) -> + [{adapter, path}] + + _ -> + [] + end + end + + defp literal(attrs, name) do + Enum.find_value(attrs, fn + {^name, {:string, value, _meta}, _attr_meta} -> + value + + {^name, {:expr, source, _meta}, _attr_meta} -> + case Code.string_to_quoted(source, existing_atoms_only: true) do + {:ok, value} when is_binary(value) or is_atom(value) -> value + _ -> nil + end + + _ -> + nil + end) + end +end diff --git a/lib/astral/islands/registry.ex b/lib/astral/islands/registry.ex index e9ce324..fed31ff 100644 --- a/lib/astral/islands/registry.ex +++ b/lib/astral/islands/registry.ex @@ -14,16 +14,42 @@ defmodule Astral.Islands.Registry do @type state :: %{ site: Astral.Site.t(), islands: %{String.t() => Island.t()}, - ids: %{String.t() => pos_integer()} + ids: %{String.t() => pos_integer()}, + styles: MapSet.t(String.t()) } @doc "Start an empty island registry for a site render." @spec start(Astral.Site.t()) :: :ok def start(%Astral.Site{} = site) do - Process.put(@key, %{site: site, islands: %{}, ids: %{}}) + Process.put(@key, %{site: site, islands: %{}, ids: %{}, styles: MapSet.new()}) :ok end + @doc "Start document-local island identities and stylesheet deduplication." + def start_document do + Process.put(@key, %{state!() | islands: %{}, ids: %{}, styles: MapSet.new()}) + :ok + end + + @doc "Return styles needed by this island that have not been emitted in this document." + def styles(island) do + state = state!() + + files = + case state.site.asset_manifest do + nil -> + [] + + manifest -> + key = Path.rootname(Path.basename(island.entry_source)) <> ".js" + Volt.Builder.ManifestEntry.stylesheets(manifest, key) + end + + fresh = Enum.reject(files, &MapSet.member?(state.styles, &1)) + Process.put(@key, %{state | styles: Enum.into(fresh, state.styles)}) + Enum.map(fresh, &Volt.URL.join(state.site.config.asset_url_prefix, &1)) + end + @doc "Clear the current process registry." @spec stop() :: :ok def stop do @@ -70,6 +96,16 @@ defmodule Astral.Islands.Registry do Path.relative_to(component_path, site.config.assets) ) + if is_map(site.asset_manifest) do + key = Path.rootname(Path.basename(entry_source)) <> ".js" + + unless Map.has_key?(site.asset_manifest, key) do + raise ArgumentError, + "island component #{inspect(component)} was not discovered before the asset build; " <> + "declare component #{inspect(adapter)}, #{inspect(component)} in the islands configuration" + end + end + entry_path = entry_source island = %Island{ diff --git a/lib/astral/site.ex b/lib/astral/site.ex index b1f9fa7..227916e 100644 --- a/lib/astral/site.ex +++ b/lib/astral/site.ex @@ -13,7 +13,8 @@ defmodule Astral.Site do collections: [Astral.Collection.t()], entries: entries(), routes: [Astral.Route.t()], - mode: :build | :dev + mode: :build | :dev, + asset_manifest: %{String.t() => Volt.Builder.ManifestEntry.t()} | nil } defstruct config: nil, @@ -22,5 +23,6 @@ defmodule Astral.Site do collections: [], entries: %{}, routes: [], - mode: :build + mode: :build, + asset_manifest: nil end diff --git a/lib/astral/template/assets.ex b/lib/astral/template/assets.ex index 3c2a3f9..c91e3d1 100644 --- a/lib/astral/template/assets.ex +++ b/lib/astral/template/assets.ex @@ -44,14 +44,17 @@ defmodule Astral.Template.Assets do end end - defp template_source("---\n" <> rest) do + @doc false + def template_source("---\n" <> rest) do case String.split(rest, "\n---\n", parts: 2) do [_setup, template] -> template [_] -> "---\n" <> rest end end - defp template_source(source), do: source + def template_source(source) do + source + end defp parse(source, opts) do Phoenix.LiveView.TagEngine.Parser.parse(source, diff --git a/mix.exs b/mix.exs index c65589b..69286ef 100644 --- a/mix.exs +++ b/mix.exs @@ -40,7 +40,7 @@ defmodule Astral.MixProject do defp deps do [ - {:volt, github: "elixir-volt/volt", ref: "d0c31113d831ded053ce5f3c5d0df93f40584d63"}, + {:volt, github: "elixir-volt/volt", ref: "93d485e57df19ee2041ebf47bab72e339cd8df92"}, {:mdex, "~> 0.13"}, {:yaml_elixir, "~> 2.12"}, {:json_spec, "~> 1.1"}, diff --git a/mix.lock b/mix.lock index e3ddb36..dd1f0c0 100644 --- a/mix.lock +++ b/mix.lock @@ -75,7 +75,7 @@ "vibe_kit": {:hex, :vibe_kit, "0.1.5", "211550fd6f4bffc2525f6ff50bd5000e3abeea3fedb4c9bbfc1b57acfa653568", [:mix], [{:igniter, "~> 0.7", [hex: :igniter, repo: "hexpm", optional: true]}], "hexpm", "b0eae7a400944d789f4bb79f45721029a43bd6688621249c40a2c364aa1e309f"}, "vix": {:hex, :vix, "0.39.0", "ae5c24665a81a69ba36855e299f33ca8869ad6820a70e2a3afcca8615cc83978", [:make, :mix], [{:cc_precompiler, "~> 0.1.4 or ~> 0.2", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.7.3 or ~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:kino, "~> 0.7", [hex: :kino, repo: "hexpm", optional: true]}], "hexpm", "16899fc87686e6a819cee90c29a8e5658a630c64bc6f8d3ed4da7885c35e2f8b"}, "vize": {:hex, :vize, "0.14.2", "4baf40c18e00963ce802c03ebb664feaccd8558d7ec62aa43fb07ad8485d0208", [:mix], [{:rustler, "~> 0.36", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "8336744616bc27ce4edc7448cda6f0b8a481cbec9035d3c4d6bf3229be91386d"}, - "volt": {:git, "https://github.com/elixir-volt/volt.git", "d0c31113d831ded053ce5f3c5d0df93f40584d63", [ref: "d0c31113d831ded053ce5f3c5d0df93f40584d63"]}, + "volt": {:git, "https://github.com/elixir-volt/volt.git", "93d485e57df19ee2041ebf47bab72e339cd8df92", [ref: "93d485e57df19ee2041ebf47bab72e339cd8df92"]}, "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, "websock_adapter": {:hex, :websock_adapter, "0.6.0", "73db5ab8aaefd1a876a97ce3e6afc96562625de69ef17a4e04426e034849d0b8", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "50021a85bce8f203b086705d9e0c5415e2c7eb05d319111b0428fe71f9934617"}, "xm": {:hex, :xm, "0.2.0", "30a07986b9d96e54b3856c8740ebed026e48234b18e42b7162269111cf57f546", [:mix], [{:saxy, "~> 1.6", [hex: :saxy, repo: "hexpm", optional: false]}], "hexpm", "2fd8a669788adf7ae784b98045868ea184f13c380f158ab740f396a0abf4dd6e"}, diff --git a/test/astral/assets/build_order_test.exs b/test/astral/assets/build_order_test.exs new file mode 100644 index 0000000..02aabb3 --- /dev/null +++ b/test/astral/assets/build_order_test.exs @@ -0,0 +1,114 @@ +defmodule Astral.Assets.BuildOrderTest do + use ExUnit.Case, async: false + + defmodule JSONRoute do + @behaviour Astral.Plugin + def name, do: "asset-json-test" + + def routes(site), + do: [Astral.Route.new("/data.json", site.config, content_type: "application/json")] + + def render_route(%{path: "/data.json"}, site) do + Process.put(:asset_json_render_count, Process.get(:asset_json_render_count, 0) + 1) + {:ok, Jason.encode!(%{url: Astral.asset_path(site, "app.js")}), "application/json"} + end + + def render_route(_, _), do: nil + end + + @moduletag :tmp_dir + setup %{tmp_dir: root} do + File.mkdir_p!(Path.join(root, "pages")) + File.mkdir_p!(Path.join(root, "assets")) + Astral.Islands.SiteFixtures.link_node_modules!(root) + File.write!(Path.join(root, "assets/app.js"), "console.log('app')") + + File.write!(Path.join(root, "assets/Viewer.vue"), """ + + + + """) + + :ok + end + + test "resolves real hashed URLs before props and JSON serialization, rendering each route once", + %{tmp_dir: root} do + File.write!(Path.join(root, "pages/index.astral"), """ + <.vue component="Viewer.vue" props={%{url: Astral.asset_path(@site, "app.js")}} /> + """) + + Process.put(:asset_json_render_count, 0) + assert {:ok, result} = Astral.build(root: root, layout: false, plugins: [JSONRoute]) + assert Process.get(:asset_json_render_count) == 1 + Process.delete(:asset_json_render_count) + expected = "/assets/" <> result.assets.manifest["app.js"].file + html = File.read!(Path.join(root, "dist/index.html")) |> Floki.parse_document!() + [props] = Floki.attribute(html, "[data-astral-island]", "data-astral-props") + assert Jason.decode!(props)["url"] == expected + + assert root |> Path.join("dist/data.json") |> File.read!() |> Jason.decode!() == %{ + "url" => expected + } + end + + test "emits island styles once per document and only on documents using the island", %{ + tmp_dir: root + } do + for name <- ["index", "second"] do + File.write!( + Path.join(root, "pages/#{name}.astral"), + "<.vue component=\"Viewer.vue\"/><.vue component=\"Viewer.vue\"/>" + ) + end + + File.write!(Path.join(root, "pages/plain.astral"), "

Plain

") + assert {:ok, result} = Astral.build(root: root, layout: false) + + key = + Astral.Islands.VirtualEntry.id(:vue, "Viewer.vue") + |> Path.basename() + |> Path.rootname() + |> Kernel.<>(".js") + + expected = Enum.map(result.assets.manifest[key].css, &"/assets/#{&1}") + assert [_] = expected + + for path <- ["dist/index.html", "dist/second/index.html"] do + html = root |> Path.join(path) |> File.read!() |> Floki.parse_document!() + assert Floki.attribute(html, "link[rel=stylesheet]", "href") == expected + end + + refute File.read!(Path.join(root, "dist/plain/index.html")) =~ "rel=\"stylesheet\"" + end + + test "runtime-selected components require declarations", %{tmp_dir: root} do + File.write!(Path.join(root, "pages/index.astral"), """ + --- + assigns = assign(assigns, :viewer, "Viewer.vue") + --- + <.vue component={@viewer} /> + """) + + assert_raise ArgumentError, ~r/declare component :vue/, fn -> + Astral.build(root: root, layout: false) + end + + assert {:ok, _} = + Astral.build(root: root, layout: false, islands: [component: {:vue, "Viewer.vue"}]) + end + + test "builds JavaScript when Tailwind is explicitly disabled", %{tmp_dir: root} do + previous = Application.get_env(:volt, :tailwind) + Application.put_env(:volt, :tailwind, false) + + on_exit(fn -> + if is_nil(previous), + do: Application.delete_env(:volt, :tailwind), + else: Application.put_env(:volt, :tailwind, previous) + end) + + File.write!(Path.join(root, "pages/index.astral"), "

Plain

") + assert {:ok, _} = Astral.build(root: root, layout: false) + end +end diff --git a/test/astral/assets/references_test.exs b/test/astral/assets/references_test.exs deleted file mode 100644 index e335fbf..0000000 --- a/test/astral/assets/references_test.exs +++ /dev/null @@ -1,74 +0,0 @@ -defmodule Astral.Assets.ReferencesTest do - use ExUnit.Case, async: true - - alias Astral.Assets.References - - test "escapes resolved HTML attributes exactly once" do - url = ~s(/assets/a.js?v=1&x="quoted") - html = References.finalize(~s(), %{"TOKEN" => url}) - assert html =~ "&" - assert html =~ """ - assert html |> Floki.parse_document!() |> Floki.attribute("script", "src") == [url] - end - - test "rejects raw text and compound uses instead of guessing escaping" do - for body <- [ - ~s(), - ~s||, - ~s(link), - ~s(

TOKEN

), - ~s(), - ~s() - ] do - assert_raise ArgumentError, ~r/complete src, href, or poster/, fn -> - References.finalize(body, %{"TOKEN" => "/asset.js"}) - end - end - end - - test "rejects unquoted references, including mixed quoted and unquoted occurrences" do - for html <- ["", ~s()] do - assert_raise ArgumentError, ~r/require quoted HTML attribute values/, fn -> - References.finalize(html, %{"TOKEN" => "/asset\fform-feed.svg"}) - end - end - end - - test "delegates escaping to Phoenix for either quote delimiter without changing markup" do - url = "/asset\fform-feed.svg?x=1 &y=\"quoted\"&z='single'`" - escaped = url |> Phoenix.HTML.html_escape() |> Phoenix.HTML.safe_to_string() - - for quote <- ["\"", "'"] do - html = "\n" - - assert References.finalize(html, %{"TOKEN" => url}) == - "\n" - end - end - - test "token identities cannot overlap after ten references" do - config = Astral.Config.new(root: System.tmp_dir!()) - References.start() - - try do - tokens = for _ <- 0..11, do: References.register(config, "app.js") - refs = tokens |> Enum.with_index() |> Map.new(fn {token, i} -> {token, "/#{i}.js"} end) - body = Enum.map_join(tokens, &~s()) - result = References.finalize(body, refs) - - assert result |> Floki.parse_document!() |> Floki.attribute("script", "src") == - Enum.map(0..11, &"/#{&1}.js") - after - References.stop() - end - end - - test "does not change non-HTML output without deferred references" do - assert References.finalize(~s({"plain":true}), %{"TOKEN" => "/asset.js"}, "application/json") == - ~s({"plain":true}) - - assert_raise ArgumentError, ~r/only supported in HTML/, fn -> - References.finalize(~s({"url":"TOKEN"}), %{"TOKEN" => "/asset.js"}, "application/json") - end - end -end diff --git a/test/astral/builder_test.exs b/test/astral/builder_test.exs index 693cd85..e199805 100644 --- a/test/astral/builder_test.exs +++ b/test/astral/builder_test.exs @@ -319,7 +319,7 @@ defmodule Astral.BuilderTest do assert {:ok, _} = Astral.build(root: tmp(), layout: false, asset_entry: "app.ts") end - test "renders island pages once and resolves their deferred asset references" do + test "renders island pages once with built asset URLs" do write("assets/app.ts", "console.log('entry')") write( diff --git a/test/astral/config/reader_test.exs b/test/astral/config/reader_test.exs index e778c23..8c70fea 100644 --- a/test/astral/config/reader_test.exs +++ b/test/astral/config/reader_test.exs @@ -22,6 +22,11 @@ defmodule Astral.Config.ReaderTest do entry "client.js" url_prefix "/ui" end + + islands do + component :vue, "Gallery.vue" + component :react, "Viewer.jsx" + end end """) @@ -33,6 +38,7 @@ defmodule Astral.Config.ReaderTest do assert config.layout == "base.html" assert config.asset_entry == [Path.join(tmp_dir, "ui/client.js")] assert config.asset_url_prefix == "/ui" + assert config.islands.components == [vue: "Gallery.vue", react: "Viewer.jsx"] end test "reads top-level astral.config.exs declarations", %{tmp_dir: tmp_dir} do diff --git a/test/astral/islands/discovery_test.exs b/test/astral/islands/discovery_test.exs new file mode 100644 index 0000000..e034939 --- /dev/null +++ b/test/astral/islands/discovery_test.exs @@ -0,0 +1,47 @@ +defmodule Astral.Islands.DiscoveryTest do + use ExUnit.Case, async: true + + @tag :tmp_dir + test "discovers literal references without executing setup, including EEx branches and Markdown", + %{tmp_dir: root} do + File.mkdir_p!(Path.join(root, "assets")) + File.mkdir_p!(Path.join(root, "pages")) + + for name <- ["A.vue", "B.vue", "C.vue"] do + File.write!(Path.join(root, "assets/#{name}"), "") + end + + File.write!(Path.join(root, "pages/index.astral"), """ + --- + raise "setup must not execute during discovery" + --- + <%= if @show do %> + <.vue component="./A.vue" /> + <% else %> + <.island adapter={:vue} component={"B.vue"} /> + <% end %> + <.vue component={@dynamic} /> + """) + + File.write!(Path.join(root, "pages/post.md"), "# Post\n\n<.vue component=\"C.vue\" />") + config = Astral.Config.new(root: root) + entries = Astral.Islands.Discovery.entries(config) + + assert Enum.sort(entries) == + Enum.sort( + for name <- ["A.vue", "B.vue", "C.vue"], + do: Astral.Islands.VirtualEntry.id(:vue, name) + ) + end + + @tag :tmp_dir + test "includes explicitly configured runtime-selected components", %{tmp_dir: root} do + File.mkdir_p!(Path.join(root, "assets")) + File.write!(Path.join(root, "assets/Dynamic.vue"), "") + config = Astral.Config.new(root: root, islands: [component: {:vue, "Dynamic.vue"}]) + + assert Astral.Islands.Discovery.entries(config) == [ + Astral.Islands.VirtualEntry.id(:vue, "Dynamic.vue") + ] + end +end diff --git a/test/astral/islands/integration_test.exs b/test/astral/islands/integration_test.exs index f1e51ae..4d52b59 100644 --- a/test/astral/islands/integration_test.exs +++ b/test/astral/islands/integration_test.exs @@ -42,6 +42,12 @@ defmodule Astral.Islands.IntegrationTest do end test "mounts mixed framework islands from a static build in a browser" do + File.write!( + Path.join(tmp(), "assets/islands/Gallery.vue"), + "\n", + [:append] + ) + assert {:ok, _result} = Astral.build(root: tmp(), layout: false, asset_hash: false) html = File.read!(Path.join(tmp(), "dist/index.html")) |> Floki.parse_document!() vue = html |> Floki.find("[data-astral-island=vue]") @@ -70,6 +76,13 @@ defmodule Astral.Islands.IntegrationTest do assert_eventually_text(frame, "#vue-result", "Vue Gallery Vue slot") assert_eventually_text(frame, "#vue-secondary", "Vue Second") + + assert {:ok, "rgb(12, 34, 56)"} = + Frame.evaluate(frame.guid, + expression: "getComputedStyle(document.querySelector('#vue-result')).color", + timeout: 5_000 + ) + assert_eventually_text(frame, "#svelte-result", "Svelte Counter Svelte slot") assert_eventually_text(frame, "#react-result", "React Counter React slot") assert_eventually_text(frame, "#react-secondary", "React Second") From 761d2a57360b7b0bee8a1edf1a5c0c42eb95031b Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 14 Sep 2026 09:59:19 +0300 Subject: [PATCH 06/13] Activate shared nested islands and finalize document styles --- CHANGELOG.md | 3 +- guides/features/ui-and-browser-code.md | 6 +- lib/astral/assets/stylesheets.ex | 40 ++++++++++++ lib/astral/builder.ex | 10 ++- lib/astral/components.ex | 2 - lib/astral/islands/registry.ex | 41 ++++++------ mix.exs | 3 +- mix.lock | 4 +- priv/islands/entry.ts | 30 +++++---- priv/islands/runtime.ts | 2 +- test/astral/assets/stylesheets_test.exs | 49 ++++++++++++++ test/astral/islands/integration_test.exs | 68 ++++++++++++++++++++ test/astral/islands/registry_test.exs | 33 ++++++++++ test/support/astral/islands/site_fixtures.ex | 32 +++++++++ 14 files changed, 284 insertions(+), 39 deletions(-) create mode 100644 lib/astral/assets/stylesheets.ex create mode 100644 test/astral/assets/stylesheets_test.exs diff --git a/CHANGELOG.md b/CHANGELOG.md index 7112e04..e56119f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,8 @@ ### Fixed -- Include production island stylesheet dependencies in each document that uses them. +- 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. ## 0.2.6 - 2026-09-04 diff --git a/guides/features/ui-and-browser-code.md b/guides/features/ui-and-browser-code.md index 66a3be3..b3a2ce9 100644 --- a/guides/features/ui-and-browser-code.md +++ b/guides/features/ui-and-browser-code.md @@ -161,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/lib/astral/assets/stylesheets.ex b/lib/astral/assets/stylesheets.ex new file mode 100644 index 0000000..9e9bdf8 --- /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 inject(String.t(), [String.t()], String.t()) :: String.t() + def inject(html, styles, content_type \\ "text/html") + def inject(html, [], _content_type), do: html + + def inject(html, styles, content_type) do + if html?(content_type), do: inject_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 inject_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/builder.ex b/lib/astral/builder.ex index 1823a48..2b54227 100644 --- a/lib/astral/builder.ex +++ b/lib/astral/builder.ex @@ -158,6 +158,7 @@ defmodule Astral.Builder do with :ok <- validate_output_path(page.output_path, site.config), {:ok, html} <- Astral.Renderer.render_page(site, page) do + html = Astral.Assets.Stylesheets.inject(html, Astral.Islands.Registry.stylesheets()) {:ok, {page.output_path, html, "text/html"}} else {:error, {:missing_layout, _path, _layout} = reason} -> {:error, reason} @@ -180,7 +181,14 @@ defmodule Astral.Builder do with :ok <- validate_output_path(route.output_path, site.config), {:ok, body, content_type} <- render_route_body(site.config.plugins, route, site) do - {:ok, {route.output_path, IO.iodata_to_binary(body), content_type}} + body = + Astral.Assets.Stylesheets.inject( + 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}} diff --git a/lib/astral/components.ex b/lib/astral/components.ex index 3a1e9ac..9aaad48 100644 --- a/lib/astral/components.ex +++ b/lib/astral/components.ex @@ -132,10 +132,8 @@ defmodule Astral.Components do assigns |> assign(:island, island) |> assign(:entry_path, Astral.Assets.path(site, island.entry_source)) - |> assign(:island_styles, Astral.Islands.Registry.styles(island)) ~H""" -
Island.t()}, ids: %{String.t() => pos_integer()}, - styles: MapSet.t(String.t()) + entries: [String.t()] } @doc "Start an empty island registry for a site render." @spec start(Astral.Site.t()) :: :ok def start(%Astral.Site{} = site) do - Process.put(@key, %{site: site, islands: %{}, ids: %{}, styles: MapSet.new()}) + Process.put(@key, %{site: site, islands: %{}, ids: %{}, entries: []}) :ok end - @doc "Start document-local island identities and stylesheet deduplication." + @doc "Start document-local island identities and dependency collection." def start_document do - Process.put(@key, %{state!() | islands: %{}, ids: %{}, styles: MapSet.new()}) + Process.put(@key, %{state!() | islands: %{}, ids: %{}, entries: []}) :ok end - @doc "Return styles needed by this island that have not been emitted in this document." - def styles(island) do + @doc "Return the completed document's stylesheet dependencies in registration order." + @spec stylesheets() :: [String.t()] + def stylesheets do state = state!() - files = - case state.site.asset_manifest do - nil -> - [] - - manifest -> - key = Path.rootname(Path.basename(island.entry_source)) <> ".js" - Volt.Builder.ManifestEntry.stylesheets(manifest, key) - end - - fresh = Enum.reject(files, &MapSet.member?(state.styles, &1)) - Process.put(@key, %{state | styles: Enum.into(fresh, state.styles)}) - Enum.map(fresh, &Volt.URL.join(state.site.config.asset_url_prefix, &1)) + case state.site.asset_manifest do + nil -> + [] + + manifest -> + state.entries + |> Enum.reverse() + |> Enum.uniq() + |> Enum.flat_map(&Volt.Builder.ManifestEntry.stylesheets(manifest, &1)) + |> Enum.uniq() + |> Enum.map(&Volt.URL.join(state.site.config.asset_url_prefix, &1)) + end end @doc "Clear the current process registry." @@ -122,7 +122,8 @@ defmodule Astral.Islands.Registry do } islands = Map.put(state.islands, id, island) - Process.put(@key, %{state | islands: islands, ids: ids}) + key = Path.rootname(Path.basename(entry_source)) <> ".js" + Process.put(@key, %{state | islands: islands, ids: ids, entries: [key | state.entries]}) island end diff --git a/mix.exs b/mix.exs index 69286ef..b000c18 100644 --- a/mix.exs +++ b/mix.exs @@ -40,7 +40,7 @@ defmodule Astral.MixProject do defp deps do [ - {:volt, github: "elixir-volt/volt", ref: "93d485e57df19ee2041ebf47bab72e339cd8df92"}, + {:volt, github: "elixir-volt/volt", ref: "af5ef72b951cc3051461517c896a31b66e47be76"}, {:mdex, "~> 0.13"}, {:yaml_elixir, "~> 2.12"}, {:json_spec, "~> 1.1"}, @@ -54,6 +54,7 @@ defmodule Astral.MixProject do {:phoenix_live_view, "~> 1.2"}, {:bandit, "~> 1.12"}, {:floki, "~> 0.38"}, + {:lazy_html, "~> 0.1.12"}, {:plug, "~> 1.20"}, {:phoenix_iconify, "~> 0.3.5"}, {:igniter, "~> 0.8", optional: true}, diff --git a/mix.lock b/mix.lock index dd1f0c0..afe3ec7 100644 --- a/mix.lock +++ b/mix.lock @@ -20,6 +20,7 @@ "ex_slop": {:hex, :ex_slop, "0.4.2", "142aba9a82eddfb258e39c45d59392ab3cdb6b5a3ad401b09b362b7134fc54eb", [:mix], [{:credo, "~> 1.7", [hex: :credo, repo: "hexpm", optional: false]}], "hexpm", "c7f5316f755f83566e7a0a049f6fedfcd5ff916fce83c6ebfdf806be62fd7a69"}, "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, "finch": {:hex, :finch, "0.23.0", "e3f9287ac25a8832f848b144c2b57346aac65b205e2e0629a52adfe6507fd837", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.8", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "80e58d3f936f57e3fdf404f83a3642897ae6d9fb642934e46da4d8fe761b99d5"}, + "fine": {:hex, :fine, "0.1.6", "4bf7151493443c454aac9f2fa2f34f5fefd0346a83fb5586a016c4a135c63247", [:mix], [], "hexpm", "5638eb4495488e885ebec167fa57973e5c35e1a50c344eb7666c90ec1c4e3b12"}, "floki": {:hex, :floki, "0.38.4", "10f98971e892aed2c2f1b3a0f928e488e3797e1c6dd3dfd98db40b14e9a78bcf", [:mix], [], "hexpm", "bdb34645eee8e79845c7edaca2d4099a52804ee4d4a3ecc683a69451f0244973"}, "glob_ex": {:hex, :glob_ex, "0.1.12", "7b2d9369c20e2697efcfd185d13d6e84c94cd3bfd2730fbde613141c2e015c00", [:mix], [], "hexpm", "2e2fac83f113514434c7eaf267b4c38af2f91766f1cab2c5db7053b7fc1ee0bb"}, "hex_solver": {:hex, :hex_solver, "0.3.0", "81e7659ad6caba1f856d89fc7ca52f88e8eb4f801deedcdce215f9006d706d1d", [:mix], [], "hexpm", "8a04c8ef0df25ca1f5e4d7d5f32833fac569f5c6442f05e216ee33615593e64d"}, @@ -32,6 +33,7 @@ "json_codec": {:hex, :json_codec, "0.2.3", "b75b2f76a2c89844a72f2dcc8f83c045d0fb030da041b9c2278c82f70067ec78", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "4dad674cbee1119161b555a155ef1537908ee337d49e619e1b155313ec523b71"}, "json_spec": {:hex, :json_spec, "1.1.1", "f447923eae57121ab30774ad3a32e38bb51d9f5157811d8c2599dc143743faf4", [:mix], [], "hexpm", "47a75442b203bb65f0b057dc33d4888668acb8042aa41e4785e2467aa2aab3ad"}, "jsv": {:hex, :jsv, "0.19.6", "1d76146b2372d4bde954a08453eaca671b436cf9ef80976ed5afd748a04b25a6", [:mix], [{:abnf_parsec, "~> 2.0", [hex: :abnf_parsec, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:idna, "~> 6.0 or ~> 7.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:texture, "~> 1.0", [hex: :texture, repo: "hexpm", optional: false]}], "hexpm", "c4b62f335d49cea8bf8cdebba972fe1e2f402dbcf2dbf17d2803c9caab3ccd08"}, + "lazy_html": {:hex, :lazy_html, "0.1.12", "31a55ee622918fce988c94b06232227b42daa64e4eab14ac32081d0f3fd8db6f", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "8a0da594776caee58782c6f93b2abaa5bdb809daf8d43351a561f7de9dc2e2a8"}, "libgraph": {:hex, :libgraph, "0.16.0", "3936f3eca6ef826e08880230f806bfea13193e49bf153f93edcf0239d4fd1d07", [:mix], [], "hexpm", "41ca92240e8a4138c30a7e06466acc709b0cbb795c643e9e17174a178982d6bf"}, "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, @@ -75,7 +77,7 @@ "vibe_kit": {:hex, :vibe_kit, "0.1.5", "211550fd6f4bffc2525f6ff50bd5000e3abeea3fedb4c9bbfc1b57acfa653568", [:mix], [{:igniter, "~> 0.7", [hex: :igniter, repo: "hexpm", optional: true]}], "hexpm", "b0eae7a400944d789f4bb79f45721029a43bd6688621249c40a2c364aa1e309f"}, "vix": {:hex, :vix, "0.39.0", "ae5c24665a81a69ba36855e299f33ca8869ad6820a70e2a3afcca8615cc83978", [:make, :mix], [{:cc_precompiler, "~> 0.1.4 or ~> 0.2", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.7.3 or ~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:kino, "~> 0.7", [hex: :kino, repo: "hexpm", optional: true]}], "hexpm", "16899fc87686e6a819cee90c29a8e5658a630c64bc6f8d3ed4da7885c35e2f8b"}, "vize": {:hex, :vize, "0.14.2", "4baf40c18e00963ce802c03ebb664feaccd8558d7ec62aa43fb07ad8485d0208", [:mix], [{:rustler, "~> 0.36", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "8336744616bc27ce4edc7448cda6f0b8a481cbec9035d3c4d6bf3229be91386d"}, - "volt": {:git, "https://github.com/elixir-volt/volt.git", "93d485e57df19ee2041ebf47bab72e339cd8df92", [ref: "93d485e57df19ee2041ebf47bab72e339cd8df92"]}, + "volt": {:git, "https://github.com/elixir-volt/volt.git", "af5ef72b951cc3051461517c896a31b66e47be76", [ref: "af5ef72b951cc3051461517c896a31b66e47be76"]}, "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, "websock_adapter": {:hex, :websock_adapter, "0.6.0", "73db5ab8aaefd1a876a97ce3e6afc96562625de69ef17a4e04426e034849d0b8", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "50021a85bce8f203b086705d9e0c5415e2c7eb05d319111b0428fe71f9934617"}, "xm": {:hex, :xm, "0.2.0", "30a07986b9d96e54b3856c8740ebed026e48234b18e42b7162269111cf57f546", [:mix], [{:saxy, "~> 1.6", [hex: :saxy, repo: "hexpm", optional: false]}], "hexpm", "2fd8a669788adf7ae784b98045868ea184f13c380f158ab740f396a0abf4dd6e"}, diff --git a/priv/islands/entry.ts b/priv/islands/entry.ts index cde1b80..0c690e0 100644 --- a/priv/islands/entry.ts +++ b/priv/islands/entry.ts @@ -1,15 +1,23 @@ import Component from 'astral:island-component' import { mountIslandComponent } from 'astral:island-runtime' -for (const element of document.querySelectorAll('[data-astral-component]')) { - if (element.dataset.astralComponent !== $astral_component) continue - const client = element.dataset.astralClient - if (client !== 'load' && client !== 'idle' && client !== 'visible' && client !== 'media') continue - mountIslandComponent({ - id: element.id, - component: Component, - props: JSON.parse(element.dataset.astralProps ?? '{}'), - client, - media: element.dataset.astralMedia ?? null - }) +const scheduled = new WeakSet() + +export function mountIslands(root: ParentNode = document): void { + for (const element of root.querySelectorAll('[data-astral-component]')) { + if (element.dataset.astralComponent !== $astral_component || scheduled.has(element)) continue + const client = element.dataset.astralClient + if (client !== 'load' && client !== 'idle' && client !== 'visible' && client !== 'media') + continue + scheduled.add(element) + mountIslandComponent({ + id: element.id, + component: Component, + props: JSON.parse(element.dataset.astralProps ?? '{}'), + client, + media: element.dataset.astralMedia ?? null + }) + } } + +mountIslands() diff --git a/priv/islands/runtime.ts b/priv/islands/runtime.ts index c29c6fa..2278c9e 100644 --- a/priv/islands/runtime.ts +++ b/priv/islands/runtime.ts @@ -130,6 +130,6 @@ function activateNestedIslandScripts(island: HTMLElement): void { )) { const src = script.src script.remove() - void import(src) + void import(src).then(({ mountIslands }) => mountIslands(island)) } } diff --git a/test/astral/assets/stylesheets_test.exs b/test/astral/assets/stylesheets_test.exs new file mode 100644 index 0000000..754ec0e --- /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> + + <SVG> + + + """ + + output = Stylesheets.inject(html, ["/asset.css", "/asset.css"]) + assert output =~ "" + document = LazyHTML.from_document(output) + assert LazyHTML.attribute(document["html > head > link"], "href") == ["/asset.css"] + assert LazyHTML.text(document["head > title"]) == "" + assert Enum.empty?(document["head > script"]) + assert LazyHTML.attribute(document["svg"], "viewBox") == ["0 0 10 10"] + assert Enum.count(document["linearGradient"]) == 1 + assert LazyHTML.text(document["body > script"]) == ~s|if (a < b) { console.log("&"); }| + assert output =~ "

Slot content

" + end + + test "author links cannot suppress required styles and the serializer escapes URLs" do + html = + "" + + href = ~s(/asset.css?x="quoted"&y=1) + output = Stylesheets.inject(html, ["/existing.css", href, href]) + document = LazyHTML.from_document(output) + + assert LazyHTML.attribute(document["head > link:not([disabled]):not([media])"], "href") == [ + "/existing.css", + href + ] + + assert Enum.count(document["head > link[disabled][media=print]"]) == 1 + end + + test "leaves documents without dependencies and non-HTML output untouched" do + assert Stylesheets.inject("

unchanged", []) == "

unchanged" + + assert Stylesheets.inject(~s({"value":"

"}), ["/asset.css"], "application/json") == + ~s({"value":"

"}) + end +end diff --git a/test/astral/islands/integration_test.exs b/test/astral/islands/integration_test.exs index 4d52b59..fe0fa64 100644 --- a/test/astral/islands/integration_test.exs +++ b/test/astral/islands/integration_test.exs @@ -139,6 +139,74 @@ defmodule Astral.Islands.IntegrationTest do end end + test "delayed nested instances reuse an entry without suppressing layout CSS or mounting twice" do + Astral.Islands.SiteFixtures.write_delayed_shared_island_site!(tmp()) + assert {:ok, _} = Astral.build(root: tmp(), layout: "site.astral", asset_hash: false) + html = File.read!(Path.join(tmp(), "dist/index.html")) + assert html =~ "" + tree = Floki.parse_document!(html) + assert [_] = Floki.find(tree, "head > link[rel=stylesheet]") + assert [] = Floki.find(tree, "template link[rel=stylesheet]") + + port = unused_port() + + start_supervised!( + {Bandit, plug: {StaticSitePlug, root: Path.join(tmp(), "dist")}, port: port} + ) + + {:ok, playwright, playwright_owner?} = start_playwright!() + + try do + {:ok, browser} = PlaywrightEx.launch_browser(:chromium, timeout: 10_000) + + {:ok, context} = + Browser.new_context(browser.guid, viewport: %{width: 1024, height: 768}, timeout: 10_000) + + {:ok, %{main_frame: frame} = page} = BrowserContext.new_page(context.guid, timeout: 10_000) + + try do + assert {:ok, _} = + Frame.goto(frame.guid, url: url(port), wait_until: "load", timeout: 15_000) + + assert_eventually_text(frame, "#outside .styled", "Outside") + + assert {:ok, ["rgb(12, 34, 56)", 1, "CSS1Compat", false]} = + Frame.evaluate(frame.guid, + expression: + "[getComputedStyle(document.querySelector('#outside .styled')).color, document.styleSheets.length, document.compatMode, !!document.querySelector('#delayed-shell')]", + timeout: 5_000 + ) + + assert {:ok, _} = + PlaywrightEx.Connection.send( + PlaywrightEx.Supervisor.Connection, + %{ + guid: page.guid, + method: :set_viewport_size, + params: %{viewport_size: %{width: 1600, height: 900}} + }, + 5_000 + ) + |> PlaywrightEx.ChannelResponse.unwrap(& &1) + + assert_eventually_text(frame, "#nested-one .styled", "Nested one") + assert_eventually_text(frame, "#nested-two .styled", "Nested two") + + assert {:ok, [3, "rgb(12, 34, 56)"]} = + Frame.evaluate(frame.guid, + expression: + "[globalThis.astralMounts, getComputedStyle(document.querySelector('#nested-one .styled')).color]", + timeout: 5_000 + ) + after + BrowserContext.close(context.guid, timeout: 10_000) + Browser.close(browser.guid, timeout: 10_000) + end + after + if playwright_owner?, do: Process.exit(playwright, :normal) + end + end + defp assert_eventually_text(frame, selector, expected) do assert {:ok, _element} = Frame.wait_for_selector(frame.guid, selector: selector, timeout: 15_000) diff --git a/test/astral/islands/registry_test.exs b/test/astral/islands/registry_test.exs index 8e10c15..3fe1093 100644 --- a/test/astral/islands/registry_test.exs +++ b/test/astral/islands/registry_test.exs @@ -67,6 +67,39 @@ defmodule Astral.Islands.RegistryTest do assert relocated_source =~ "Widget.vue" end + test "collects styles without consuming them and resets collection for each document" do + island = Astral.Islands.Registry.register(component: "islands/Widget.vue", adapter: :vue) + key = Path.rootname(Path.basename(island.entry_source)) <> ".js" + + manifest = %{ + key => %Volt.Builder.ManifestEntry{ + file: "widget.js", + css: ["widget.css"], + imports: ["shared.js"] + }, + "shared.js" => %Volt.Builder.ManifestEntry{file: "shared.js", css: ["shared.css"]} + } + + site = %{Astral.Islands.Registry.site() | asset_manifest: manifest} + Astral.Islands.Registry.start(site) + + for label <- ["First", "Second"] do + Astral.Islands.Registry.register( + component: "islands/Widget.vue", + adapter: :vue, + props: %{label: label} + ) + end + + expected = + Enum.map(["shared.css", "widget.css"], &Volt.URL.join(site.config.asset_url_prefix, &1)) + + assert Astral.Islands.Registry.stylesheets() == expected + assert Astral.Islands.Registry.stylesheets() == expected + Astral.Islands.Registry.start_document() + assert Astral.Islands.Registry.stylesheets() == [] + end + test "registering virtual entries creates no generated directory", %{tmp_dir: tmp} do island = Astral.Islands.Registry.register(component: "islands/Widget.vue", adapter: :vue) assert String.starts_with?(island.entry_path, "astral:islands/entry/") diff --git a/test/support/astral/islands/site_fixtures.ex b/test/support/astral/islands/site_fixtures.ex index ce512a8..991d82d 100644 --- a/test/support/astral/islands/site_fixtures.ex +++ b/test/support/astral/islands/site_fixtures.ex @@ -137,6 +137,38 @@ defmodule Astral.Islands.SiteFixtures do ''') end + def write_delayed_shared_island_site!(root) do + write(root, "assets/islands/Shell.vue", ~S''' + + ''') + + write(root, "assets/islands/Styled.vue", ~S''' + + + + ''') + + write(root, "pages/index.astral", ~S''' + <.vue component="islands/Shell.vue" client={:media} media="(min-width: 1500px)" id="delayed-parent"> + <.vue component="islands/Styled.vue" props={%{label: "Nested one"}} id="nested-one" /> + <.vue component="islands/Styled.vue" props={%{label: "Nested two"}} id="nested-two" /> + + ''') + + write(root, "layouts/site.astral", ~S''' + + {@content} + <.vue component="islands/Styled.vue" props={%{label: "Outside"}} id="outside" /> + + ''') + end + defp write(root, path, content) do path = Path.join(root, path) File.mkdir_p!(Path.dirname(path)) From da658d759194f4d48bb2ace8d115fed039422802 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 14 Sep 2026 11:12:46 +0300 Subject: [PATCH 07/13] Use Volt with QuickBEAM 0.11.1 --- mix.exs | 2 +- mix.lock | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/mix.exs b/mix.exs index b000c18..2ef8d20 100644 --- a/mix.exs +++ b/mix.exs @@ -40,7 +40,7 @@ defmodule Astral.MixProject do defp deps do [ - {:volt, github: "elixir-volt/volt", ref: "af5ef72b951cc3051461517c896a31b66e47be76"}, + {:volt, github: "elixir-volt/volt", ref: "dab017eb537f800fd73b8bc801ce25d8e491348a"}, {:mdex, "~> 0.13"}, {:yaml_elixir, "~> 2.12"}, {:json_spec, "~> 1.1"}, diff --git a/mix.lock b/mix.lock index afe3ec7..dd1db71 100644 --- a/mix.lock +++ b/mix.lock @@ -60,7 +60,7 @@ "playwright_ex": {:hex, :playwright_ex, "0.7.1", "4a8a3f317734a5d20c4f25b5b425d565d05730166013f86f0c1a799714e61e2d", [:mix], [{:nimble_options, "~> 1.1", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:websockex, "~> 0.4", [hex: :websockex, repo: "hexpm", optional: true]}], "hexpm", "558176309cf322b8ba5c4f85ec6c655c1667cd68531db83d1d3065bd572755c2"}, "plug": {:hex, :plug, "1.20.3", "56c480c633ec2ce10140e236e15233bf576e1d323887d7c96711bd02ab5160db", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "be266aee1b8536ef6409d58cf39a3121319f0ec47cfa1b24024485aa0e76ad76"}, "plug_crypto": {:hex, :plug_crypto, "2.2.0", "144014737daaf485407f5ed77daeaad74d651b216a28c87543f8cc7043f8efc8", [:mix], [], "hexpm", "83a95744ab1c75876542b6fab135fcc176280e0f301a111c1f757fddcec95d2c"}, - "quickbeam": {:hex, :quickbeam, "0.11.0", "2e34ec4128452d6a8e0b6e3d3ab673c748c5f78ff67e3d86f43aac96f63179d7", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:mint_web_socket, "~> 1.0", [hex: :mint_web_socket, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:npm, "~> 0.7.6", [hex: :npm, repo: "hexpm", optional: true]}, {:oxc, "~> 0.17.8", [hex: :oxc, repo: "hexpm", optional: false]}, {:varint, "~> 1.6", [hex: :varint, repo: "hexpm", optional: false]}, {:zigler, "~> 0.16.0", [hex: :zigler, repo: "hexpm", optional: true]}, {:zigler_precompiled, "~> 0.1.5", [hex: :zigler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "5914d43b5129005f0217f6252e97b48306dc9ed02dec0bfebdb94f8c9a98148f"}, + "quickbeam": {:hex, :quickbeam, "0.11.1", "55e7700b2a16c8df246402f8400908222e906f3b463af4ac8706f748d83b66db", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:mint, "~> 1.10", [hex: :mint, repo: "hexpm", optional: false]}, {:mint_web_socket, "~> 1.0", [hex: :mint_web_socket, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:npm, "~> 0.7.6", [hex: :npm, repo: "hexpm", optional: true]}, {:oxc, "~> 0.17.8", [hex: :oxc, repo: "hexpm", optional: false]}, {:varint, "~> 1.6", [hex: :varint, repo: "hexpm", optional: false]}, {:zigler, "~> 0.16.0", [hex: :zigler, repo: "hexpm", optional: true]}, {:zigler_precompiled, "~> 0.1.6", [hex: :zigler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "6c0240b9d016bbc174319ef004e81f2893ae2e77939d6b9b87c5542b344d0b87"}, "reach": {:hex, :reach, "2.7.5", "2148096233ebf84f1b9c79d23134c3262f546303af07ee21f7e9d7ed281ff616", [:mix], [{:boxart, "~> 0.3.3", [hex: :boxart, repo: "hexpm", optional: true]}, {:ex_ast, "~> 0.12.0", [hex: :ex_ast, repo: "hexpm", optional: false]}, {:ex_dna, "~> 1.5", [hex: :ex_dna, repo: "hexpm", optional: true]}, {:libgraph, "~> 0.16.0", [hex: :libgraph, repo: "hexpm", optional: false]}, {:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: true]}, {:makeup_js, "~> 0.1", [hex: :makeup_js, repo: "hexpm", optional: true]}, {:quickbeam, "~> 0.10", [hex: :quickbeam, repo: "hexpm", optional: true]}], "hexpm", "b31fd7cf23a649a6f76f11168b2ef296845441d6498474634ec673dee2d60567"}, "req": {:hex, :req, "0.7.4", "23e9ffec17de032a46a4b15ed65c09793893bf4a7c680f4bbf6227fce6bdf74d", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:finch, "~> 0.21", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "4b192d63253e8dcc6221ef992ea9ebef7d3555166e8423aa5b553e86bc3c69a2"}, "rewrite": {:hex, :rewrite, "1.3.0", "67448ba7975690b35ba7e7f35717efcce317dbd5963cb0577aa7325c1923121a", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}, {:text_diff, "~> 0.1", [hex: :text_diff, repo: "hexpm", optional: false]}], "hexpm", "d111ac7ff3a58a802ef4f193bbd1831e00a9c57b33276e5068e8390a212714a5"}, @@ -77,12 +77,12 @@ "vibe_kit": {:hex, :vibe_kit, "0.1.5", "211550fd6f4bffc2525f6ff50bd5000e3abeea3fedb4c9bbfc1b57acfa653568", [:mix], [{:igniter, "~> 0.7", [hex: :igniter, repo: "hexpm", optional: true]}], "hexpm", "b0eae7a400944d789f4bb79f45721029a43bd6688621249c40a2c364aa1e309f"}, "vix": {:hex, :vix, "0.39.0", "ae5c24665a81a69ba36855e299f33ca8869ad6820a70e2a3afcca8615cc83978", [:make, :mix], [{:cc_precompiler, "~> 0.1.4 or ~> 0.2", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.7.3 or ~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:kino, "~> 0.7", [hex: :kino, repo: "hexpm", optional: true]}], "hexpm", "16899fc87686e6a819cee90c29a8e5658a630c64bc6f8d3ed4da7885c35e2f8b"}, "vize": {:hex, :vize, "0.14.2", "4baf40c18e00963ce802c03ebb664feaccd8558d7ec62aa43fb07ad8485d0208", [:mix], [{:rustler, "~> 0.36", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "8336744616bc27ce4edc7448cda6f0b8a481cbec9035d3c4d6bf3229be91386d"}, - "volt": {:git, "https://github.com/elixir-volt/volt.git", "af5ef72b951cc3051461517c896a31b66e47be76", [ref: "af5ef72b951cc3051461517c896a31b66e47be76"]}, + "volt": {:git, "https://github.com/elixir-volt/volt.git", "dab017eb537f800fd73b8bc801ce25d8e491348a", [ref: "dab017eb537f800fd73b8bc801ce25d8e491348a"]}, "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, "websock_adapter": {:hex, :websock_adapter, "0.6.0", "73db5ab8aaefd1a876a97ce3e6afc96562625de69ef17a4e04426e034849d0b8", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "50021a85bce8f203b086705d9e0c5415e2c7eb05d319111b0428fe71f9934617"}, "xm": {:hex, :xm, "0.2.0", "30a07986b9d96e54b3856c8740ebed026e48234b18e42b7162269111cf57f546", [:mix], [{:saxy, "~> 1.6", [hex: :saxy, repo: "hexpm", optional: false]}], "hexpm", "2fd8a669788adf7ae784b98045868ea184f13c380f158ab740f396a0abf4dd6e"}, "yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"}, "yaml_elixir": {:hex, :yaml_elixir, "2.12.2", "9dd1330fb4cd9a36a7b0f502e5b12486eff632792ee4a5f0eba52a4d4ec32c9c", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "e7c1b10122f973e6558462d51c39026ba0e14afbc6745318e990ea82cfe9e159"}, - "zigler_precompiled": {:hex, :zigler_precompiled, "0.1.5", "7da17a36e168a69c06e32e8f0dcf739e8c09450dcfee65aad38a025c20fc2a9a", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:zigler, "~> 0.13", [hex: :zigler, repo: "hexpm", optional: true]}], "hexpm", "4ee01cfdcd703215cf331f1ecc4324874ee2e3bfe15e7b2f3d65231c551a0571"}, + "zigler_precompiled": {:hex, :zigler_precompiled, "0.1.6", "1c5c889ff30164eb340e599ae987b6c51c3ba0654117ec7a68a6b83d4df39310", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:zigler, "~> 0.13", [hex: :zigler, repo: "hexpm", optional: true]}], "hexpm", "5754d342533b2fb8e767c0ef216ee13c928e17f317cac7d6a01a0972ba68ce4a"}, "zoi": {:hex, :zoi, "0.18.4", "849c1ccdf69a4a7b7b6c2e41766312bcc4edf1e0af5bfb9f2f3d98234191b8ef", [:mix], [{:decimal, "~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "587fb221824ae7343fca3af90b8a4c53ac5cf9019891cf3aba215b43be2ba05d"}, } From 344d5f89a5b041235bc4aaf76daf9fdd0ae21ed8 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 14 Sep 2026 11:41:14 +0300 Subject: [PATCH 08/13] Name document stylesheet finalization explicitly --- AGENTS.md | 2 ++ lib/astral/assets/stylesheets.ex | 12 ++++++------ lib/astral/builder.ex | 4 ++-- test/astral/assets/stylesheets_test.exs | 8 ++++---- 4 files changed, 14 insertions(+), 12 deletions(-) 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/lib/astral/assets/stylesheets.ex b/lib/astral/assets/stylesheets.ex index 9e9bdf8..b3f6cf0 100644 --- a/lib/astral/assets/stylesheets.ex +++ b/lib/astral/assets/stylesheets.ex @@ -2,12 +2,12 @@ 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 inject(String.t(), [String.t()], String.t()) :: String.t() - def inject(html, styles, content_type \\ "text/html") - def inject(html, [], _content_type), do: html + @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 inject(html, styles, content_type) do - if html?(content_type), do: inject_html(html, styles), else: 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 @@ -15,7 +15,7 @@ defmodule Astral.Assets.Stylesheets do "text/html" end - defp inject_html(html, styles) do + defp finalize_html(html, styles) do document = LazyHTML.from_document(html) links = diff --git a/lib/astral/builder.ex b/lib/astral/builder.ex index 2b54227..7e42ae9 100644 --- a/lib/astral/builder.ex +++ b/lib/astral/builder.ex @@ -158,7 +158,7 @@ defmodule Astral.Builder do with :ok <- validate_output_path(page.output_path, site.config), {:ok, html} <- Astral.Renderer.render_page(site, page) do - html = Astral.Assets.Stylesheets.inject(html, Astral.Islands.Registry.stylesheets()) + 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} @@ -182,7 +182,7 @@ defmodule Astral.Builder do with :ok <- validate_output_path(route.output_path, site.config), {:ok, body, content_type} <- render_route_body(site.config.plugins, route, site) do body = - Astral.Assets.Stylesheets.inject( + Astral.Assets.Stylesheets.finalize( IO.iodata_to_binary(body), Astral.Islands.Registry.stylesheets(), content_type diff --git a/test/astral/assets/stylesheets_test.exs b/test/astral/assets/stylesheets_test.exs index 754ec0e..7797a82 100644 --- a/test/astral/assets/stylesheets_test.exs +++ b/test/astral/assets/stylesheets_test.exs @@ -12,7 +12,7 @@ defmodule Astral.Assets.StylesheetsTest do """ - output = Stylesheets.inject(html, ["/asset.css", "/asset.css"]) + output = Stylesheets.finalize(html, ["/asset.css", "/asset.css"]) assert output =~ "" document = LazyHTML.from_document(output) assert LazyHTML.attribute(document["html > head > link"], "href") == ["/asset.css"] @@ -29,7 +29,7 @@ defmodule Astral.Assets.StylesheetsTest do "" href = ~s(/asset.css?x="quoted"&y=1) - output = Stylesheets.inject(html, ["/existing.css", href, href]) + output = Stylesheets.finalize(html, ["/existing.css", href, href]) document = LazyHTML.from_document(output) assert LazyHTML.attribute(document["head > link:not([disabled]):not([media])"], "href") == [ @@ -41,9 +41,9 @@ defmodule Astral.Assets.StylesheetsTest do end test "leaves documents without dependencies and non-HTML output untouched" do - assert Stylesheets.inject("

unchanged", []) == "

unchanged" + assert Stylesheets.finalize("

unchanged", []) == "

unchanged" - assert Stylesheets.inject(~s({"value":"

"}), ["/asset.css"], "application/json") == + assert Stylesheets.finalize(~s({"value":"

"}), ["/asset.css"], "application/json") == ~s({"value":"

"}) end end From 90711da3dfc913098a51e025cda5a3aa765f5aca Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 14 Sep 2026 11:41:14 +0300 Subject: [PATCH 09/13] Use Volt naming and test-structure cleanup --- mix.exs | 2 +- mix.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/mix.exs b/mix.exs index 2ef8d20..ca3f1a5 100644 --- a/mix.exs +++ b/mix.exs @@ -40,7 +40,7 @@ defmodule Astral.MixProject do defp deps do [ - {:volt, github: "elixir-volt/volt", ref: "dab017eb537f800fd73b8bc801ce25d8e491348a"}, + {:volt, github: "elixir-volt/volt", ref: "cdc4ecf618b99f75fa155c1a111ad8793156a464"}, {:mdex, "~> 0.13"}, {:yaml_elixir, "~> 2.12"}, {:json_spec, "~> 1.1"}, diff --git a/mix.lock b/mix.lock index dd1db71..9de53c5 100644 --- a/mix.lock +++ b/mix.lock @@ -77,7 +77,7 @@ "vibe_kit": {:hex, :vibe_kit, "0.1.5", "211550fd6f4bffc2525f6ff50bd5000e3abeea3fedb4c9bbfc1b57acfa653568", [:mix], [{:igniter, "~> 0.7", [hex: :igniter, repo: "hexpm", optional: true]}], "hexpm", "b0eae7a400944d789f4bb79f45721029a43bd6688621249c40a2c364aa1e309f"}, "vix": {:hex, :vix, "0.39.0", "ae5c24665a81a69ba36855e299f33ca8869ad6820a70e2a3afcca8615cc83978", [:make, :mix], [{:cc_precompiler, "~> 0.1.4 or ~> 0.2", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.7.3 or ~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:kino, "~> 0.7", [hex: :kino, repo: "hexpm", optional: true]}], "hexpm", "16899fc87686e6a819cee90c29a8e5658a630c64bc6f8d3ed4da7885c35e2f8b"}, "vize": {:hex, :vize, "0.14.2", "4baf40c18e00963ce802c03ebb664feaccd8558d7ec62aa43fb07ad8485d0208", [:mix], [{:rustler, "~> 0.36", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "8336744616bc27ce4edc7448cda6f0b8a481cbec9035d3c4d6bf3229be91386d"}, - "volt": {:git, "https://github.com/elixir-volt/volt.git", "dab017eb537f800fd73b8bc801ce25d8e491348a", [ref: "dab017eb537f800fd73b8bc801ce25d8e491348a"]}, + "volt": {:git, "https://github.com/elixir-volt/volt.git", "cdc4ecf618b99f75fa155c1a111ad8793156a464", [ref: "cdc4ecf618b99f75fa155c1a111ad8793156a464"]}, "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, "websock_adapter": {:hex, :websock_adapter, "0.6.0", "73db5ab8aaefd1a876a97ce3e6afc96562625de69ef17a4e04426e034849d0b8", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "50021a85bce8f203b086705d9e0c5415e2c7eb05d319111b0428fe71f9934617"}, "xm": {:hex, :xm, "0.2.0", "30a07986b9d96e54b3856c8740ebed026e48234b18e42b7162269111cf57f546", [:mix], [{:saxy, "~> 1.6", [hex: :saxy, repo: "hexpm", optional: false]}], "hexpm", "2fd8a669788adf7ae784b98045868ea184f13c380f158ab740f396a0abf4dd6e"}, From 5ef083c02d928c4b8f1cd75e1e1e016d4fdd60cf Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 14 Sep 2026 21:06:51 +0300 Subject: [PATCH 10/13] Require patched server and installer dependencies --- CHANGELOG.md | 5 +++++ mix.exs | 6 +++--- mix.lock | 2 +- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e56119f..78daa7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,11 @@ - 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). + ## 0.2.6 - 2026-09-04 ### Fixed diff --git a/mix.exs b/mix.exs index ca3f1a5..09f393b 100644 --- a/mix.exs +++ b/mix.exs @@ -40,7 +40,7 @@ defmodule Astral.MixProject do defp deps do [ - {:volt, github: "elixir-volt/volt", ref: "cdc4ecf618b99f75fa155c1a111ad8793156a464"}, + {:volt, github: "elixir-volt/volt", ref: "f9bb77d4fd409939045d2666f73f65a744f77b22"}, {:mdex, "~> 0.13"}, {:yaml_elixir, "~> 2.12"}, {:json_spec, "~> 1.1"}, @@ -52,12 +52,12 @@ defmodule Astral.MixProject do {:image, "~> 0.68"}, {:req, "~> 0.6"}, {:phoenix_live_view, "~> 1.2"}, - {:bandit, "~> 1.12"}, + {:bandit, ">= 1.12.5 and < 2.0.0"}, {:floki, "~> 0.38"}, {:lazy_html, "~> 0.1.12"}, {:plug, "~> 1.20"}, {:phoenix_iconify, "~> 0.3.5"}, - {:igniter, "~> 0.8", optional: true}, + {:igniter, ">= 0.8.4 and < 1.0.0", optional: true}, {:ex_slop, "~> 0.4", only: [:dev, :test], runtime: false}, {:reach, "~> 2.0", only: [:dev, :test], runtime: false}, {:ex_dna, "~> 1.0", only: [:dev, :test], runtime: false}, diff --git a/mix.lock b/mix.lock index 9de53c5..16d2d47 100644 --- a/mix.lock +++ b/mix.lock @@ -77,7 +77,7 @@ "vibe_kit": {:hex, :vibe_kit, "0.1.5", "211550fd6f4bffc2525f6ff50bd5000e3abeea3fedb4c9bbfc1b57acfa653568", [:mix], [{:igniter, "~> 0.7", [hex: :igniter, repo: "hexpm", optional: true]}], "hexpm", "b0eae7a400944d789f4bb79f45721029a43bd6688621249c40a2c364aa1e309f"}, "vix": {:hex, :vix, "0.39.0", "ae5c24665a81a69ba36855e299f33ca8869ad6820a70e2a3afcca8615cc83978", [:make, :mix], [{:cc_precompiler, "~> 0.1.4 or ~> 0.2", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.7.3 or ~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:kino, "~> 0.7", [hex: :kino, repo: "hexpm", optional: true]}], "hexpm", "16899fc87686e6a819cee90c29a8e5658a630c64bc6f8d3ed4da7885c35e2f8b"}, "vize": {:hex, :vize, "0.14.2", "4baf40c18e00963ce802c03ebb664feaccd8558d7ec62aa43fb07ad8485d0208", [:mix], [{:rustler, "~> 0.36", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "8336744616bc27ce4edc7448cda6f0b8a481cbec9035d3c4d6bf3229be91386d"}, - "volt": {:git, "https://github.com/elixir-volt/volt.git", "cdc4ecf618b99f75fa155c1a111ad8793156a464", [ref: "cdc4ecf618b99f75fa155c1a111ad8793156a464"]}, + "volt": {:git, "https://github.com/elixir-volt/volt.git", "f9bb77d4fd409939045d2666f73f65a744f77b22", [ref: "f9bb77d4fd409939045d2666f73f65a744f77b22"]}, "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, "websock_adapter": {:hex, :websock_adapter, "0.6.0", "73db5ab8aaefd1a876a97ce3e6afc96562625de69ef17a4e04426e034849d0b8", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "50021a85bce8f203b086705d9e0c5415e2c7eb05d319111b0428fe71f9934617"}, "xm": {:hex, :xm, "0.2.0", "30a07986b9d96e54b3856c8740ebed026e48234b18e42b7162269111cf57f546", [:mix], [{:saxy, "~> 1.6", [hex: :saxy, repo: "hexpm", optional: false]}], "hexpm", "2fd8a669788adf7ae784b98045868ea184f13c380f158ab740f396a0abf4dd6e"}, From c78cb3e9c3b5fb241fe2d9007f290ea6882d4fd0 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 14 Sep 2026 22:35:42 +0300 Subject: [PATCH 11/13] Supervise remote image test resources through ExUnit --- test/astral/dev_server_test.exs | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/test/astral/dev_server_test.exs b/test/astral/dev_server_test.exs index cbe69ba..9d48c32 100644 --- a/test/astral/dev_server_test.exs +++ b/test/astral/dev_server_test.exs @@ -210,15 +210,13 @@ defmodule Astral.DevServerTest do test "defers remote dev image fetches until image requests" do File.rm!(Path.join(tmp(), "pages/index.md")) port = unused_port() - {:ok, _agent} = Agent.start_link(fn -> 0 end, name: Astral.DevServerTest.RemoteHits) - {:ok, server} = Bandit.start_link(plug: RemoteImageServer, port: port) - on_exit(fn -> - Process.exit(server, :normal) + start_supervised!(%{ + id: Astral.DevServerTest.RemoteHits, + start: {Agent, :start_link, [fn -> 0 end, [name: Astral.DevServerTest.RemoteHits]]} + }) - if Process.whereis(Astral.DevServerTest.RemoteHits), - do: Agent.stop(Astral.DevServerTest.RemoteHits) - end) + start_supervised!({Bandit, plug: RemoteImageServer, port: port}) write("pages/index.astral", ~s''' <.image src="http://127.0.0.1:#{port}/hero.svg" alt="Hero" width={50} height={25} /> From a9f38ca3edb5e33485a09a41d8ef76c3ef1e2880 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 14 Sep 2026 22:35:42 +0300 Subject: [PATCH 12/13] Use Volt candidate with formatter configuration fix --- mix.exs | 2 +- mix.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/mix.exs b/mix.exs index 09f393b..25bc36d 100644 --- a/mix.exs +++ b/mix.exs @@ -40,7 +40,7 @@ defmodule Astral.MixProject do defp deps do [ - {:volt, github: "elixir-volt/volt", ref: "f9bb77d4fd409939045d2666f73f65a744f77b22"}, + {:volt, github: "elixir-volt/volt", ref: "5cf6fd26827dbeb013bd77ffb0cf9e1f495a8dfd"}, {:mdex, "~> 0.13"}, {:yaml_elixir, "~> 2.12"}, {:json_spec, "~> 1.1"}, diff --git a/mix.lock b/mix.lock index 16d2d47..eeffd35 100644 --- a/mix.lock +++ b/mix.lock @@ -77,7 +77,7 @@ "vibe_kit": {:hex, :vibe_kit, "0.1.5", "211550fd6f4bffc2525f6ff50bd5000e3abeea3fedb4c9bbfc1b57acfa653568", [:mix], [{:igniter, "~> 0.7", [hex: :igniter, repo: "hexpm", optional: true]}], "hexpm", "b0eae7a400944d789f4bb79f45721029a43bd6688621249c40a2c364aa1e309f"}, "vix": {:hex, :vix, "0.39.0", "ae5c24665a81a69ba36855e299f33ca8869ad6820a70e2a3afcca8615cc83978", [:make, :mix], [{:cc_precompiler, "~> 0.1.4 or ~> 0.2", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.7.3 or ~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:kino, "~> 0.7", [hex: :kino, repo: "hexpm", optional: true]}], "hexpm", "16899fc87686e6a819cee90c29a8e5658a630c64bc6f8d3ed4da7885c35e2f8b"}, "vize": {:hex, :vize, "0.14.2", "4baf40c18e00963ce802c03ebb664feaccd8558d7ec62aa43fb07ad8485d0208", [:mix], [{:rustler, "~> 0.36", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "8336744616bc27ce4edc7448cda6f0b8a481cbec9035d3c4d6bf3229be91386d"}, - "volt": {:git, "https://github.com/elixir-volt/volt.git", "f9bb77d4fd409939045d2666f73f65a744f77b22", [ref: "f9bb77d4fd409939045d2666f73f65a744f77b22"]}, + "volt": {:git, "https://github.com/elixir-volt/volt.git", "5cf6fd26827dbeb013bd77ffb0cf9e1f495a8dfd", [ref: "5cf6fd26827dbeb013bd77ffb0cf9e1f495a8dfd"]}, "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, "websock_adapter": {:hex, :websock_adapter, "0.6.0", "73db5ab8aaefd1a876a97ce3e6afc96562625de69ef17a4e04426e034849d0b8", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "50021a85bce8f203b086705d9e0c5415e2c7eb05d319111b0428fe71f9934617"}, "xm": {:hex, :xm, "0.2.0", "30a07986b9d96e54b3856c8740ebed026e48234b18e42b7162269111cf57f546", [:mix], [{:saxy, "~> 1.6", [hex: :saxy, repo: "hexpm", optional: false]}], "hexpm", "2fd8a669788adf7ae784b98045868ea184f13c380f158ab740f396a0abf4dd6e"}, From da79b35ea0ba8bebacc670396fa92a2d951aed07 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Tue, 15 Sep 2026 09:47:24 +0300 Subject: [PATCH 13/13] Prepare Astral 0.3.0 with released Volt 0.18 --- CHANGELOG.md | 6 ++++++ guides/introduction/getting-started.md | 2 +- mix.exs | 4 ++-- mix.lock | 2 +- test/astral_test.exs | 2 +- 5 files changed, 11 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 78daa7e..91df659 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## 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. @@ -24,6 +26,10 @@ - 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/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/mix.exs b/mix.exs index 25bc36d..d599fc7 100644 --- a/mix.exs +++ b/mix.exs @@ -1,7 +1,7 @@ defmodule Astral.MixProject do use Mix.Project - @version "0.2.6" + @version "0.3.0" @source_url "https://github.com/elixir-volt/astral" def project do @@ -40,7 +40,7 @@ defmodule Astral.MixProject do defp deps do [ - {:volt, github: "elixir-volt/volt", ref: "5cf6fd26827dbeb013bd77ffb0cf9e1f495a8dfd"}, + {:volt, "~> 0.18.0"}, {:mdex, "~> 0.13"}, {:yaml_elixir, "~> 2.12"}, {:json_spec, "~> 1.1"}, diff --git a/mix.lock b/mix.lock index eeffd35..d8edc1b 100644 --- a/mix.lock +++ b/mix.lock @@ -77,7 +77,7 @@ "vibe_kit": {:hex, :vibe_kit, "0.1.5", "211550fd6f4bffc2525f6ff50bd5000e3abeea3fedb4c9bbfc1b57acfa653568", [:mix], [{:igniter, "~> 0.7", [hex: :igniter, repo: "hexpm", optional: true]}], "hexpm", "b0eae7a400944d789f4bb79f45721029a43bd6688621249c40a2c364aa1e309f"}, "vix": {:hex, :vix, "0.39.0", "ae5c24665a81a69ba36855e299f33ca8869ad6820a70e2a3afcca8615cc83978", [:make, :mix], [{:cc_precompiler, "~> 0.1.4 or ~> 0.2", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.7.3 or ~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:kino, "~> 0.7", [hex: :kino, repo: "hexpm", optional: true]}], "hexpm", "16899fc87686e6a819cee90c29a8e5658a630c64bc6f8d3ed4da7885c35e2f8b"}, "vize": {:hex, :vize, "0.14.2", "4baf40c18e00963ce802c03ebb664feaccd8558d7ec62aa43fb07ad8485d0208", [:mix], [{:rustler, "~> 0.36", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "8336744616bc27ce4edc7448cda6f0b8a481cbec9035d3c4d6bf3229be91386d"}, - "volt": {:git, "https://github.com/elixir-volt/volt.git", "5cf6fd26827dbeb013bd77ffb0cf9e1f495a8dfd", [ref: "5cf6fd26827dbeb013bd77ffb0cf9e1f495a8dfd"]}, + "volt": {:hex, :volt, "0.18.0", "205ed8fae9fd2713c6a46eabf9e061233bc8539dbf618f8bff839f5d9fd72025", [:mix], [{:dotenvy, "~> 1.1", [hex: :dotenvy, repo: "hexpm", optional: false]}, {:file_system, "~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:floki, "~> 0.38", [hex: :floki, repo: "hexpm", optional: false]}, {:glob_ex, "~> 0.1.12", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:igniter, ">= 0.8.4 and < 1.0.0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:json_codec, "~> 0.2.3", [hex: :json_codec, repo: "hexpm", optional: false]}, {:mime, "~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:npm, "~> 0.7.6", [hex: :npm, repo: "hexpm", optional: false]}, {:oxc, "~> 0.17.8", [hex: :oxc, repo: "hexpm", optional: false]}, {:oxide_ex, "~> 0.2.2", [hex: :oxide_ex, repo: "hexpm", optional: false]}, {:plug, "~> 1.16", [hex: :plug, repo: "hexpm", optional: false]}, {:quickbeam, "~> 0.11.1", [hex: :quickbeam, repo: "hexpm", optional: false]}, {:vize, "~> 0.14.2", [hex: :vize, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "e50679dd9614fc48d0ba12f84164ec651056a02900943355c93028ec0702dd90"}, "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, "websock_adapter": {:hex, :websock_adapter, "0.6.0", "73db5ab8aaefd1a876a97ce3e6afc96562625de69ef17a4e04426e034849d0b8", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "50021a85bce8f203b086705d9e0c5415e2c7eb05d319111b0428fe71f9934617"}, "xm": {:hex, :xm, "0.2.0", "30a07986b9d96e54b3856c8740ebed026e48234b18e42b7162269111cf57f546", [:mix], [{:saxy, "~> 1.6", [hex: :saxy, repo: "hexpm", optional: false]}], "hexpm", "2fd8a669788adf7ae784b98045868ea184f13c380f158ab740f396a0abf4dd6e"}, diff --git a/test/astral_test.exs b/test/astral_test.exs index d3219ec..5fb0916 100644 --- a/test/astral_test.exs +++ b/test/astral_test.exs @@ -4,6 +4,6 @@ defmodule AstralTest do doctest Astral test "returns the package version" do - assert Astral.version() == "0.2.6" + assert Astral.version() == "0.3.0" end end