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

unchanged", []) == "

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

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

"}) + 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..e199805 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 with built asset URLs" 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..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 """) @@ -31,8 +36,9 @@ 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" + assert config.islands.components == [vue: "Gallery.vue", react: "Viewer.jsx"] end test "reads top-level astral.config.exs declarations", %{tmp_dir: tmp_dir} do @@ -84,7 +90,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 +105,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..9d48c32 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 @@ -208,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} /> @@ -298,7 +298,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/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 4e74c9f..fe0fa64 100644 --- a/test/astral/islands/integration_test.exs +++ b/test/astral/islands/integration_test.exs @@ -42,7 +42,19 @@ 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]") + 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() @@ -64,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") @@ -120,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 2ee9db3..3fe1093 100644 --- a/test/astral/islands/registry_test.exs +++ b/test/astral/islands/registry_test.exs @@ -22,12 +22,88 @@ defmodule Astral.Islands.RegistryTest do :ok end - test "does not rewrite unchanged generated entries" do + 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, relocated_source} = + Astral.Islands.RuntimePlugin.load(relocated.entry_path, + assets: Path.join(other, "assets") + ) + + 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) - File.touch!(island.entry_path, 1_000_000_000) + 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) - assert :ok = Astral.Islands.Writer.write!(island) - assert File.stat!(island.entry_path, time: :posix).mtime == 1_000_000_000 + 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/") + 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 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 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))