defmodule AshHqWeb.Pages.Docs do @moduledoc "The page for showing documentation" use Surface.LiveComponent import AshHqWeb.Helpers alias AshHqWeb.Components.{CalloutText, DocSidebar, RightNav, Tag} alias AshHqWeb.Components.Docs.{DocPath, Functions, SourceLink} alias AshHqWeb.DocRoutes alias Phoenix.LiveView.JS alias Surface.Components.LivePatch require Logger require Ash.Query prop change_versions, :event, required: true prop selected_versions, :map, required: true prop libraries, :list, default: [] prop uri, :string prop remove_version, :event prop add_version, :event prop change_version, :event prop params, :map, required: true data library, :any data extension, :any data docs, :any data library_version, :any data guide, :any data doc_path, :list, default: [] data dsls, :list, default: [] data dsl, :any data options, :list, default: [] data module, :any data mix_task, :any data positional_options, :list data description, :string data title, :string @spec render(any) :: Phoenix.LiveView.Rendered.t() def render(assigns) do ~F"""
{#if @module} {#else}
{/if}
""" end def docs(assigns) do ~F"""
{raw(@docs)}
""" end def update(assigns, socket) do if socket.assigns[:loaded_once?] && assigns[:selected_versions] == socket.assigns[:selected_versions] do {:ok, socket |> assign(Map.delete(assigns, :libraries)) |> load_docs()} else {:ok, socket |> assign(assigns) |> assign_libraries() |> load_docs() |> assign(:loaded_once?, true)} end end defp modules_in_scope(nil, _, _, _), do: [] defp modules_in_scope(_, nil, _, _), do: [] defp modules_in_scope(dsl, %{dsls: dsls}, libraries, selected_versions) do dsl_path = dsl.path ++ [dsl.name] dsls |> Enum.filter(fn potential_parent -> List.starts_with?(dsl_path, potential_parent.path ++ [potential_parent.name]) end) |> Enum.flat_map(fn dsl -> dsl.imports || [] end) |> Enum.flat_map(fn mod_name -> case find_module(libraries, selected_versions, mod_name) do nil -> Logger.warn("No such module found called #{inspect(mod_name)}") [] module -> [module] end end) end defp find_module(libraries, selected_versions, mod_name) do Enum.find_value(libraries, fn library -> version = selected_version(library, selected_versions[library.id]) if version do Enum.find(version.modules, &(&1.name == mod_name)) end end) end defp selected_version(library, selected_version) do if selected_version == "latest" do latest_version(library) else Enum.find(library.versions, &(&1.id == selected_version)) end end defp child_dsls(_, nil), do: [] defp child_dsls(nil, _), do: [] defp child_dsls(%{dsls: dsls}, dsl) do dsl_path = dsl.path ++ [dsl.name] dsl_path_count = Enum.count(dsl_path) Enum.filter(dsls, fn potential_child -> potential_child_path = potential_child.path ++ [potential_child.name] List.starts_with?(potential_child_path, dsl_path) && Enum.count(potential_child_path) - dsl_path_count == 1 end) end defp positional_options(options) do options |> Enum.filter(& &1.argument_index) |> Enum.sort_by(& &1.argument_index) end def path_to_name(path, name) do Enum.map_join(path ++ [name], "-", &DocRoutes.sanitize_name/1) end defp render_tags(assigns, option) do ~F""" {#if option.required} Required {/if} """ end def show_sidebar(js \\ %JS{}) do js |> JS.toggle( to: "#mobile-sidebar-container", in: { "transition ease-in duration-100", "opacity-0", "opacity-100" }, out: { "transition ease-out duration-75", "opacity-100", "opacity-0" } ) end def hide_sidebar(js \\ %JS{}) do js |> JS.hide( to: "#mobile-sidebar-container", transition: { "transition ease-out duration-75", "opacity-100", "opacity-0" } ) end def assign_libraries(socket) do socket = assign_library(socket) dsls_query = AshHq.Docs.Dsl |> Ash.Query.sort(order: :asc) |> load_for_search() options_query = AshHq.Docs.Option |> Ash.Query.sort(order: :asc) |> load_for_search() guides_query = AshHq.Docs.Guide |> Ash.Query.new() |> load_for_search() modules_query = AshHq.Docs.Module |> Ash.Query.sort(order: :asc) |> load_for_search() mix_tasks_query = AshHq.Docs.MixTask |> Ash.Query.sort(order: :asc) |> load_for_search() extensions_query = AshHq.Docs.Extension |> Ash.Query.sort(order: :asc) |> Ash.Query.load(options: options_query, dsls: dsls_query) |> load_for_search() new_libraries = socket.assigns.libraries |> Enum.flat_map(fn library -> latest_version = AshHqWeb.Helpers.latest_version(library) Enum.filter(library.versions, fn version -> (latest_version && version.id == latest_version.id) || version.id == socket.assigns[:selected_versions][library.id] || (socket.assigns[:library_version] && socket.assigns[:library_version].id == version.id) || (socket.assigns.params["version"] && socket.assigns.params["version"] == version.version) end) end) |> AshHq.Docs.load!( [ extensions: extensions_query, guides: guides_query, modules: modules_query, mix_tasks: mix_tasks_query ], lazy?: true ) |> Enum.reduce(socket.assigns.libraries, fn library_version, libraries -> Enum.map(libraries, fn library -> if library.id == library_version.library_id do Map.update!(library, :versions, fn versions -> Enum.map(versions, fn current_version -> if current_version.id == library_version.id do library_version else current_version end end) end) else library end end) end) assign(socket, :libraries, new_libraries) end def load_docs(socket) do socket |> assign_library() |> assign_extension() |> assign_guide() |> assign_module() |> assign_mix_task() |> assign_dsl() |> assign_fallback_guide() |> assign_docs() end defp assign_fallback_guide(socket) do if socket.assigns[:library_version] && !(socket.assigns[:dsl] || socket.assigns[:mix_task] || socket.assigns[:guide] || socket.assigns[:extension] || socket.assigns[:module]) do guide = Enum.find(socket.assigns.library_version.guides, fn guide -> guide.default end) || Enum.find(socket.assigns.library_version.guides, fn guide -> String.contains?(guide.sanitized_name, "started") end) || Enum.at(socket.assigns.library_version.guides, 0) guide = guide |> reselect!(:text_html) assign(socket, guide: guide) else socket end end defp reselect!(%resource{} = record, field) do if Ash.Resource.selected?(record, field) do record else # will blow up if pkey is not an id or if its not a docs resource # but w/e value = resource |> Ash.Query.select(field) |> Ash.Query.filter(id == ^record.id) |> AshHq.Docs.read_one!() |> Map.get(field) Map.put(record, field, value) end end defp reselect_and_get!(record, field) do record |> reselect!(field) |> Map.get(field) end defp load_for_search(query) do query |> Ash.Query.load(AshHq.Docs.Extensions.Search.load_for_search(query.resource)) |> deselect_doc_attributes() end defp deselect_doc_attributes(query) do query.resource |> AshHq.Docs.Extensions.RenderMarkdown.render_attributes() |> Enum.reduce(query, fn {source, target}, query -> Ash.Query.deselect(query, [source, target]) end) end defp assign_library(socket) do case Enum.find( socket.assigns.libraries, &(&1.name == socket.assigns.params["library"]) ) do nil -> socket |> assign(:library, nil) |> assign(:library_version, nil) library -> socket = if socket.assigns[:params]["version"] do library_version = case socket.assigns[:params]["version"] do "latest" -> AshHqWeb.Helpers.latest_version(library) version -> Enum.find( library.versions, &(&1.version == version) ) end if library_version do socket = assign( socket, library_version: library_version ) if socket.assigns.params["version"] != "latest" && (!socket.assigns[:library] || socket.assigns.params["library"] != socket.assigns.library.name) do new_selected_versions = Map.put(socket.assigns.selected_versions, library.id, library_version.id) socket |> assign(selected_versions: new_selected_versions) |> push_event("selected-versions", new_selected_versions) else socket end else assign(socket, :library_version, nil) end else assign(socket, :library_version, nil) end assign(socket, :library, library) end end defp assign_extension(socket) do if socket.assigns.library_version && socket.assigns[:params]["extension"] do extensions = socket.assigns.library_version.extensions assign(socket, extension: Enum.find(extensions, fn extension -> extension.sanitized_name == socket.assigns[:params]["extension"] || AshHqWeb.DocRoutes.sanitize_name(extension.target) == socket.assigns[:params]["extension"] end) ) else if socket.assigns.library_version && socket.assigns[:params]["module"] do extensions = socket.assigns.library_version.extensions assign(socket, extension: Enum.find(extensions, fn extension -> extension.sanitized_name == socket.assigns[:params]["module"] || AshHqWeb.DocRoutes.sanitize_name(extension.target) == socket.assigns[:params]["module"] end) ) else assign(socket, :extension, nil) end end end defp assign_guide(socket) do guide_route = socket.assigns[:params]["guide"] guide = if guide_route && socket.assigns.library_version do guide_route = Enum.map(List.wrap(guide_route), &String.trim_trailing(&1, ".md")) Enum.find(socket.assigns.library_version.guides, fn guide -> matches_path?(guide, guide_route) || matches_name?(guide, guide_route) end) end assign(socket, :guide, guide) end defp matches_path?(guide, guide_route) do guide.sanitized_route == DocRoutes.sanitize_name(Enum.join(guide_route, "/"), true) end defp matches_name?(guide, list) do guide_name = List.last(list) DocRoutes.sanitize_name(guide.name) == DocRoutes.sanitize_name(guide_name) end defp assign_dsl(socket) do case socket.assigns[:params]["dsl_path"] do nil -> assign(socket, :dsl, nil) path -> path = Enum.join(path, "/") dsl = Enum.find( socket.assigns.extension.dsls, &(&1.sanitized_path == path) ) socket |> assign( :dsl, dsl ) end end defp assign_module(socket) do if !socket.assigns.extension && socket.assigns.library && socket.assigns.library_version && socket.assigns[:params]["module"] do module = Enum.find( socket.assigns.library_version.modules, &(&1.sanitized_name == socket.assigns[:params]["module"]) ) functions_query = AshHq.Docs.Function |> Ash.Query.sort(name: :asc, arity: :asc) |> load_for_search() |> Ash.Query.load(:doc_html) assign(socket, module: AshHq.Docs.load!(module, [functions: functions_query], lazy?: true) ) else assign(socket, :module, nil) end end defp assign_mix_task(socket) do if socket.assigns.library && socket.assigns.library_version && socket.assigns[:params]["mix_task"] do mix_task = Enum.find( socket.assigns.library_version.mix_tasks, &(&1.sanitized_name == socket.assigns[:params]["mix_task"]) ) assign(socket, mix_task: mix_task ) else assign(socket, :mix_task, nil) end end defp assign_docs(socket) do cond do socket.assigns.module -> send(self(), {:page_title, socket.assigns.module.name}) assign(socket, docs: socket.assigns.module |> reselect_and_get!(:doc_html), title: "Module: #{socket.assigns.module.name}", description: "View the documentation for #{socket.assigns.module.name} on Ash HQ.", doc_path: [socket.assigns.library.name, socket.assigns.module.name], options: [] ) socket.assigns.mix_task -> send(self(), {:page_title, socket.assigns.mix_task.name}) assign(socket, docs: socket.assigns.mix_task |> reselect_and_get!(:doc_html), title: "Mix Task: #{socket.assigns.mix_task.name}", description: "View the documentation for #{socket.assigns.mix_task.name} on Ash HQ.", doc_path: [socket.assigns.library.name, socket.assigns.mix_task.name], options: [] ) socket.assigns.dsl -> send(self(), {:page_title, socket.assigns.dsl.name}) meta_name = Enum.join( [ socket.assigns.library.name, socket.assigns.extension.name ] ++ socket.assigns.dsl.path ++ [socket.assigns.dsl.name], " > " ) meta_type = String.capitalize(to_string(socket.assigns.dsl.type)) assign(socket, docs: socket.assigns.dsl |> reselect_and_get!(:doc_html), title: "DSL #{meta_type}: #{meta_name}", description: "View the documentation for DSL #{meta_type}: #{meta_name} on Ash HQ.", doc_path: [ socket.assigns.library.name, socket.assigns.extension.name ] ++ socket.assigns.dsl.path ++ [socket.assigns.dsl.name], options: Enum.filter( socket.assigns.extension.options, &(&1.path == socket.assigns.dsl.path ++ [socket.assigns.dsl.name]) ) ) socket.assigns.extension -> send(self(), {:page_title, socket.assigns.extension.name}) assign(socket, docs: socket.assigns.extension |> reselect_and_get!(:doc_html), title: "Extension: #{socket.assigns.extension.name}", description: "View the documentation for #{socket.assigns.extension.name} on Ash HQ.", doc_path: [socket.assigns.library.name, socket.assigns.extension.name], options: [] ) socket.assigns.guide -> send(self(), {:page_title, socket.assigns.guide.name}) assign(socket, title: "Guide: #{socket.assigns.guide.name}", docs: socket.assigns.guide |> reselect_and_get!(:text_html), description: "Read the \"#{socket.assigns.guide.name}\" guide on Ash HQ", doc_path: [socket.assigns.library.name, socket.assigns.guide.name], options: [] ) true -> assign(socket, docs: "", title: "Ash Framework", description: default_description(), doc_path: [], dsls: [], options: [] ) end end defp default_description do "A declarative foundation for ambitious Elixir applications. Model your domain, derive the rest." end # workaround for https://github.com/patrick-steele-idem/morphdom/pull/231 # Adding a unique ID on the container for the rendered docs prevents morphdom # merging them incorrectly. defp docs_container_id(doc_path) do ["docs-container" | doc_path] |> Enum.join("-") |> String.replace(~r/[^A-Za-z0-9_]/, "-") |> String.downcase() end end