diff --git a/.formatter.exs b/.formatter.exs index 6ede60f6..277d7292 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -8,7 +8,7 @@ request: 2 ], line_length: 120, - import_deps: [:gen_lsp], + import_deps: [:gen_lsp, :plug, :temple], plugins: [Styler], inputs: [ ".formatter.exs", diff --git a/assets/css/app.css b/assets/css/app.css new file mode 100644 index 00000000..b94b4b8c --- /dev/null +++ b/assets/css/app.css @@ -0,0 +1,14 @@ +@import "tailwindcss/base"; +@import "tailwindcss/components"; +@import "tailwindcss/utilities"; + +@import url('https://fonts.googleapis.com/css2?family=Rubik:ital,wght@0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap'); +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@100;200;300;400;500;600;700;800;900&family=Rubik:ital,wght@0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap'); + +pre { + @apply bg-zinc-500 text-white rounded px-2 text-sm; +} + +h1, h2, h3, h4, h5 { + @apply font-fancy font-semibold; +} diff --git a/assets/tailwind.config.js b/assets/tailwind.config.js new file mode 100644 index 00000000..40b5eec3 --- /dev/null +++ b/assets/tailwind.config.js @@ -0,0 +1,17 @@ +// See the Tailwind configuration guide for advanced usage +// https://tailwindcss.com/docs/configuration + +const defaultTheme = require("tailwindcss/defaultTheme"); + +module.exports = { + content: ["./js/**/*.js", "./lib/**/*.ex"], + theme: { + extend: { + fontFamily: { + sans: ['"Inter"', ...defaultTheme.fontFamily.sans], + fancy: ['"Rubik"', ...defaultTheme.fontFamily.sans], + }, + }, + }, + plugins: [], +}; diff --git a/config/config.exs b/config/config.exs index cd210e21..cb8aad5e 100644 --- a/config/config.exs +++ b/config/config.exs @@ -2,6 +2,21 @@ import Config config :next_ls, :indexing_timeout, 100 +config :temple, + engine: EEx.SmartEngine, + attributes: {Temple, :attributes} + +config :tailwind, + version: "3.3.2", + default: [ + args: ~w( + --config=assets/tailwind.config.js + --input=assets/css/app.css + --output=priv/css/site.css + ) + ] + +# config :logger, :default_handler, config: [type: :standard_error] config :logger, :default_handler, config: [ file: ~c".elixir-tools/next-ls.log", @@ -14,4 +29,12 @@ config :logger, :default_handler, config :logger, :default_formatter, format: "\n$time $metadata[$level] $message\n", metadata: [:id] +config :next_ls, :logger, [ + {:handler, :ui_logger, NextLS.UI.Logger, + %{ + config: %{}, + formatter: Logger.Formatter.new() + }} +] + import_config "#{config_env()}.exs" diff --git a/config/dev.exs b/config/dev.exs index becde769..614a307b 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -1 +1,6 @@ import Config + +config :next_ls, :assets, tailwind: {Tailwind, :install_and_run, [:default, ~w(--watch)]} + +config :web_dev_utils, :reload_url, "'wss://' + location.host + '/ws'" +config :web_dev_utils, :reload_log, true diff --git a/lib/next_ls.ex b/lib/next_ls.ex index 7ba9440d..25b379df 100644 --- a/lib/next_ls.ex +++ b/lib/next_ls.ex @@ -64,13 +64,22 @@ defmodule NextLS do task_supervisor = Keyword.fetch!(args, :task_supervisor) runtime_task_supervisor = Keyword.fetch!(args, :runtime_task_supervisor) dynamic_supervisor = Keyword.fetch!(args, :dynamic_supervisor) - registry = Keyword.fetch!(args, :registry) - extensions = Keyword.get(args, :extensions, elixir: NextLS.ElixirExtension, credo: NextLS.CredoExtension) cache = Keyword.fetch!(args, :cache) + {:ok, logger} = DynamicSupervisor.start_child(dynamic_supervisor, {NextLS.Logger, lsp: lsp}) + {:ok, ui} = + DynamicSupervisor.start_child( + dynamic_supervisor, + {Bandit, + [ + plug: {NextLS.UI.Router, registry: registry}, + port: "NEXTLS_UI_PORT" |> System.get_env("0") |> String.to_integer() + ]} + ) + {:ok, assign(lsp, auto_update: Keyword.get(args, :auto_update, false), @@ -85,7 +94,8 @@ defmodule NextLS do registry: registry, extensions: extensions, ready: false, - client_capabilities: nil + client_capabilities: nil, + ui: ui )} end @@ -127,6 +137,7 @@ defmodule NextLS do nil end, document_formatting_provider: true, + execute_command_provider: %GenLSP.Structures.ExecuteCommandOptions{commands: ["open-ui"]}, hover_provider: true, workspace_symbol_provider: true, document_symbol_provider: true, @@ -572,6 +583,23 @@ defmodule NextLS do {:reply, [], lsp} end + def handle_request( + %GenLSP.Requests.WorkspaceExecuteCommand{params: %GenLSP.Structures.ExecuteCommandParams{command: command}}, + lsp + ) do + {:ok, {_, port}} = ThousandIsland.listener_info(lsp.assigns.ui) + + case command do + "open-ui" -> + System.cmd("open", ["http://localhost:#{port}"]) + + _ -> + NextLS.Logger.warning(lsp.logger, "[Next LS] Unknown workspace command: #{command}") + end + + {:reply, nil, lsp} + end + def handle_request(%Shutdown{}, lsp) do {:reply, nil, assign(lsp, exit_code: 0)} end diff --git a/lib/next_ls/application.ex b/lib/next_ls/application.ex index 0e935ce0..58aab83c 100644 --- a/lib/next_ls/application.ex +++ b/lib/next_ls/application.ex @@ -24,11 +24,22 @@ defmodule NextLS.Application do Node.start(:"next-ls-#{System.system_time()}", :shortnames) - children = [NextLS.LSPSupervisor] + children = [ + {Registry, name: NextLS.UI.Registry, keys: :duplicate}, + WebDevUtils.FileSystem, + WebDevUtils.CodeReloader, + NextLS.LSPSupervisor + ] # See https://hexdocs.pm/elixir/Supervisor.html # for other strategies and supported options opts = [strategy: :one_for_one, name: NextLS.Supervisor] - Supervisor.start_link(children, opts) + Supervisor.start_link(children ++ asset_children(), opts) + end + + def asset_children do + for conf <- Application.get_env(:next_ls, :assets, []) do + {WebDevUtils.Assets, conf} + end end end diff --git a/lib/next_ls/db/activity.ex b/lib/next_ls/db/activity.ex index 26d34099..988f2826 100644 --- a/lib/next_ls/db/activity.ex +++ b/lib/next_ls/db/activity.ex @@ -13,7 +13,13 @@ defmodule NextLS.DB.Activity do :gen_statem.start_link({:local, Keyword.get(args, :name)}, __MODULE__, Keyword.drop(args, [:name]), []) end - def update(statem, count), do: :gen_statem.cast(statem, count) + def update(statem, count, time \\ DateTime.utc_now() |> DateTime.to_unix(:millisecond)) do + Registry.dispatch(NextLS.UI.Registry, :activity_socket, fn entries -> + for {pid, _} <- entries, do: send(pid, {:activity, count, time}) + end) + + :gen_statem.cast(statem, count) + end @impl :gen_statem def callback_mode, do: :state_functions @@ -44,6 +50,10 @@ defmodule NextLS.DB.Activity do {:next_state, :waiting, %{data | token: nil}} end + # def active(event, msg, data) do + # handle_event(event, msg, data) + # end + def waiting(:cast, 0, _data) do :keep_state_and_data end @@ -53,4 +63,12 @@ defmodule NextLS.DB.Activity do NextLS.Progress.start(data.lsp, token, "Indexing!") {:next_state, :active, %{data | count: mailbox_count, token: token}} end + + # def waiting(event, msg, data) do + # handle_event(event, msg, data) + # end + + # defp handle_event({:call, from}, :get, data) do + # {:keep_state, data, [{:reply, from, data.count}]} + # end end diff --git a/lib/next_ls/runtime/supervisor.ex b/lib/next_ls/runtime/supervisor.ex index 32751e03..5a74452c 100644 --- a/lib/next_ls/runtime/supervisor.ex +++ b/lib/next_ls/runtime/supervisor.ex @@ -26,7 +26,9 @@ defmodule NextLS.Runtime.Supervisor do children = [ {NextLS.Runtime.Sidecar, name: sidecar_name, db: db_name}, {NextLS.DB.Activity, - logger: logger, name: db_activity, lsp: lsp, timeout: Application.get_env(:next_ls, :indexing_timeout)}, + logger: logger, + registry: registry, + name: db_activity, lsp: lsp, timeout: Application.get_env(:next_ls, :indexing_timeout)}, {NextLS.DB, logger: logger, file: "#{hidden_folder}/nextls.db", diff --git a/lib/next_ls_ui/components.ex b/lib/next_ls_ui/components.ex new file mode 100644 index 00000000..64448658 --- /dev/null +++ b/lib/next_ls_ui/components.ex @@ -0,0 +1,111 @@ +defmodule NextLS.UI.Component do + @moduledoc false + use Temple.Component + + defmacro __using__(_) do + quote do + import Temple + import unquote(__MODULE__) + end + end +end + +defmodule NextLS.UI.Components do + @moduledoc false + use NextLS.UI.Component + + @env Mix.env() + + def root(assigns) do + assigns = Map.put(assigns, :env, @env) + + temple do + "" + + html lang: "en" do + head do + meta charset: "utf-8" + meta http_equiv: "X-UA-Compatible", content: "IE=edge" + meta name: "viewport", content: "width=device-width, initial-scale=1.0" + + title do + "Next LS Inspector" + end + + script src: "https://unpkg.com/htmx.org@1.9.6" + script src: "https://unpkg.com/htmx.org/dist/ext/ws.js" + script src: "https://unpkg.com/htmx.org/dist/ext/morphdom-swap.js" + + script src: "https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.0/chart.umd.min.js" + + script src: "https://cdn.jsdelivr.net/npm/luxon@^2" + script src: "https://cdn.jsdelivr.net/npm/chartjs-adapter-luxon@^1" + + + link rel: "stylesheet", href: "/css/site.css" + end + + body class: "bg-zinc-200 dark:bg-zinc-900 font-sans", hx_ext: "morphdom-swap" do + main class: "container mx-auto" do + header class: "mb-8 py-2" do + div class: "flex items-center space-x-2" do + a href: "/", class: "hover:underline" do + img src: "/nextls-logo-no-background.png", class: "h-8 w-8" + end + + h2 class: "text-xl dark:text-white" do + a href: "/" do + "Next LS Inspector" + end + end + end + end + + slot @inner_block + + footer class: "flex justify-between dark:text-white mt-8 py-4" do + div do + a class: "underline", + href: "https://github.com/elixir-tools/next-ls", + do: "Source Code" + end + + div class: "italic" do + span do: "Built with" + + a class: "underline", + href: "https://github.com/mhanberg/temple", + do: "Temple," + + a class: "underline", + href: "https://tailwindcss.com", + do: "TailwindCSS," + + a class: "underline", + href: "https://htmx.org", + do: "HTMX," + + " and" + + span class: "text-red-500", do: "♥" + end + end + end + + if @env == :dev do + c &WebDevUtils.Components.live_reload/1 + end + end + end + end + end + + def card(assigns) do + temple do + div class: "#{assigns[:class]} bg-zinc-50 dark:bg-zinc-700 dark:text-white rounded shadow-xl p-2", + rest!: Map.take(assigns, [:id]) do + slot @inner_block + end + end + end +end diff --git a/lib/next_ls_ui/logger.ex b/lib/next_ls_ui/logger.ex new file mode 100644 index 00000000..c0ffc3dd --- /dev/null +++ b/lib/next_ls_ui/logger.ex @@ -0,0 +1,12 @@ +defmodule NextLS.UI.Logger do + @moduledoc false + def log(event, _config) do + if Process.alive?(Process.whereis(NextLS.UI.Registry)) do + Registry.dispatch(NextLS.UI.Registry, :log_socket, fn entries -> + for {pid, _} <- entries do + send(pid, {:log, event}) + end + end) + end + end +end diff --git a/lib/next_ls_ui/pages/home_page.ex b/lib/next_ls_ui/pages/home_page.ex new file mode 100644 index 00000000..3ed07cc9 --- /dev/null +++ b/lib/next_ls_ui/pages/home_page.ex @@ -0,0 +1,268 @@ +defmodule NextLS.UI.HomePage do + @moduledoc false + + use NextLS.UI.Component + + import NextLS.UI.Components + + def run(_conn, assigns) do + assigns = Map.put(assigns, :node, assigns.query["node"] || Atom.to_string(Node.self())) + + temple do + c &root/1 do + div class: "grid grid-cols-1 lg:grid-cols-2 gap-4" do + c &card/1 do + h2 class: "text-xl dark:text-white mb-2" do + "System Information" + end + + ul do + li do + span class: "flex items-center gap-2" do + "Version:" + pre do: NextLS.version() + end + end + + li do + span class: "flex items-center gap-2" do + "Elixir: " + pre do: System.version() + end + end + + li do + span class: "flex items-center gap-2" do + "OTP:" + pre class: "whitespace-pre-wrap", do: :erlang.system_info(:system_version) + end + end + + li do + span class: "flex items-center gap-2" do + "OS:" + pre class: "whitespace-pre-wrap", do: inspect(:os.type()) + end + end + + li do + span class: "flex items-center gap-2" do + "Arch:" + + pre class: "whitespace-pre-wrap", + do: :system_architecture |> :erlang.system_info() |> List.to_string() + end + end + + li do + span class: "flex items-center gap-2" do + "Schedulers:" + + pre class: "whitespace-pre-wrap", + do: System.schedulers_online() + end + end + end + end + + c &card/1, class: "col-span-1" do + div class: "flex flex-col justify-between mb-2" do + h2 class: "text-lg dark:text-white mb-2" do + "Node Information" + end + + select class: "bg-zinc-500 text-zinc-50 dark:bg-zinc-100 dark:text-zinc-950 rounded p-1", + name: "node", + hx_get: "/node", + hx_target: "#node" do + for n <- [Node.self() | Node.list()] do + option value: n, selected: @node == n do + n + end + end + end + end + + div id: "node" do + c &node_information/1, node: @node + end + end + + c &card/1, class: "col-span-2" do + h2 class: "text-lg dark:text-white mb-2" do + "DB Query Lag" + end + + div class: "w-full" do + canvas id: "lag" + end + + div hx_ext: "ws", ws_connect: "/ws/activity" do + div id: "activity" do + script do: """ + var chart = new Chart( + document.getElementById('lag'), + { + type: 'bar', + options: { + scales: { + x: { + type: "time" + } + } + }, + data: { + labels: [], + datasets: [ + { + label: 'DB lag over time', + data: [] + } + ] + } + } + ); + """ + end + end + end + + c &card/1, class: "col-span-1 lg:col-span-2" do + h2 class: "text-xl dark:text-white mb-2" do + "Logs" + end + + div class: "group min-h-[16rem]", + id: "log-container", + hx_ext: "ws", + ws_connect: "/ws/logs", + data_log_show_error: true, + data_log_show_warning: true, + data_log_show_info: true, + data_log_show_debug: true, + hx_on: + "htmx:wsAfterMessage: event.currentTarget.children.logs.scrollTo(0, event.currentTarget.children.logs.scrollHeight)" do + div class: "flex gap-4 mb-4" do + c &log_toggle_button/1, type: :debug do + "debug" + end + + c &log_toggle_button/1, type: :info do + "info" + end + + c &log_toggle_button/1, type: :warning do + "warning" + end + + c &log_toggle_button/1, type: :error do + "error" + end + end + + div id: "logs", class: "group max-h-72 overflow-y-scroll font-mono" do + div class: "hidden only:block italic text-sm" do + "Nothing yet..." + end + end + end + end + end + end + end + end + + def log_toggle_button(assigns) do + classes = + case assigns.type do + :error -> + "border-red-500 group-data-[log-show-error]:bg-red-500 text-red-500 group-data-[log-show-error]:text-red-950" + + :warning -> + "border-yellow-500 group-data-[log-show-warning]:bg-yellow-500 text-yellow-500 group-data-[log-show-warning]:text-yellow-950" + + :info -> + "border-white group-data-[log-show-info]:bg-white text-white group-data-[log-show-info]:text-black" + + :debug -> + "border-cyan-500 group-data-[log-show-debug]:bg-cyan-500 text-cyan-500 group-data-[log-show-debug]:text-cyan-950" + end + + assigns = Map.put(assigns, :classes, classes) + + temple do + button class: "w-16 text-sm rounded border bg-transparent #{@classes}", + "hx_on:click": "htmx.find('#log-container').toggleAttribute('data-log-show-#{@type}')", + type: "button" do + slot @inner_block + end + end + end + + def node_information(assigns) do + temple do + ul do + li do + span class: "flex items-center gap-2" do + "Elixir: " + pre do: :erpc.call(String.to_atom(@node), System, :version, []) + end + end + + li do + span class: "flex items-center gap-2" do + "OTP:" + + pre class: "whitespace-pre-wrap", + do: :erpc.call(String.to_atom(@node), :erlang, :system_info, [:system_version]) + end + end + + li do + span class: "flex items-center gap-2" do + "Directory:" + + pre class: "whitespace-pre-wrap", + do: :erpc.call(String.to_atom(@node), File, :cwd!, []) + end + end + + li do + span class: "flex items-center gap-2" do + "Elixir exe:" + + pre class: "whitespace-pre-wrap", + do: :erpc.call(String.to_atom(@node), System, :find_executable, ["elixir"]) + end + end + + li do + span class: "flex items-center gap-2" do + "Erlang exe:" + + pre class: "whitespace-pre-wrap", + do: :erpc.call(String.to_atom(@node), System, :find_executable, ["erl"]) + end + end + + li do + span class: "flex items-center gap-2" do + "epmd exe:" + + pre class: "whitespace-pre-wrap", + do: :erpc.call(String.to_atom(@node), System, :find_executable, ["epmd"]) + end + end + + li do + span class: "flex items-center gap-2" do + "Schedulers:" + + pre class: "whitespace-pre-wrap", + do: :erpc.call(String.to_atom(@node), System, :schedulers_online, []) + end + end + end + end + end +end diff --git a/lib/next_ls_ui/router.ex b/lib/next_ls_ui/router.ex new file mode 100644 index 00000000..9c09d66f --- /dev/null +++ b/lib/next_ls_ui/router.ex @@ -0,0 +1,69 @@ +defmodule NextLS.UI.Router do + use Plug.Router, copy_opts_to_assign: :opts + use Plug.Debugger + use NextLS.UI.Component + + require Logger + + @not_found ~s''' + Not Found + ''' + + def init(opts), do: opts + + if Mix.env() == :dev do + plug :recompile + + defp recompile(conn, _) do + WebDevUtils.CodeReloader.reload() + + conn + end + end + + plug Plug.Static, at: "/", from: "priv", cache_control_for_etags: "no-cache" + plug :fetch_query_params + + plug :match + plug :dispatch + + get "/" do + response = NextLS.UI.HomePage.run(conn, %{query: conn.query_params}) + + conn + |> put_resp_header("Content-Type", "text/html") + |> resp(200, response) + end + + get "/node" do + response = NextLS.UI.HomePage.node_information(%{node: conn.query_params["node"]}) + + conn + |> put_resp_header("Content-Type", "text/html") + |> resp(200, response) + end + + get "/ws" do + conn + |> WebSockAdapter.upgrade(NextLS.UI.Websocket.Reload, [], timeout: 60_000) + |> halt() + end + + get "/ws/logs" do + conn + |> WebSockAdapter.upgrade(NextLS.UI.Websocket.Logs, [], timeout: 60_000) + |> halt() + end + + get "/ws/activity" do + conn + |> WebSockAdapter.upgrade(NextLS.UI.Websocket.Activity, [registry: conn.assigns.opts[:registry]], timeout: 60_000) + |> halt() + end + + match _ do + Logger.error("File not found: #{conn.request_path}") + + send_resp(conn, 404, @not_found) + end +end diff --git a/lib/next_ls_ui/websocket.ex b/lib/next_ls_ui/websocket.ex new file mode 100644 index 00000000..3a46ed51 --- /dev/null +++ b/lib/next_ls_ui/websocket.ex @@ -0,0 +1,173 @@ +defmodule NextLS.UI.Websocket do + @moduledoc false + defmodule Reload do + @moduledoc false + + def init(_args) do + :ok = WebDevUtils.LiveReload.init() + + {:ok, %{}} + end + + def handle_in({"subscribe", [opcode: :text]}, state) do + {:push, {:text, "subscribed"}, state} + end + + def handle_info({:reload, _asset_type}, state) do + {:push, {:text, "reload"}, state} + end + + def handle_info({:file_event, _watcher_pid, {_path, _event}} = file_event, state) do + WebDevUtils.LiveReload.reload!(file_event, + patterns: [ + ~r"lib/next_ls_ui/.*.ex", + ~r"assets/.*.(css|js)" + ] + ) + + {:ok, state} + end + end + + defmodule Logs do + @moduledoc false + import Temple + + require Logger + + def init(_args) do + Registry.register(NextLS.UI.Registry, :log_socket, true) + + {:ok, %{}} + end + + def handle_info({:log, event}, state) do + resp = + if event.level == :notice do + "" + else + {_formatter, config} = Logger.default_formatter(colors: [enabled: false]) + + temple do + div id: "logs", hx_swap_oob: "beforeend" do + div data_log_type: event.level, + class: """ + hidden + #{log_show(event.level)} + data-[log-type=error]:text-red-500 + data-[log-type=warning]:text-yellow-500 + data-[log-type=info]:text-white + data-[log-type=debug]:text-cyan-500 + """ do + Logger.Formatter.format(event, config) + end + end + end + end + + {:push, {:text, resp}, state} + end + + def handle_info(message, state) do + Logger.notice("Unhandled message: #{inspect(message)}") + + {:ok, state} + end + + defp log_show(type) do + case type do + :error -> "group-data-[log-show-error]:block" + :warning -> "group-data-[log-show-warning]:block" + :info -> "group-data-[log-show-info]:block" + :debug -> "group-data-[log-show-debug]:block" + end + end + end + + defmodule Activity do + @moduledoc false + import Temple + + require Logger + + def init(_args) do + Registry.register(NextLS.UI.Registry, :activity_socket, true) + :timer.send_interval(5000, :update) + {:ok, %{data: [], last: %{count: 0}}} + end + + def handle_info({:activity, count, time}, state) do + {:ok, put_in(state.data, [%{count: count, time: time} | state.data])} + end + + def handle_info(:update, state) do + now = DateTime.utc_now() |> DateTime.to_unix(:millisecond) |> to_string() + + assigns = + if state.data == [] do + %{ + counts: to_string(state.last.count), + times: now + } + else + counts = Enum.map_join(state.data, ", ", &"#{&1.count}") + times = Enum.map_join(state.data, ", ", &"#{&1.time}") + + %{ + counts: counts, + times: times + } + end + + resp = + temple do + div id: "activity" do + script do + """ + (function() { + const then = #{now} - 30000; + chart.data.labels.push(#{@times}); + chart.data.datasets[0].data.push(#{@counts}); + const data = chart.data.datasets[0].data; + const labels = chart.data.labels.filter((label) => label > then); + chart.data.labels = labels; + chart.data.datasets[0].data = data.slice(-labels.length); + chart.update() + })(); + """ + end + end + end + + {:push, {:text, resp}, %{state | data: [], last: List.first(state.data) || state.last}} + end + + def handle_info(message, state) do + Logger.notice("Unhandled message: #{inspect(message)}") + + {:ok, state} + end + + def javascript_escape(data) when is_binary(data), do: javascript_escape(data, "") + + defp javascript_escape(<<0x2028::utf8, t::binary>>, acc), do: javascript_escape(t, <>) + + defp javascript_escape(<<0x2029::utf8, t::binary>>, acc), do: javascript_escape(t, <>) + + defp javascript_escape(<<0::utf8, t::binary>>, acc), do: javascript_escape(t, <>) + + defp javascript_escape(<<">, acc), do: javascript_escape(t, <>) + + defp javascript_escape(<<"\r\n", t::binary>>, acc), do: javascript_escape(t, <>) + + defp javascript_escape(<>, acc) when h in [?", ?', ?\\, ?`], + do: javascript_escape(t, <>) + + defp javascript_escape(<>, acc) when h in [?\r, ?\n], + do: javascript_escape(t, <>) + + defp javascript_escape(<>, acc), do: javascript_escape(t, <>) + + defp javascript_escape(<<>>, acc), do: acc + end +end diff --git a/mix.exs b/mix.exs index 9838cfad..6993219b 100644 --- a/mix.exs +++ b/mix.exs @@ -64,12 +64,17 @@ defmodule NextLS.MixProject do {:gen_lsp, "~> 0.7"}, {:req, "~> 0.3.11"}, {:schematic, "~> 0.2"}, - {:opentelemetry, "~> 1.3"}, {:opentelemetry_api, "~> 1.2"}, {:opentelemetry_exporter, "~> 1.4"}, {:opentelemetry_process_propagator, "~> 0.2.2"}, {:opentelemetry_telemetry, "~> 1.0"}, + {:bandit, "~> 1.0"}, + {:websock_adapter, "~> 0.5"}, + {:temple, "~> 0.12"}, + {:web_dev_utils, "~> 0.1"}, + {:contex, "~> 0.5"}, + {:tailwind, "~> 0.2"}, {:burrito, github: "burrito-elixir/burrito", only: [:dev, :prod]}, {:bypass, "~> 2.1", only: :test}, {:dialyxir, ">= 0.0.0", only: [:dev, :test], runtime: false}, diff --git a/mix.lock b/mix.lock index 60f06bf4..68bd6b8d 100644 --- a/mix.lock +++ b/mix.lock @@ -1,5 +1,6 @@ %{ "acceptor_pool": {:hex, :acceptor_pool, "1.0.0", "43c20d2acae35f0c2bcd64f9d2bde267e459f0f3fd23dab26485bf518c281b21", [:rebar3], [], "hexpm", "0cbcd83fdc8b9ad2eee2067ef8b91a14858a5883cb7cd800e6fcd5803e158788"}, + "bandit": {:hex, :bandit, "1.0.0", "2bd87bbf713d0eed0090f2fa162cd1676198122e6c2b68a201c706e354a6d5e5", [:mix], [{:hpax, "~> 0.1.1", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "32acf6ac030fee1f99fd9c3fcf81671911ae8637e0a61c98111861b466efafdb"}, "burrito": {:git, "https://github.com/burrito-elixir/burrito.git", "01f4781df21bb6657c68ef9b780daf194dd1aced", []}, "bypass": {:hex, :bypass, "2.1.0", "909782781bf8e20ee86a9cabde36b259d44af8b9f38756173e8f5e2e1fabb9b1", [:mix], [{:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: false]}, {:ranch, "~> 1.3", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "d9b5df8fa5b7a6efa08384e9bbecfe4ce61c77d28a4282f79e02f1ef78d96b80"}, "castore": {:hex, :castore, "1.0.3", "7130ba6d24c8424014194676d608cb989f62ef8039efd50ff4b3f33286d06db8", [:mix], [], "hexpm", "680ab01ef5d15b161ed6a95449fac5c6b8f60055677a8e79acf01b27baa4390b"}, @@ -16,8 +17,10 @@ "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, "ex_doc": {:hex, :ex_doc, "0.29.4", "6257ecbb20c7396b1fe5accd55b7b0d23f44b6aa18017b415cb4c2b91d997729", [:mix], [{:earmark_parser, "~> 1.4.31", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "2c6699a737ae46cb61e4ed012af931b57b699643b24dabe2400a8168414bc4f5"}, "exqlite": {:hex, :exqlite, "0.13.15", "a32c0763915e2b0d7ced9dd8638802d38e9569053f3b28b815bd0faef1cbe6d9", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "4afcc870a33b57781a1e57cd4294eef68815059d26b774c7cd075536b21434b7"}, + "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, "finch": {:hex, :finch, "0.16.0", "40733f02c89f94a112518071c0a91fe86069560f5dbdb39f9150042f44dcfb1a", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.3", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 0.2.6 or ~> 1.0", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f660174c4d519e5fec629016054d60edd822cdfe2b7270836739ac2f97735ec5"}, "gen_lsp": {:hex, :gen_lsp, "0.7.0", "14a73525c55b6ca7ba08390fe59720788f95e0b6b2ba0ccb43dcd439381d5e37", [:mix], [{:jason, "~> 1.3", [hex: :jason, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.5 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:schematic, "~> 0.2.1", [hex: :schematic, repo: "hexpm", optional: false]}, {:typed_struct, "~> 0.3.0", [hex: :typed_struct, repo: "hexpm", optional: false]}], "hexpm", "491810806d2c6cce53f0577d96ffdf36dbb104beaba1661cd2950ffc0bbd792c"}, + "floki": {:hex, :floki, "0.35.1", "b21cf592ed38c1207c5ea52120a2e81d6ecba11337a633a3f29ec17a64033178", [:mix], [], "hexpm", "f126e3eb814f131c21befeeeb773d2c4e2331ce05214c1a9844a3edde5c69003"}, "gproc": {:hex, :gproc, "0.9.1", "f1df0364423539cf0b80e8201c8b1839e229e5f9b3ccb944c5834626998f5b8c", [:rebar3], [], "hexpm", "905088e32e72127ed9466f0bac0d8e65704ca5e73ee5a62cb073c3117916d507"}, "grpcbox": {:hex, :grpcbox, "0.17.1", "6e040ab3ef16fe699ffb513b0ef8e2e896da7b18931a1ef817143037c454bcce", [:rebar3], [{:acceptor_pool, "~> 1.0.0", [hex: :acceptor_pool, repo: "hexpm", optional: false]}, {:chatterbox, "~> 0.15.1", [hex: :ts_chatterbox, repo: "hexpm", optional: false]}, {:ctx, "~> 0.6.0", [hex: :ctx, repo: "hexpm", optional: false]}, {:gproc, "~> 0.9.1", [hex: :gproc, repo: "hexpm", optional: false]}], "hexpm", "4a3b5d7111daabc569dc9cbd9b202a3237d81c80bf97212fbc676832cb0ceb17"}, "hpack": {:hex, :hpack_erl, "0.3.0", "2461899cc4ab6a0ef8e970c1661c5fc6a52d3c25580bc6dd204f84ce94669926", [:rebar3], [], "hexpm", "d6137d7079169d8c485c6962dfe261af5b9ef60fbc557344511c1e65e3d95fb0"}, @@ -31,12 +34,14 @@ "nimble_options": {:hex, :nimble_options, "1.0.2", "92098a74df0072ff37d0c12ace58574d26880e522c22801437151a159392270e", [:mix], [], "hexpm", "fd12a8db2021036ce12a309f26f564ec367373265b53e25403f0ee697380f1b8"}, "nimble_parsec": {:hex, :nimble_parsec, "1.3.1", "2c54013ecf170e249e9291ed0a62e5832f70a476c61da16f6aac6dca0189f2af", [:mix], [], "hexpm", "2682e3c0b2eb58d90c6375fc0cc30bc7be06f365bf72608804fb9cffa5e1b167"}, "nimble_pool": {:hex, :nimble_pool, "1.0.0", "5eb82705d138f4dd4423f69ceb19ac667b3b492ae570c9f5c900bb3d2f50a847", [:mix], [], "hexpm", "80be3b882d2d351882256087078e1b1952a28bf98d0a287be87e4a24a710b67a"}, + "nimble_strftime": {:hex, :nimble_strftime, "0.1.1", "b988184d1bd945bc139b2c27dd00a6c0774ec94f6b0b580083abd62d5d07818b", [:mix], [], "hexpm", "89e599c9b8b4d1203b7bb5c79eb51ef7c6a28fbc6228230b312f8b796310d755"}, "opentelemetry": {:hex, :opentelemetry, "1.3.1", "f0a342a74379e3540a634e7047967733da4bc8b873ec9026e224b2bd7369b1fc", [:rebar3], [{:opentelemetry_api, "~> 1.2.2", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}, {:opentelemetry_semantic_conventions, "~> 0.2", [hex: :opentelemetry_semantic_conventions, repo: "hexpm", optional: false]}], "hexpm", "de476b2ac4faad3e3fe3d6e18b35dec9cb338c3b9910c2ce9317836dacad3483"}, "opentelemetry_api": {:hex, :opentelemetry_api, "1.2.2", "693f47b0d8c76da2095fe858204cfd6350c27fe85d00e4b763deecc9588cf27a", [:mix, :rebar3], [{:opentelemetry_semantic_conventions, "~> 0.2", [hex: :opentelemetry_semantic_conventions, repo: "hexpm", optional: false]}], "hexpm", "dc77b9a00f137a858e60a852f14007bb66eda1ffbeb6c05d5fe6c9e678b05e9d"}, "opentelemetry_exporter": {:hex, :opentelemetry_exporter, "1.6.0", "f4fbf69aa9f1541b253813221b82b48a9863bc1570d8ecc517bc510c0d1d3d8c", [:rebar3], [{:grpcbox, ">= 0.0.0", [hex: :grpcbox, repo: "hexpm", optional: false]}, {:opentelemetry, "~> 1.3", [hex: :opentelemetry, repo: "hexpm", optional: false]}, {:opentelemetry_api, "~> 1.2", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}, {:tls_certificate_check, "~> 1.18", [hex: :tls_certificate_check, repo: "hexpm", optional: false]}], "hexpm", "1802d1dca297e46f21e5832ecf843c451121e875f73f04db87355a6cb2ba1710"}, "opentelemetry_process_propagator": {:hex, :opentelemetry_process_propagator, "0.2.2", "85244a49f0c32ae1e2f3d58c477c265bd6125ee3480ade82b0fa9324b85ed3f0", [:mix, :rebar3], [{:opentelemetry_api, "~> 1.0", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}], "hexpm", "04db13302a34bea8350a13ed9d49c22dfd32c4bc590d8aa88b6b4b7e4f346c61"}, "opentelemetry_semantic_conventions": {:hex, :opentelemetry_semantic_conventions, "0.2.0", "b67fe459c2938fcab341cb0951c44860c62347c005ace1b50f8402576f241435", [:mix, :rebar3], [], "hexpm", "d61fa1f5639ee8668d74b527e6806e0503efc55a42db7b5f39939d84c07d6895"}, "opentelemetry_telemetry": {:hex, :opentelemetry_telemetry, "1.0.0", "d5982a319e725fcd2305b306b65c18a86afdcf7d96821473cf0649ff88877615", [:mix, :rebar3], [{:opentelemetry_api, "~> 1.0", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_registry, "~> 0.3.0", [hex: :telemetry_registry, repo: "hexpm", optional: false]}], "hexpm", "3401d13a1d4b7aa941a77e6b3ec074f0ae77f83b5b2206766ce630123a9291a9"}, + "phoenix_html": {:hex, :phoenix_html, "3.3.3", "380b8fb45912b5638d2f1d925a3771b4516b9a78587249cabe394e0a5d579dc9", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "923ebe6fec6e2e3b3e569dfbdc6560de932cd54b000ada0208b5f45024bdd76c"}, "plug": {:hex, :plug, "1.14.2", "cff7d4ec45b4ae176a227acd94a7ab536d9b37b942c8e8fa6dfc0fff98ff4d80", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "842fc50187e13cf4ac3b253d47d9474ed6c296a8732752835ce4a86acdf68d13"}, "plug_cowboy": {:hex, :plug_cowboy, "2.6.1", "9a3bbfceeb65eff5f39dab529e5cd79137ac36e913c02067dba3963a26efe9b2", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "de36e1a21f451a18b790f37765db198075c25875c64834bcc82d90b309eb6613"}, "plug_crypto": {:hex, :plug_crypto, "1.2.5", "918772575e48e81e455818229bf719d4ab4181fcbf7f85b68a35620f78d89ced", [:mix], [], "hexpm", "26549a1d6345e2172eb1c233866756ae44a9609bd33ee6f99147ab3fd87fd842"}, @@ -45,8 +50,17 @@ "schematic": {:hex, :schematic, "0.2.1", "0b091df94146fd15a0a343d1bd179a6c5a58562527746dadd09477311698dbb1", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "0b255d65921e38006138201cd4263fd8bb807d9dfc511074615cd264a571b3b1"}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, "styler": {:hex, :styler, "0.8.1", "f3c0f65023e4bfbf7e7aa752d128b8475fdabfd30f96ee7314b84480cc56e788", [:mix], [], "hexpm", "1aa48d3aa689a639289af3d8254d40e068e98c083d6e5e3d1a695e71a147b344"}, + "table": {:hex, :table, "0.1.2", "87ad1125f5b70c5dea0307aa633194083eb5182ec537efc94e96af08937e14a8", [:mix], [], "hexpm", "7e99bc7efef806315c7e65640724bf165c3061cdc5d854060f74468367065029"}, + "tailwind": {:hex, :tailwind, "0.2.1", "83d8eadbe71a8e8f67861fe7f8d51658ecfb258387123afe4d9dc194eddc36b0", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "e8a13f6107c95f73e58ed1b4221744e1eb5a093cd1da244432067e19c8c9a277"}, "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, "telemetry_registry": {:hex, :telemetry_registry, "0.3.1", "14a3319a7d9027bdbff7ebcacf1a438f5f5c903057b93aee484cca26f05bdcba", [:mix, :rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6d0ca77b691cf854ed074b459a93b87f4c7f5512f8f7743c635ca83da81f939e"}, + "temple": {:hex, :temple, "0.12.0", "b50b806e1f1805219f0cbffc9c747c14f138543977fa6c01e74756c3e0daaa25", [:mix], [{:floki, ">= 0.0.0", [hex: :floki, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.2", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:typed_struct, "~> 0.3", [hex: :typed_struct, repo: "hexpm", optional: false]}], "hexpm", "0d006e850bf21f6684fa0ee52ceeb2f8516bb0213bd003f6d38c66880262f8a8"}, + "thousand_island": {:hex, :thousand_island, "1.0.0", "63fc8807d8607c9d74fa670996897c8c8a1f2022c8c68d024182e45249acd756", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "996320c72ba8f34d7be9b02900622e44341649f24359e0f67643e4dda8f23995"}, "tls_certificate_check": {:hex, :tls_certificate_check, "1.20.0", "1ac0c53f95e201feb8d398ef9d764ae74175231289d89f166ba88a7f50cd8e73", [:rebar3], [{:ssl_verify_fun, "~> 1.1", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "ab57b74b1a63dc5775650699a3ec032ec0065005eff1f020818742b7312a8426"}, + "tucan": {:hex, :tucan, "0.2.1", "442cf0dc083ad7d83f1cb7e1164210ba93daa7c5c9a0f630fa53e79b4d95bec2", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:vega_lite, "~> 0.1.8", [hex: :vega_lite, repo: "hexpm", optional: false]}], "hexpm", "9687361060a16978c8af7f60f8650fd095425f3881c857ecee1d739577a6fd47"}, "typed_struct": {:hex, :typed_struct, "0.3.0", "939789e3c1dca39d7170c87f729127469d1315dcf99fee8e152bb774b17e7ff7", [:mix], [], "hexpm", "c50bd5c3a61fe4e198a8504f939be3d3c85903b382bde4865579bc23111d1b6d"}, + "vega_lite": {:hex, :vega_lite, "0.1.8", "7f6119126ecaf4bc2c1854084370d7091424f5cce4795fbac044eee9963f0752", [:mix], [{:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: false]}], "hexpm", "6c8a9271f850612dd8a90de8d1ebd433590ed07ffef76fc2397c240dc04d3fdc"}, + "web_dev_utils": {:hex, :web_dev_utils, "0.1.0", "0c41285667b4c9944eaa2c3189f22ec3a915c349c4b879b5ae023e8ca0824657", [:mix], [{:file_system, "~> 0.2", [hex: :file_system, repo: "hexpm", optional: false]}], "hexpm", "af82e1723fe76c09d817a9a9baf85160f7ed572cb4af854194d3f7609ae92f10"}, + "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, + "websock_adapter": {:hex, :websock_adapter, "0.5.4", "7af8408e7ed9d56578539594d1ee7d8461e2dd5c3f57b0f2a5352d610ddde757", [: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", "d2c238c79c52cbe223fcdae22ca0bb5007a735b9e933870e241fce66afb4f4ab"}, } diff --git a/priv/elixir-tools-no-background.png b/priv/elixir-tools-no-background.png new file mode 100644 index 00000000..16a03926 Binary files /dev/null and b/priv/elixir-tools-no-background.png differ diff --git a/priv/favicon.ico b/priv/favicon.ico new file mode 100644 index 00000000..15a12930 Binary files /dev/null and b/priv/favicon.ico differ diff --git a/priv/nextls-logo-no-background.png b/priv/nextls-logo-no-background.png new file mode 100644 index 00000000..ceb29687 Binary files /dev/null and b/priv/nextls-logo-no-background.png differ diff --git a/tailwind.config.js b/tailwind.config.js new file mode 120000 index 00000000..0b4987e9 --- /dev/null +++ b/tailwind.config.js @@ -0,0 +1 @@ +assets/tailwind.config.js \ No newline at end of file