diff --git a/CHANGELOG.md b/CHANGELOG.md index 46de98f..d24e8ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +* LiveViewNative.Template.Parser - a new Elixir parser for the LVN template syntax + ### Changed * `LiveViewNative.Component` no longer imports `Phoenix.Component.to_form/2` diff --git a/lib/live_view_native/template/parse_error.ex b/lib/live_view_native/template/parse_error.ex new file mode 100644 index 0000000..2b6ea61 --- /dev/null +++ b/lib/live_view_native/template/parse_error.ex @@ -0,0 +1,44 @@ +defmodule LiveViewNative.Template.ParseError do + @moduledoc """ + + + """ + + defexception [:message] + + @impl true + def exception({document, message, [start: cursor, end: cursor]}) do + msg = """ + #{message} + + #{document_line(document, cursor)} + """ + + %__MODULE__{message: msg} + end + + def exception({document, message, [start: start_cursor, end: end_cursor]}) do + msg = """ + #{message} + + Start: + #{document_line(document, start_cursor)} + + End: + #{document_line(document, end_cursor)} + """ + + %__MODULE__{message: msg} + end + + defp document_line(document, [line: line, column: column]) do + doc_line = + document + |> String.split("\n") + |> Enum.at(line - 1) + + loc = "#{line}: " + + ~s|#{loc}#{doc_line}\n#{String.duplicate(" ", String.length(loc))}#{String.pad_leading("^", column, "-")}| + end +end diff --git a/lib/live_view_native/template/parser.ex b/lib/live_view_native/template/parser.ex index 1e74968..7baa5bd 100644 --- a/lib/live_view_native/template/parser.ex +++ b/lib/live_view_native/template/parser.ex @@ -1,8 +1,76 @@ defmodule LiveViewNative.Template.Parser do + @moduledoc ~S''' + Floki-compliant parser for LiveView Native template syntax + + iex> """ + ...> + ...> Hello + ...> world! + ...> + ...> """ + ...> |> LiveViewNative.Template.Parser.parse_document() + {:ok, [{"Group", [], [{"Text", [{"class", "bold"}], ["Hello"]}, {"Text", [], ["world!"]}]}]} + + You can pass this AST into Floki for querying: + + iex> """ + ...> + ...> Hello" + ...> world! + ...> + ...> """ + ...> |> LiveViewNative.Template.Parser.parse_document!() + ...> |> Floki.find("Text.bold") + [{"Text", [{"class", "bold"}], ["Hello"]}] + + ## Floki Integration + + Floki support passing parser in by option, this parser is compliant with that API: + + iex> """ + ...> + ...> Hello" + ...> world! + ...> + ...> """ + ...> |> Floki.parse_document!(html_parser: LiveViewNative.Template.Parser) + ...> |> Floki.find("Text.bold") + [{"Text", [{"class", "bold"}], ["Hello"]}] + + Or you can configure as the default: + + ```elixir + config :floki, :html_parser, LiveViewNative.Tempalte.Parser + ``` + ''' + + alias LiveViewNative.Template.ParseError + @first_chars ~c"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" @chars ~c"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-" @whitespace ~c"\s\t\n\r" + @doc """ + Parses an LVN document from a string. + + This is the main function to get a tree from a LVN string. + + ## Options + + * `:attributes_as_maps` - Change the behaviour of the parser to return the attributes + as maps, instead of a list of `{"key", "value"}`. Default to `false`. + + ## Examples + + iex> LiveViewNative.Template.Parser.parse_document("hello") + {:ok, [{"Group", [], [{"Text", [], []}, {"Text", [], ["hello"]}]}]} + + iex> LiveViewNative.Template.Parser.parse_document( + ...> ~S(hello), + ...> attributes_as_maps: true + ...>) + {:ok, [{"Group", %{}, [{"Text", %{}, []}, {"Text", %{"class" => "main"}, ["hello"]}]}]} + """ def parse_document(document, args \\ []) do parse(document, [line: 1, column: 1], [], args) |> case do @@ -11,10 +79,22 @@ defmodule LiveViewNative.Template.Parser do end end + @doc """ + Parses a LVN Document from a string. + + Similar to `parse_document/1`, but raises `LiveViewNative.Template.ParseError` if there was an + error parsing the document. + + ## Example + + iex> LiveViewNative.Template.Parser.parse_document!("hello") + [{"Group", [], [{"Text", [], []}, {"Text", [], ["hello"]}]}] + + """ def parse_document!(document, args \\ []) do case parse_document(document, args) do - {:ok, {nodes, _cursor}} -> nodes - {:error, message, _range} -> raise message + {:ok, nodes} -> nodes + {:error, message, range} -> raise ParseError, {document, message, range} end end diff --git a/test/live_view_native/template/parser_test.exs b/test/live_view_native/template/parser_test.exs index 7b9ae3e..05df169 100644 --- a/test/live_view_native/template/parser_test.exs +++ b/test/live_view_native/template/parser_test.exs @@ -2,6 +2,10 @@ defmodule LiveViewNative.Template.ParserTest do use ExUnit.Case, async: false import LiveViewNative.Template.Parser + alias LiveViewNative.Template.ParseError + + doctest LiveViewNative.Template.Parser + test "will parse a tag" do {:ok, nodes} = """ @@ -119,98 +123,126 @@ defmodule LiveViewNative.Template.ParserTest do describe "parsing errors" do test "eof within a comment" do - {:error, _message, [start: start_pos, end: end_pos]} = "