defmodule AshHqWeb.Pages.Docs do @moduledoc "The page for showing documentation" use Surface.LiveComponent import AshHqWeb.Helpers import AshHqWeb.Tails alias AshHqWeb.Components.DocSidebar alias AshHqWeb.DocRoutes alias Phoenix.LiveView.JS alias Surface.Components.LivePatch require Logger require Ash.Query prop(change_versions, :event, 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(docs, :any) data(library_version, :any) data(guide, :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"""
""" end def github_guide_link(assigns) do ~F""" """ end def hex_guide_link(assigns) do ~F""" """ end def docs(assigns) do ~F"""
{raw(@docs)}
""" end def update(assigns, socket) do if socket.assigns[:loaded_once?] 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 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() new_libraries = AshHq.Docs.load!(socket.assigns.libraries, versions: [guides: guides_query]) assign(socket, :libraries, new_libraries) end def load_docs(socket) do socket |> assign_library() |> assign_guide() |> assign_fallback_guide() |> assign_docs() end defp assign_sidebar_content(socket) do sidebar_data = guides_by_category_and_library( socket.assigns[:libraries], socket.assigns[:guide] ) 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, active_guide) do libraries |> Enum.map(fn library -> {library, Enum.at(library.versions, 0)} end) |> 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), 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 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 defp assign_fallback_guide(socket) do if socket.assigns[:library_version] && !socket.assigns[:guide] 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!(nil, _), do: nil 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 -> library = Enum.find(socket.assigns.libraries, &(&1.name == "ash")) assign(socket, not_found: true, library: library, library_version: AshHqWeb.Helpers.latest_version(library) ) library -> socket |> assign(:library, library) |> assign(:library_version, AshHqWeb.Helpers.latest_version(library)) 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_docs(socket) do cond do socket.assigns.guide -> send(self(), {:page_title, socket.assigns.guide.name}) assign(socket, title: "Guide: #{socket.assigns.guide.name}" |> IO.inspect(), docs: socket.assigns.guide |> reselect_and_get!(:text_html), description: "Read the \"#{socket.assigns.guide.name}\" guide on Ash HQ" ) true -> assign(socket, docs: "", title: "Ash Framework", description: default_description() ) end end defp default_description do "A declarative foundation for ambitious Elixir applications. Model your domain, derive the rest." end end