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 prop show_catalogue_call_to_action, :boolean 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 data sidebar_data, :any data not_found, :boolean, default: false @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 catalogue_call_to_action(assigns) do ~F"""
View the full range of Ash libraries for authentication, soft deletion, GraphQL and more
""" 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_sidebar_content() |> 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) 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) |> 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() rescue _e in Ash.Error.Query.NotFound -> assign(socket, :not_found, true) end defp assign_sidebar_content(socket) do sidebar_data = [ %{ name: "Guides", id: "guides", categories: guides_by_category_and_library( socket.assigns[:libraries], socket.assigns[:library_version], socket.assigns[:selected_versions], socket.assigns[:guide] ) }, %{ name: "DSLs & Extensions", id: "dsls", categories: get_extensions( socket.assigns[:libraries], socket.assigns[:library_version], socket.assigns[:selected_versions], socket.assigns[:extension] ) }, %{ name: "Code", id: "code", categories: modules_by_category_and_library( socket.assigns[:libraries], socket.assigns[:library_version], socket.assigns[:selected_versions], socket.assigns[:module] ) }, %{ name: "Mix Tasks", id: "mix-tasks", categories: mix_tasks_by_category_and_library( socket.assigns[:libraries], socket.assigns[:library_version], socket.assigns[:selected_versions], socket.assigns[:mix_task] ) } ] assign(socket, sidebar_libraries: socket.assigns.libraries, sidebar_data: sidebar_data) end @start_guides ["Tutorials", "Topics", "How To", "Misc"] defp guides_by_category_and_library(libraries, library_version, selected_versions, active_guide) do libraries |> Enum.map(&{&1, selected_version(&1, library_version, selected_versions)}) |> Enum.filter(fn {_library, version} -> version != nil end) |> Enum.sort_by(fn {library, _version} -> library.order end) |> Enum.flat_map(fn {library, %{guides: guides}} -> guides |> Enum.sort_by(& &1.order) |> Enum.group_by(& &1.category, fn guide -> %{ id: guide.id, name: guide.name, to: DocRoutes.doc_link(guide, selected_versions), active?: active_guide && active_guide.id == guide.id } end) |> Enum.map(fn {category, guides} -> {category, {library.display_name, guides}} end) end) |> Enum.group_by(&elem(&1, 0), &elem(&1, 1)) |> partially_alphabetically_sort(@start_guides, []) end @last_categories ["Errors"] defp modules_by_category_and_library( libraries, library_version, selected_versions, active_module ) do libraries |> Enum.map(&{&1, selected_version(&1, library_version, selected_versions)}) |> Enum.filter(fn {_library, version} -> version != nil end) |> Enum.sort_by(fn {library, _version} -> library.order end) |> Enum.flat_map(fn {library, %{modules: modules}} -> modules |> Enum.sort_by(& &1.order) |> Enum.group_by(& &1.category, fn module -> %{ id: module.id, name: module.name, to: DocRoutes.doc_link(module, selected_versions), active?: active_module && active_module.id == module.id } end) |> Enum.map(fn {category, modules} -> {category, {library.display_name, modules}} end) end) |> Enum.group_by(&elem(&1, 0), &elem(&1, 1)) |> partially_alphabetically_sort([], @last_categories) end defp mix_tasks_by_category_and_library( libraries, library_version, selected_versions, active_mix_task ) do libraries |> Enum.map(&{&1, selected_version(&1, library_version, selected_versions)}) |> Enum.filter(fn {_library, version} -> version != nil end) |> Enum.sort_by(fn {library, _version} -> library.order end) |> Enum.flat_map(fn {library, %{mix_tasks: mix_tasks}} -> mix_tasks |> Enum.sort_by(& &1.order) |> Enum.group_by(& &1.category, fn mix_task -> %{ id: mix_task.id, name: mix_task.name, to: DocRoutes.doc_link(mix_task, selected_versions), active?: active_mix_task && active_mix_task.id == mix_task.id } end) |> Enum.map(fn {category, mix_tasks} -> {category, {library.display_name, mix_tasks}} end) end) |> Enum.group_by(&elem(&1, 0), &elem(&1, 1)) |> partially_alphabetically_sort([], @last_categories) end defp selected_version(library, library_version, selected_versions) do selected_version = selected_versions[library.id] if library_version && library_version.library_id == library.id do library_version else if selected_version == "latest" do AshHqWeb.Helpers.latest_version(library) else if selected_version not in [nil, ""] do Enum.find(library.versions, &(&1.id == selected_version)) end end end end defp partially_alphabetically_sort(keyed_list, first, last) do {first_items, rest} = Enum.split_with(keyed_list, fn {key, _} -> key in first end) {last_items, rest} = Enum.split_with(rest, fn {key, _} -> key in last end) first_items |> Enum.sort_by(fn {key, _} -> Enum.find_index(first, &(&1 == key)) end) |> Enum.concat(Enum.sort_by(rest, &elem(&1, 0))) |> Enum.concat( Enum.sort_by(last_items, fn {key, _} -> Enum.find_index(last, &(&1 == key)) end) ) end def slug(string) do string |> String.downcase() |> String.replace(" ", "_") |> String.replace(~r/[^a-z0-9-_]/, "-") end @target_order ["Ash.Resource", "Ash.Api", "Ash.Flow", "Ash.Registry"] defp get_extensions(libraries, library_version, selected_versions, active_extension) do libraries |> Enum.map(&{&1, selected_version(&1, library_version, selected_versions)}) |> Enum.filter(fn {_library, version} -> version != nil end) |> Enum.sort_by(fn {library, _version} -> library.order end) |> Enum.flat_map(fn {library, %{extensions: extensions}} -> extensions |> Enum.sort_by(& &1.order) |> Enum.group_by(& &1.target, fn extension -> %{ id: extension.id, name: extension.module || extension.name, to: DocRoutes.doc_link(extension, selected_versions), active?: active_extension && active_extension.id == extension.id } end) |> Enum.map(fn {target, extensions} -> {target, {library.display_name, extensions}} end) end) |> Enum.group_by(&elem(&1, 0), &elem(&1, 1)) |> partially_alphabetically_sort(@target_order, []) 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 -> assign(socket, not_found: true, library: Enum.find(socket.assigns.libraries, &(&1.name == "ash")) ) 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 is_nil(library_version) do assign(socket, not_found: true, library_version: AshHqWeb.Helpers.latest_version(library) ) else 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 assign(socket, :library_version, nil) end 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 extension = Enum.find(socket.assigns.library_version.extensions, fn extension -> extension.sanitized_name == socket.assigns[:params]["extension"] || AshHqWeb.DocRoutes.sanitize_name(extension.target) == socket.assigns[:params]["extension"] end) || raise Ash.Error.Query.NotFound, primary_key: %{sanitized_name: socket.assigns[:params]["extension"]} 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() extension = AshHq.Docs.load!(extension, dsls: dsls_query, options: options_query) assign(socket, extension: extension ) else if socket.assigns.library_version && socket.assigns[:params]["module"] do extension = Enum.find(socket.assigns.library_version.extensions, fn extension -> extension.sanitized_name == socket.assigns[:params]["module"] || AshHqWeb.DocRoutes.sanitize_name(extension.target) == socket.assigns[:params]["module"] end) extension = if extension do 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() AshHq.Docs.load!(extension, dsls: dsls_query, options: options_query) end assign(socket, extension: extension ) 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) ) || raise Ash.Error.Query.NotFound, primary_key: %{sanitized_path: socket.assigns[:params]["dsl_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"]) ) || raise Ash.Error.Query.NotFound, primary_key: %{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"]) ) || raise Ash.Error.Query.NotFound, primary_key: %{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)) dsl = AshHq.Docs.load!(socket.assigns.dsl, [:doc_html], lazy?: true) options = Enum.filter( socket.assigns.extension.options, &(&1.path == socket.assigns.dsl.path ++ [socket.assigns.dsl.name]) ) |> AshHq.Docs.load!(:doc_html, lazy?: true) assign(socket, docs: dsl.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: options ) 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