diff --git a/.github/workflows/elixir.yml b/.github/workflows/elixir.yml index 5e7f4cd82..83630bc01 100644 --- a/.github/workflows/elixir.yml +++ b/.github/workflows/elixir.yml @@ -11,20 +11,28 @@ permissions: jobs: test: - name: test (OTP ${{matrix.otp}} | Elixir ${{matrix.elixir}}) - - env: - MIX_ENV: test - MDEX_BUILD: 1 + name: "test: OTP ${{matrix.otp}} | Elixir ${{matrix.elixir}} | Phoenix ${{matrix.phoenix-version}} | LiveView ${{matrix.phoenix-live-view-version}}" strategy: matrix: include: - - elixir: "1.13.0" - otp: "23" + # minimum required versions + - otp: "23" + elixir: "1.13.0" + phoenix-version: "1.7.0" + phoenix-live-view-version: "0.19.0" + + # latest + - otp: "26" + elixir: "1.15" + phoenix-version: "~> 1.7" + phoenix-live-view-version: "~> 0.20" - - elixir: "1.15.1" - otp: "26" + env: + MIX_ENV: test + MDEX_BUILD: 1 + PHOENIX_VERSION: ${{matrix.phoenix-version}} + PHOENIX_LIVE_VIEW_VERSION: ${{matrix.phoenix-live-view-version}} runs-on: ubuntu-20.04 @@ -32,6 +40,7 @@ jobs: postgres: image: postgres:13.1 env: + POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres options: >- --health-cmd pg_isready @@ -48,8 +57,8 @@ jobs: - name: Set up Elixir uses: erlef/setup-beam@v1 with: - elixir-version: ${{ matrix.elixir }} otp-version: ${{ matrix.otp }} + elixir-version: ${{ matrix.elixir }} - name: Cache mix deps uses: actions/cache@v3 @@ -58,29 +67,31 @@ jobs: path: | deps _build - key: mix-${{ env.MIX_ENV }}-${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}-${{ hashFiles('**/mix.lock') }} + key: mix-${{ env.MIX_ENV }}-${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}-${{ matrix.phoenix-version }}-${{ matrix.phoenix-live-view-version }}-${{ hashFiles('**/mix.lock') }} restore-keys: | - mix-${{ env.MIX_ENV }}-${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}- + mix-${{ env.MIX_ENV }}-${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}-${{ matrix.phoenix-version }}-${{ matrix.phoenix-live-view-version }}-${{ hashFiles('**/mix.lock') }} - run: mix do deps.get, deps.compile - if: steps.cache-deps.outputs.cache-hit != 'true' - run: mix tailwind.install - run: mix test quality: - name: quality (OTP ${{matrix.otp}} | Elixir ${{matrix.elixir}}) - - env: - MIX_ENV: dev - MDEX_BUILD: 1 + name: "quality: OTP ${{matrix.otp}} | Elixir ${{matrix.elixir}} | Phoenix ${{matrix.phoenix-version}} | LiveView ${{matrix.phoenix-live-view-version}}" strategy: matrix: include: - - elixir: "1.15.1" - otp: "26" + # latest + - otp: "26" + elixir: "1.15" + phoenix-version: "~> 1.7" + phoenix-live-view-version: "~> 0.20" + + env: + MIX_ENV: dev + MDEX_BUILD: 1 runs-on: ubuntu-20.04 @@ -91,8 +102,8 @@ jobs: - name: Set up Elixir uses: erlef/setup-beam@v1 with: - elixir-version: ${{ matrix.elixir }} otp-version: ${{ matrix.otp }} + elixir-version: ${{ matrix.elixir }} - name: Cache mix deps uses: actions/cache@v3 @@ -101,21 +112,20 @@ jobs: path: | deps _build - key: mix-${{ env.MIX_ENV }}-${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}-${{ hashFiles('**/mix.lock') }} + key: mix-${{ env.MIX_ENV }}-${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}-${{ matrix.phoenix-version }}-${{ matrix.phoenix-live-view-version }}-${{ hashFiles('**/mix.lock') }} restore-keys: | - mix-${{ env.MIX_ENV }}-${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}- + mix-${{ env.MIX_ENV }}-${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}-${{ matrix.phoenix-version }}-${{ matrix.phoenix-live-view-version }}-${{ hashFiles('**/mix.lock') }} - run: mix do deps.get, deps.compile - if: steps.cache-deps.outputs.cache-hit != 'true' - name: Cache dialyzer uses: actions/cache@v2 id: cache-plt with: path: priv/plts - key: dialyzer-${{ env.MIX_ENV }}-${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}-${{ hashFiles('**/mix.lock') }} + key: dialyzer-${{ env.MIX_ENV }}-${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}-${{ matrix.phoenix-version }}-${{ matrix.phoenix-live-view-version }}-${{ hashFiles('**/mix.lock') }} restore-keys: | - dialyzer-${{ env.MIX_ENV }}-${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}- + dialyzer-${{ env.MIX_ENV }}-${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}-${{ matrix.phoenix-version }}-${{ matrix.phoenix-live-view-version }}-${{ hashFiles('**/mix.lock') }} - name: Generate dialyzer plt run: mix dialyzer --plt @@ -123,7 +133,7 @@ jobs: - run: mix tailwind.install - # - run: mix compile --warnings-as-errors + - run: mix compile --warnings-as-errors - run: mix format --check-formatted @@ -131,4 +141,4 @@ jobs: - run: mix credo --strict - - run: mix dialyzer + - run: mix dialyzer --format github diff --git a/lib/beacon/content.ex b/lib/beacon/content.ex index cb5120cbd..9d2eb10b9 100644 --- a/lib/beacon/content.ex +++ b/lib/beacon/content.ex @@ -1770,6 +1770,27 @@ defmodule Beacon.Content do |> Repo.all() end + @doc type: :error_pages + @spec list_error_pages_by(Site.t(), keyword(), keyword()) :: Layout.t() | nil + def list_error_pages_by(site, clauses, opts \\ []) when is_atom(site) and is_list(clauses) do + per_page = Keyword.get(opts, :per_page, 20) + preloads = Keyword.get(opts, :preloads, []) + + filter_layout_id = + if layout_id = clauses[:layout_id] do + dynamic([ep], ep.layout_id == ^layout_id) + else + true + end + + site + |> query_list_error_pages_base() + |> query_list_error_pages_limit(per_page) + |> query_list_error_pages_preloads(preloads) + |> where(^filter_layout_id) + |> Repo.all() + end + defp query_list_error_pages_base(site) do from p in ErrorPage, where: p.site == ^site, diff --git a/lib/beacon/content/page.ex b/lib/beacon/content/page.ex index 3bba23791..9ec8f7b63 100644 --- a/lib/beacon/content/page.ex +++ b/lib/beacon/content/page.ex @@ -36,7 +36,7 @@ defmodule Beacon.Content.Page do field :description, :string field :template, :string field :meta_tags, {:array, :map}, default: [] - field :raw_schema, {:array, :map}, default: [] + field :raw_schema, Beacon.Types.JsonArrayMap, default: [] field :order, :integer, default: 1 field :format, Beacon.Types.Atom, default: :heex field :extra, :map, default: %{} @@ -112,6 +112,7 @@ defmodule Beacon.Content.Page do :title, :description, :meta_tags, + :raw_schema, :format ]) |> cast(attrs, [:path], empty_values: []) @@ -122,7 +123,6 @@ defmodule Beacon.Content.Page do :format ]) |> validate_string([:path]) - |> validate_raw_schema(attrs["raw_schema"]) |> remove_all_newlines([:description]) |> remove_empty_meta_attributes(:meta_tags) |> Content.PageField.apply_changesets(page.site, extra_attrs) @@ -173,13 +173,4 @@ defmodule Beacon.Content.Page do |> Enum.reject(fn {_key, value} -> is_nil(value) || String.trim(value) == "" end) |> Map.new() end - - defp validate_raw_schema(changeset, raw_schema) do - raw_schema = if raw_schema in ["", nil], do: "[]", else: raw_schema - - case Jason.decode(raw_schema) do - {:ok, raw_schema} -> put_change(changeset, :raw_schema, raw_schema) - {:error, _} -> add_error(changeset, :raw_schema, "invalid schema") - end - end end diff --git a/lib/beacon/loader.ex b/lib/beacon/loader.ex index 008f47a0d..aa8469f5c 100644 --- a/lib/beacon/loader.ex +++ b/lib/beacon/loader.ex @@ -171,7 +171,7 @@ defmodule Beacon.Loader do end @doc false - def reload_module!(module, ast, file \\ "nofile") do + def reload_module!(module, ast, file \\ "nofile", failure_count \\ 0) do :code.delete(module) :code.purge(module) [{^module, _}] = Code.compile_quoted(ast, file) @@ -179,16 +179,24 @@ defmodule Beacon.Loader do :ok rescue e -> - message = """ - failed to load module #{inspect(module)} + if failure_count >= 3 do + Logger.debug("failed to load module #{inspect(module)} after #{failure_count} tries.") - Got: + message = """ + failed to load module #{inspect(module)} - #{Exception.message(e)}"], + Got: - """ + #{Exception.message(e)} + + """ - reraise Beacon.LoaderError, [message: message], __STACKTRACE__ + reraise Beacon.LoaderError, [message: message], __STACKTRACE__ + else + Logger.debug("failed to load module #{inspect(module)}, retrying...") + :timer.sleep(100 * (failure_count * 2)) + reload_module!(module, ast, file, failure_count + 1) + end end # too slow to run the css compiler on every test @@ -307,14 +315,12 @@ defmodule Beacon.Loader do e in UndefinedFunctionError -> case {failure_count, e} do {x, _} when x >= 10 -> - Logger.debug("failed to call #{inspect(module)} #{inspect(function)} 10 times.") + Logger.debug("failed to call #{inspect(module)} #{inspect(function)} after #{failure_count} tries.") reraise e, __STACKTRACE__ {_, %UndefinedFunctionError{function: ^function, module: ^module}} -> - Logger.debug("failed to call #{inspect(module)} #{inspect(function)} with #{inspect(args)} for the #{failure_count + 1} time. Retrying.") - + Logger.debug("failed to call #{inspect(module)} #{inspect(function)} with #{inspect(args)} for the #{failure_count + 1} time, retrying...") :timer.sleep(100 * (failure_count * 2)) - call_function_with_retry(module, function, args, failure_count + 1) _ -> @@ -392,7 +398,7 @@ defmodule Beacon.Loader do :ok <- load_snippet_helpers(site), :ok <- load_stylesheets(site), {:ok, _module, _ast} <- Beacon.Loader.LayoutModuleLoader.load_layout!(layout), - :ok <- load_error_pages(site) do + :ok <- maybe_reload_error_pages(layout) do :ok else _ -> raise Beacon.LoaderError, message: "failed to load resources for layout #{layout.title} of site #{layout.site}" @@ -480,6 +486,13 @@ defmodule Beacon.Loader do end end + # we need to reload error pages bacause the layout is embeeded into those pages + defp maybe_reload_error_pages(layout) do + error_pages = Content.list_error_pages_by(layout.site, [layout_id: layout.id], per_page: :infinity, preloads: [:layout]) + ErrorPageModuleLoader.load_error_pages!(error_pages, layout.site) + :ok + end + @doc false # https://github.com/phoenixframework/phoenix_live_view/blob/8fedc6927fd937fe381553715e723754b3596a97/lib/phoenix_live_view/channel.ex#L435-L437 def exported?(m, f, a) do diff --git a/lib/beacon/loader/error_page_module_loader.ex b/lib/beacon/loader/error_page_module_loader.ex index 009d4cde4..4f224c8f0 100644 --- a/lib/beacon/loader/error_page_module_loader.ex +++ b/lib/beacon/loader/error_page_module_loader.ex @@ -4,6 +4,8 @@ defmodule Beacon.Loader.ErrorPageModuleLoader do alias Beacon.Content.ErrorPage alias Beacon.Loader + def load_error_pages!([] = _error_pages, _site), do: :skip + def load_error_pages!(error_pages, site) do error_module = Loader.error_module_for_site(site) layout_functions = Enum.map(error_pages, &build_layout_fn/1) diff --git a/lib/beacon/pub_sub.ex b/lib/beacon/pub_sub.ex index cb207f7ab..a201935fd 100644 --- a/lib/beacon/pub_sub.ex +++ b/lib/beacon/pub_sub.ex @@ -4,7 +4,6 @@ defmodule Beacon.PubSub do require Logger alias Beacon.Content.Component alias Beacon.Content.ErrorPage - alias Beacon.Content.ErrorPage alias Beacon.Content.Layout alias Beacon.Content.LiveData alias Beacon.Content.Page diff --git a/lib/beacon/template/heex/json_encoder.ex b/lib/beacon/template/heex/json_encoder.ex index 893597010..ef6a1abf8 100644 --- a/lib/beacon/template/heex/json_encoder.ex +++ b/lib/beacon/template/heex/json_encoder.ex @@ -78,7 +78,13 @@ defmodule Beacon.Template.HEEx.JSONEncoder do """ @spec encode(Beacon.Types.Site.t(), String.t(), map()) :: {:ok, [token()]} | {:error, String.t()} - def encode(site, template, assigns \\ %{}) when is_atom(site) and is_binary(template) and is_map(assigns) do + def encode(site, template, assigns \\ %{}) + + def encode(site, nil = _template, assigns) when is_atom(site) and is_map(assigns) do + encode(site, "", assigns) + end + + def encode(site, template, assigns) when is_atom(site) and is_binary(template) and is_map(assigns) do case Beacon.Template.HEEx.Tokenizer.tokenize(template) do {:ok, tokens} -> {:ok, encode_tokens(tokens, site, assigns)} error -> error diff --git a/lib/beacon/template/heex/lv_tokenizer.ex b/lib/beacon/template/heex/lv_tokenizer.ex new file mode 100644 index 000000000..b2ebaf93a --- /dev/null +++ b/lib/beacon/template/heex/lv_tokenizer.ex @@ -0,0 +1,717 @@ +# DO NOT CHANGE THIS FILE +# It's a copy from https://github.com/phoenixframework/phoenix_live_view/blob/fb111738d56745f37338867b9faea86eb9baa6e1/lib/phoenix_live_view/tokenizer.ex + +defmodule Beacon.Template.HEEx.LVTokenizer do + @moduledoc false + @space_chars ~c"\s\t\f" + @quote_chars ~c"\"'" + @stop_chars ~c">/=\r\n" ++ @quote_chars ++ @space_chars + + defmodule ParseError do + @moduledoc false + defexception [:file, :line, :column, :description] + + @impl true + def message(exception) do + location = + exception.file + |> Path.relative_to_cwd() + |> Exception.format_file_line_column(exception.line, exception.column) + + "#{location} #{exception.description}" + end + + def code_snippet(source, meta, indentation \\ 0) do + line_start = max(meta.line - 3, 1) + line_end = meta.line + digits = line_end |> Integer.to_string() |> byte_size() + number_padding = String.duplicate(" ", digits) + indentation = String.duplicate(" ", indentation) + + source + |> String.split(["\r\n", "\n"]) + |> Enum.slice((line_start - 1)..(line_end - 1)) + |> Enum.map_reduce(line_start, fn + expr, line_number when line_number == line_end -> + arrow = String.duplicate(" ", meta.column - 1) <> "^" + acc = "#{line_number} | #{indentation}#{expr}\n #{number_padding}| #{arrow}" + {acc, line_number + 1} + + expr, line_number -> + line_number_padding = String.pad_leading("#{line_number}", digits) + {"#{line_number_padding} | #{indentation}#{expr}", line_number + 1} + end) + |> case do + {[], _} -> + "" + + {snippet, _} -> + Enum.join(["\n #{number_padding}|" | snippet], "\n") + end + end + end + + def finalize(_tokens, file, {:comment, line, column}, source) do + message = "expected closing `-->` for comment" + meta = %{line: line, column: column} + raise_syntax_error!(message, meta, %{source: source, file: file, indentation: 0}) + end + + def finalize(tokens, _file, _cont, _source) do + tokens + |> strip_text_token_fully() + |> Enum.reverse() + |> strip_text_token_fully() + end + + @doc """ + Initiate the Tokenizer state. + + ### Params + + * `indentation` - An integer that indicates the current indentation. + * `file` - Can be either a file or a string "nofile". + * `source` - The contents of the file as binary used to be tokenized. + * `tag_handler` - Tag handler to classify the tags. See `Phoenex.LiveView.TagHandler` + behavivour. + """ + def init(indentation, file, source, tag_handler) do + %{ + file: file, + column_offset: indentation + 1, + braces: [], + context: [], + source: source, + indentation: indentation, + tag_handler: tag_handler + } + end + + @doc """ + Tokenize the given text according to the given params. + + ### Params + + * `text` - The content to be tokenized. + * `meta` - A keyword list with `:line` and `:column`. Both must be integers. + * `tokens` - A list of tokens. + * `cont` - An atom that is `:text`, `:style`, or `:script`, or a tuple + {:comment, line, column}. + * `state` - The tokenizer state that must be initiated by `Tokenizer.init/3` + + ### Examples + + iex> alias Phoenix.LiveView.Tokenizer + iex> state = Tokenizer.init(text: "
", cont: :text) + iex> Tokenizer.tokenize(state) + {[ + {:close, :tag, "section", %{column: 16, line: 1}}, + {:tag, "div", [], %{column: 10, line: 1, self_close: true}}, + {:tag, "section", [], %{column: 1, line: 1}} + ], :text} + """ + def tokenize(text, meta, tokens, cont, state) do + line = Keyword.get(meta, :line, 1) + column = Keyword.get(meta, :column, 1) + + case cont do + :text -> handle_text(text, line, column, [], tokens, state) + :style -> handle_style(text, line, column, [], tokens, state) + :script -> handle_script(text, line, column, [], tokens, state) + {:comment, _, _} -> handle_comment(text, line, column, [], tokens, state) + end + end + + ## handle_text + + defp handle_text("\r\n" <> rest, line, _column, buffer, acc, state) do + handle_text(rest, line + 1, state.column_offset, ["\r\n" | buffer], acc, state) + end + + defp handle_text("\n" <> rest, line, _column, buffer, acc, state) do + handle_text(rest, line + 1, state.column_offset, ["\n" | buffer], acc, state) + end + + defp handle_text(" rest, line, column, buffer, acc, state) do + handle_doctype(rest, line, column + 9, [" rest, line, column, buffer, acc, state) do + handle_doctype(rest, line, column + 9, [" rest, line, column, buffer, acc, state) do + state = update_in(state.context, &[:comment_start | &1]) + handle_comment(rest, line, column + 4, ["" <> rest, line, column, buffer, _state) do + {:text, rest, line, column + 3, ["-->" | buffer]} + end + + defp handle_comment(<>, line, column, buffer, state) do + handle_comment(rest, line, column + 1, [char_or_bin(c) | buffer], state) + end + + defp handle_comment(<<>>, line, column, buffer, _state) do + {:ok, line, column, buffer} + end + + ## handle_tag_open + + defp handle_tag_open(text, line, column, acc, state) do + case handle_tag_name(text, column, []) do + {:ok, name, new_column, rest} -> + meta = %{line: line, column: column - 1, inner_location: nil, tag_name: name} + + case state.tag_handler.classify_type(name) do + {:error, message} -> + raise_syntax_error!(message, meta, state) + + {type, name} -> + acc = [{type, name, [], meta} | acc] + handle_maybe_tag_open_end(rest, line, new_column, acc, state) + end + + :error -> + message = + "expected tag name after <. If you meant to use < as part of a text, use < instead" + + meta = %{line: line, column: column} + + raise_syntax_error!(message, meta, state) + end + end + + ## handle_tag_close + + defp handle_tag_close(text, line, column, acc, state) do + case handle_tag_name(text, column, []) do + {:ok, name, new_column, ">" <> rest} -> + meta = %{ + line: line, + column: column - 2, + inner_location: {line, column - 2}, + tag_name: name + } + + case state.tag_handler.classify_type(name) do + {:error, message} -> + raise_syntax_error!(message, meta, state) + + {type, name} -> + acc = [{:close, type, name, meta} | acc] + handle_text(rest, line, new_column + 1, [], acc, state) + end + + {:ok, _, new_column, _} -> + message = "expected closing `>`" + meta = %{line: line, column: new_column} + raise_syntax_error!(message, meta, state) + + :error -> + message = "expected tag name after > = text, column, buffer) + when c in @stop_chars do + done_tag_name(text, column, buffer) + end + + defp handle_tag_name(<>, column, buffer) do + handle_tag_name(rest, column + 1, [char_or_bin(c) | buffer]) + end + + defp handle_tag_name(<<>>, column, buffer) do + done_tag_name(<<>>, column, buffer) + end + + defp done_tag_name(_text, _column, []) do + :error + end + + defp done_tag_name(text, column, buffer) do + {:ok, buffer_to_string(buffer), column, text} + end + + ## handle_maybe_tag_open_end + + defp handle_maybe_tag_open_end("\r\n" <> rest, line, _column, acc, state) do + handle_maybe_tag_open_end(rest, line + 1, state.column_offset, acc, state) + end + + defp handle_maybe_tag_open_end("\n" <> rest, line, _column, acc, state) do + handle_maybe_tag_open_end(rest, line + 1, state.column_offset, acc, state) + end + + defp handle_maybe_tag_open_end(<>, line, column, acc, state) + when c in @space_chars do + handle_maybe_tag_open_end(rest, line, column + 1, acc, state) + end + + defp handle_maybe_tag_open_end("/>" <> rest, line, column, acc, state) do + acc = reverse_attrs(acc, line, column + 2) + handle_text(rest, line, column + 2, [], put_self_close(acc), state) + end + + defp handle_maybe_tag_open_end(">" <> rest, line, column, acc, state) do + case reverse_attrs(acc, line, column + 1) do + [{:tag, "script", _, _} | _] = acc -> + handle_script(rest, line, column + 1, [], acc, state) + + [{:tag, "style", _, _} | _] = acc -> + handle_style(rest, line, column + 1, [], acc, state) + + acc -> + handle_text(rest, line, column + 1, [], acc, state) + end + end + + defp handle_maybe_tag_open_end("{" <> rest, line, column, acc, state) do + handle_root_attribute(rest, line, column + 1, acc, state) + end + + defp handle_maybe_tag_open_end(<<>>, line, column, _acc, state) do + message = ~S""" + expected closing `>` or `/>` + + Make sure the tag is properly closed. This may happen if there + is an EEx interpolation inside a tag, which is not supported. + For instance, instead of + +
Content
+ + do + +
Content
+ + If @id is nil or false, then no attribute is sent at all. + + Inside {...} you can place any Elixir expression. If you want + to interpolate in the middle of an attribute value, instead of + + Text + + you can pass an Elixir string with interpolation: + + Text + """ + + raise ParseError, file: state.file, line: line, column: column, description: message + end + + defp handle_maybe_tag_open_end(text, line, column, acc, state) do + handle_attribute(text, line, column, acc, state) + end + + ## handle_attribute + + defp handle_attribute(text, line, column, acc, state) do + case handle_attr_name(text, column, []) do + {:ok, name, new_column, rest} -> + acc = put_attr(acc, name, %{line: line, column: column}) + handle_maybe_attr_value(rest, line, new_column, acc, state) + + {:error, message, column} -> + meta = %{line: line, column: column} + raise_syntax_error!(message, meta, state) + end + end + + ## handle_root_attribute + + defp handle_root_attribute(text, line, column, acc, state) do + case handle_interpolation(text, line, column, [], state) do + {:ok, value, new_line, new_column, rest, state} -> + meta = %{line: line, column: column} + acc = put_attr(acc, :root, meta, {:expr, value, meta}) + handle_maybe_tag_open_end(rest, new_line, new_column, acc, state) + + {:error, message} -> + # We do column - 1 to point to the opening { + meta = %{line: line, column: column - 1} + raise_syntax_error!(message, meta, state) + end + end + + ## handle_attr_name + + defp handle_attr_name(<>, column, _buffer) + when c in @quote_chars do + {:error, "invalid character in attribute name: #{<>}", column} + end + + defp handle_attr_name(<>, column, []) + when c in @stop_chars do + {:error, "expected attribute name", column} + end + + defp handle_attr_name(<> = text, column, buffer) + when c in @stop_chars do + {:ok, buffer_to_string(buffer), column, text} + end + + defp handle_attr_name(<>, column, buffer) do + handle_attr_name(rest, column + 1, [char_or_bin(c) | buffer]) + end + + ## handle_maybe_attr_value + + defp handle_maybe_attr_value("\r\n" <> rest, line, _column, acc, state) do + handle_maybe_attr_value(rest, line + 1, state.column_offset, acc, state) + end + + defp handle_maybe_attr_value("\n" <> rest, line, _column, acc, state) do + handle_maybe_attr_value(rest, line + 1, state.column_offset, acc, state) + end + + defp handle_maybe_attr_value(<>, line, column, acc, state) + when c in @space_chars do + handle_maybe_attr_value(rest, line, column + 1, acc, state) + end + + defp handle_maybe_attr_value("=" <> rest, line, column, acc, state) do + handle_attr_value_begin(rest, line, column + 1, acc, state) + end + + defp handle_maybe_attr_value(text, line, column, acc, state) do + handle_maybe_tag_open_end(text, line, column, acc, state) + end + + ## handle_attr_value_begin + + defp handle_attr_value_begin("\r\n" <> rest, line, _column, acc, state) do + handle_attr_value_begin(rest, line + 1, state.column_offset, acc, state) + end + + defp handle_attr_value_begin("\n" <> rest, line, _column, acc, state) do + handle_attr_value_begin(rest, line + 1, state.column_offset, acc, state) + end + + defp handle_attr_value_begin(<>, line, column, acc, state) + when c in @space_chars do + handle_attr_value_begin(rest, line, column + 1, acc, state) + end + + defp handle_attr_value_begin("\"" <> rest, line, column, acc, state) do + handle_attr_value_quote(rest, ?", line, column + 1, [], acc, state) + end + + defp handle_attr_value_begin("'" <> rest, line, column, acc, state) do + handle_attr_value_quote(rest, ?', line, column + 1, [], acc, state) + end + + defp handle_attr_value_begin("{" <> rest, line, column, acc, state) do + handle_attr_value_as_expr(rest, line, column + 1, acc, state) + end + + defp handle_attr_value_begin(_text, line, column, _acc, state) do + message = + "invalid attribute value after `=`. Expected either a value between quotes " <> + "(such as \"value\" or \'value\') or an Elixir expression between curly brackets (such as `{expr}`)" + + meta = %{line: line, column: column} + raise_syntax_error!(message, meta, state) + end + + ## handle_attr_value_quote + + defp handle_attr_value_quote("\r\n" <> rest, delim, line, _column, buffer, acc, state) do + column = state.column_offset + handle_attr_value_quote(rest, delim, line + 1, column, ["\r\n" | buffer], acc, state) + end + + defp handle_attr_value_quote("\n" <> rest, delim, line, _column, buffer, acc, state) do + column = state.column_offset + handle_attr_value_quote(rest, delim, line + 1, column, ["\n" | buffer], acc, state) + end + + defp handle_attr_value_quote(<>, delim, line, column, buffer, acc, state) do + value = buffer_to_string(buffer) + acc = put_attr_value(acc, {:string, value, %{delimiter: delim}}) + handle_maybe_tag_open_end(rest, line, column + 1, acc, state) + end + + defp handle_attr_value_quote(<>, delim, line, column, buffer, acc, state) do + handle_attr_value_quote(rest, delim, line, column + 1, [char_or_bin(c) | buffer], acc, state) + end + + defp handle_attr_value_quote(<<>>, delim, line, column, _buffer, _acc, state) do + message = """ + expected closing `#{<>}` for attribute value + + Make sure the attribute is properly closed. This may also happen if + there is an EEx interpolation inside a tag, which is not supported. + Instead of + +
> +
+ + do + +
+
+ + Where @some_attributes must be a keyword list or a map. + """ + + meta = %{line: line, column: column} + raise_syntax_error!(message, meta, state) + end + + ## handle_attr_value_as_expr + + defp handle_attr_value_as_expr(text, line, column, acc, %{braces: []} = state) do + case handle_interpolation(text, line, column, [], state) do + {:ok, value, new_line, new_column, rest, state} -> + acc = put_attr_value(acc, {:expr, value, %{line: line, column: column}}) + handle_maybe_tag_open_end(rest, new_line, new_column, acc, state) + + {:error, message} -> + # We do column - 1 to point to the opening { + meta = %{line: line, column: column - 1} + raise_syntax_error!(message, meta, state) + end + end + + ## handle_interpolation + + defp handle_interpolation("\r\n" <> rest, line, _column, buffer, state) do + handle_interpolation(rest, line + 1, state.column_offset, ["\r\n" | buffer], state) + end + + defp handle_interpolation("\n" <> rest, line, _column, buffer, state) do + handle_interpolation(rest, line + 1, state.column_offset, ["\n" | buffer], state) + end + + defp handle_interpolation("}" <> rest, line, column, buffer, %{braces: []} = state) do + value = buffer_to_string(buffer) + {:ok, value, line, column + 1, rest, state} + end + + defp handle_interpolation(~S(\}) <> rest, line, column, buffer, state) do + handle_interpolation(rest, line, column + 2, [~S(\}) | buffer], state) + end + + defp handle_interpolation(~S(\{) <> rest, line, column, buffer, state) do + handle_interpolation(rest, line, column + 2, [~S(\{) | buffer], state) + end + + defp handle_interpolation("}" <> rest, line, column, buffer, state) do + {_pos, state} = pop_brace(state) + handle_interpolation(rest, line, column + 1, ["}" | buffer], state) + end + + defp handle_interpolation("{" <> rest, line, column, buffer, state) do + state = push_brace(state, {line, column}) + handle_interpolation(rest, line, column + 1, ["{" | buffer], state) + end + + defp handle_interpolation(<>, line, column, buffer, state) do + handle_interpolation(rest, line, column + 1, [char_or_bin(c) | buffer], state) + end + + defp handle_interpolation(<<>>, _line, _column, _buffer, _state) do + {:error, "expected closing `}` for expression"} + end + + ## helpers + + @compile {:inline, ok: 2, char_or_bin: 1} + defp ok(acc, cont), do: {acc, cont} + + defp char_or_bin(c) when c <= 127, do: c + defp char_or_bin(c), do: <> + + defp buffer_to_string(buffer) do + IO.iodata_to_binary(Enum.reverse(buffer)) + end + + defp text_to_acc(buffer, acc, line, column, context) + + defp text_to_acc([], acc, _line, _column, _context), + do: acc + + defp text_to_acc(buffer, acc, line, column, context) do + meta = %{line_end: line, column_end: column} + + meta = + if context = get_context(context) do + Map.put(meta, :context, trim_context(context)) + else + meta + end + + [{:text, buffer_to_string(buffer), meta} | acc] + end + + defp trim_context([:comment_start, :comment_end | [_ | _] = rest]), do: trim_context(rest) + defp trim_context(rest), do: rest + + defp get_context([]), do: nil + defp get_context(context), do: Enum.reverse(context) + + defp put_attr([{type, name, attrs, meta} | acc], attr, attr_meta, value \\ nil) do + attrs = [{attr, value, attr_meta} | attrs] + [{type, name, attrs, meta} | acc] + end + + defp put_attr_value([{type, name, [{attr, _value, attr_meta} | attrs], meta} | acc], value) do + attrs = [{attr, value, attr_meta} | attrs] + [{type, name, attrs, meta} | acc] + end + + defp reverse_attrs([{type, name, attrs, meta} | acc], line, column) do + attrs = Enum.reverse(attrs) + meta = %{meta | inner_location: {line, column}} + [{type, name, attrs, meta} | acc] + end + + defp put_self_close([{type, name, attrs, meta} | acc]) do + meta = Map.put(meta, :self_close, true) + [{type, name, attrs, meta} | acc] + end + + defp push_brace(state, pos) do + %{state | braces: [pos | state.braces]} + end + + defp pop_brace(%{braces: [pos | braces]} = state) do + {pos, %{state | braces: braces}} + end + + defp strip_text_token_fully(tokens) do + with [{:text, text, _} | rest] <- tokens, + "" <- String.trim_leading(text) do + strip_text_token_fully(rest) + else + _ -> tokens + end + end + + defp raise_syntax_error!(message, meta, state) do + raise ParseError, + file: state.file, + line: meta.line, + column: meta.column, + description: message <> ParseError.code_snippet(state.source, meta, state.indentation) + end +end diff --git a/lib/beacon/template/heex/tokenizer.ex b/lib/beacon/template/heex/tokenizer.ex index 0813c41ed..9710df215 100644 --- a/lib/beacon/template/heex/tokenizer.ex +++ b/lib/beacon/template/heex/tokenizer.ex @@ -1,10 +1,10 @@ # DO NOT CHANGE THIS FILE -# It a copy from https://github.com/phoenixframework/phoenix_live_view/blob/d0e46f5430d113269b8903a8b45b025d77532429/lib/phoenix_live_view/html_formatter.ex - -# Generates a nested list of token for a given HEEx template +# It's a copy from https://github.com/phoenixframework/phoenix_live_view/blob/d0e46f5430d113269b8903a8b45b025d77532429/lib/phoenix_live_view/html_formatter.ex defmodule Beacon.Template.HEEx.Tokenizer do - alias Phoenix.LiveView.Tokenizer + # alias Phoenix.LiveView.Tokenizer + # TODO: use LiveView 0.20+ tokenizer + alias Beacon.Template.HEEx.LVTokenizer, as: Tokenizer defguard is_tag_open(tag_type) when tag_type in [:slot, :remote_component, :local_component, :tag] diff --git a/lib/beacon/types/atom.ex b/lib/beacon/types/atom.ex index 0d2004896..eaa608aca 100644 --- a/lib/beacon/types/atom.ex +++ b/lib/beacon/types/atom.ex @@ -7,17 +7,16 @@ defmodule Beacon.Types.Atom do def type, do: :atom - def cast(:any, site) when is_binary(site), do: {:ok, String.to_existing_atom(site)} - def cast(:any, site) when is_atom(site), do: {:ok, site} - def cast(:any, _), do: :error - def cast(site) when is_binary(site), do: {:ok, String.to_existing_atom(site)} def cast(site) when is_atom(site), do: {:ok, site} - def cast(_), do: :error - - def load(site) when is_binary(site), do: {:ok, String.to_existing_atom(site)} + def cast(site), do: {:error, message: "invalid site #{inspect(site)}"} def dump(site) when is_binary(site), do: {:ok, site} def dump(site) when is_atom(site), do: {:ok, Atom.to_string(site)} - def dump(_), do: :error + def dump(_site), do: :error + + def equal?(site1, site2), do: site1 === site2 + + def load(site) when is_binary(site), do: {:ok, String.to_existing_atom(site)} + def load(_site), do: :error end diff --git a/lib/beacon/types/binary.ex b/lib/beacon/types/binary.ex index 7bbaeae41..8a00cbf78 100644 --- a/lib/beacon/types/binary.ex +++ b/lib/beacon/types/binary.ex @@ -9,14 +9,12 @@ defmodule Beacon.Types.Binary do def type, do: :binary - def cast(:any, term), do: {:ok, term} - def cast(term), do: {:ok, term} + def cast(term) when is_binary(term), do: {:ok, term} + def cast(term), do: {:ok, :erlang.term_to_binary(term)} - def load(binary) when is_binary(binary) do - {:ok, :erlang.binary_to_term(binary)} - end + def dump(term) when is_binary(term), do: {:ok, term} + def dump(term), do: {:ok, :erlang.term_to_binary(term)} - def dump(term) do - {:ok, :erlang.term_to_binary(term)} - end + def load(binary) when is_binary(binary), do: {:ok, :erlang.binary_to_term(binary)} + def load(_binary), do: :error end diff --git a/lib/beacon/types/json_array_map.ex b/lib/beacon/types/json_array_map.ex new file mode 100644 index 000000000..0c10f0bc7 --- /dev/null +++ b/lib/beacon/types/json_array_map.ex @@ -0,0 +1,92 @@ +defmodule Beacon.Types.JsonArrayMap do + @moduledoc """ + Convert between json and map enforcing the data shape as array of objects/maps. + """ + + use Ecto.Type + + def type, do: {:array, :map} + + def cast(term) when is_map(term), do: {:ok, [term]} + + def cast(term) when is_list(term) do + case validate(term) do + {true, list} -> + {:ok, list} + + {false, list} -> + {:error, message: "expected a list of map or a map, got: #{inspect(list)}"} + end + end + + def cast(term) when is_binary(term) do + case decode(term) do + {:ok, term} -> cast(term) + {:error, message} -> {:error, message: message} + end + end + + def cast(term) do + {:error, message: "expected a list of map or a map, got: #{inspect(term)}"} + end + + def dump(term) when is_map(term), do: {:ok, [term]} + + def dump(term) when is_list(term) do + case validate(term) do + {true, list} -> + {:ok, list} + + {false, _list} -> + :error + end + end + + def dump(term) when is_binary(term) do + case decode(term) do + {:ok, term} -> dump(term) + {:error, _message} -> :error + end + end + + def dump(_site), do: :error + + def load(term) when is_map(term), do: {:ok, [term]} + + def load(term) when is_list(term), do: {:ok, term} + + def load(term) when is_binary(term) do + case decode(term) do + {:ok, term} -> load(term) + {:error, _message} -> :error + end + end + + def load(_term), do: :error + + defp validate(term) when is_list(term) do + {valid, list} = + Enum.reduce_while(term, {true, []}, fn + t, {_valid, list} when is_map(t) -> + {:cont, {true, [t | list]}} + + t, {_, list} -> + {:halt, {false, [t | list]}} + end) + + {valid, Enum.reverse(list)} + end + + defp validate(term), do: {false, term} + + defp decode(term) when is_binary(term) do + case Jason.decode(term) do + {:ok, term} -> + {:ok, term} + + {:error, error} -> + message = Exception.message(error) + {:error, "expected a list of map or a map, got error: #{message}"} + end + end +end diff --git a/lib/beacon/types/site.ex b/lib/beacon/types/site.ex index f3b315cb6..3175de36a 100644 --- a/lib/beacon/types/site.ex +++ b/lib/beacon/types/site.ex @@ -43,21 +43,17 @@ defmodule Beacon.Types.Site do @doc false def type, do: :atom - @doc false - def cast(:any, site) when is_binary(site), do: {:ok, String.to_existing_atom(site)} - def cast(:any, site) when is_atom(site), do: {:ok, site} - def cast(:any, _), do: :error - @doc false def cast(site) when is_binary(site), do: {:ok, String.to_existing_atom(site)} def cast(site) when is_atom(site), do: {:ok, site} - def cast(_), do: :error - - @doc false - def load(site) when is_binary(site), do: {:ok, String.to_existing_atom(site)} + def cast(site), do: {:error, message: "invalid site #{inspect(site)}"} @doc false def dump(site) when is_binary(site), do: {:ok, site} def dump(site) when is_atom(site), do: {:ok, Atom.to_string(site)} - def dump(_), do: :error + def dump(_site), do: :error + + @doc false + def load(site) when is_binary(site), do: {:ok, String.to_existing_atom(site)} + def load(_site), do: :error end diff --git a/lib/beacon_web/components/components.ex b/lib/beacon_web/components/components.ex index c2f3def97..2998b681b 100644 --- a/lib/beacon_web/components/components.ex +++ b/lib/beacon_web/components/components.ex @@ -37,14 +37,14 @@ defmodule BeaconWeb.Components do ## Examples - + """ attr :class, :string, default: nil attr :sizes, :string, default: nil - attr :rest, :global attr :sources, :list, default: [], doc: "a list of usage_tags" attr :asset, :map, required: true, doc: "a MediaLibrary.Asset struct" + attr :rest, :global def image_set(assigns) do assigns = diff --git a/mix.exs b/mix.exs index 9f5ef3c7f..d7cb17509 100644 --- a/mix.exs +++ b/mix.exs @@ -51,10 +51,10 @@ defmodule Beacon.MixProject do {:image, "~> 0.32"}, {:jason, "~> 1.0"}, {:solid, "~> 0.14"}, - {:phoenix, "~> 1.7"}, + phoenix_dep(), {:phoenix_ecto, "~> 4.4"}, {:phoenix_live_reload, "~> 1.3", only: :dev}, - {:phoenix_live_view, "~> 0.19"}, + phoenix_live_view_dep(), {:phoenix_pubsub, "~> 2.1"}, {:phoenix_view, "~> 2.0", only: [:dev, :test]}, {:plug_cowboy, "~> 2.6", only: [:dev, :test]}, @@ -67,19 +67,33 @@ defmodule Beacon.MixProject do ] end + defp phoenix_dep do + cond do + env = System.get_env("PHOENIX_VERSION") -> {:phoenix, env} + path = System.get_env("PHOENIX_PATH") -> {:phoenix, path} + :default -> {:phoenix, "~> 1.7"} + end + end + + defp phoenix_live_view_dep do + cond do + env = System.get_env("PHOENIX_LIVE_VIEW_VERSION") -> {:phoenix_live_view, env} + path = System.get_env("PHOENIX_LIVE_VIEW_PATH") -> {:phoenix_live_view, path} + :default -> {:phoenix_live_view, "~> 0.19"} + end + end + defp live_monaco_editor_dep do - if path = System.get_env("LIVE_MONACO_EDITOR_PATH") do - {:live_monaco_editor, path: path} - else - {:live_monaco_editor, "~> 0.1"} + cond do + path = System.get_env("LIVE_MONACO_EDITOR_PATH") -> {:live_monaco_editor, path: path} + :default -> {:live_monaco_editor, "~> 0.1"} end end defp mdex_dep do - if path = System.get_env("MDEX_PATH") do - {:mdex, path: path} - else - {:mdex, "~> 0.1"} + cond do + path = System.get_env("MDEX_PATH") -> {:mdex, path: path} + :default -> {:mdex, "~> 0.1"} end end diff --git a/mix.lock b/mix.lock index 45893618c..9ed5668cd 100644 --- a/mix.lock +++ b/mix.lock @@ -35,7 +35,7 @@ "makeup": {:hex, :makeup, "1.1.0", "6b67c8bc2882a6b6a445859952a602afc1a41c2e08379ca057c0f525366fc3ca", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "0a45ed501f4a8897f580eabf99a2e5234ea3e75a4373c8a52824f6e873be57a6"}, "makeup_elixir": {:hex, :makeup_elixir, "0.16.1", "cc9e3ca312f1cfeccc572b37a09980287e243648108384b97ff2b76e505c3555", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "e127a341ad1b209bd80f7bd1620a15693a9908ed780c3b763bccf7d200c767c6"}, "makeup_erlang": {:hex, :makeup_erlang, "0.1.2", "ad87296a092a46e03b7e9b0be7631ddcf64c790fa68a9ef5323b6cbb36affc72", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "f3f5a1ca93ce6e092d92b6d9c049bcda58a3b617a8d888f8e7231c85630e8108"}, - "mdex": {:hex, :mdex, "0.1.11", "83bac0b339811310362c86087c1ea1d37cf3190f41993a7de41fea81ccdbc8a1", [:mix], [{:rustler, "~> 0.29", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.6", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "303510829f3c59295e13b27992ef542356db8276a3a514f1369ae91afb62f60b"}, + "mdex": {:hex, :mdex, "0.1.12", "bf56aa5dfc9b4bd51e98c38a7f57ae58c3a20f7b091287a121c1f5219b5d5824", [:mix], [{:rustler, "~> 0.29", [hex: :rustler, repo: "hexpm", optional: false]}, {:rustler_precompiled, "~> 0.6", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "9a0151217cf27055753a747ee3d6aa10609eaa5299d6c82ce1445d32035abbea"}, "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, "mime": {:hex, :mime, "2.0.5", "dc34c8efd439abe6ae0343edbb8556f4d63f178594894720607772a041b04b02", [:mix], [], "hexpm", "da0d64a365c45bc9935cc5c8a7fc5e49a0e0f9932a761c55d6c52b142780a05c"}, "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, diff --git a/priv/templates/install/seeds.exs b/priv/templates/install/seeds.exs index 5c06b70d4..50d18bf74 100644 --- a/priv/templates/install/seeds.exs +++ b/priv/templates/install/seeds.exs @@ -91,8 +91,8 @@ Content.publish_layout(layout)

A blog

    -
  • Path Params Blog Slug: <%%= @beacon_path_params.blog_slug %>
  • -
  • Live Data blog_slug_uppercase: <%%= @beacon_live_data.blog_slug_uppercase %>
  • +
  • Path Params Blog Slug: <%%= @beacon_path_params["blog_slug"] %>
  • +
  • Live Data blog_slug_uppercase: <%%= @beacon_live_data[:blog_slug_uppercase] %>
""" diff --git a/test/beacon/content_test.exs b/test/beacon/content_test.exs index f5fe581e7..30feb83a7 100644 --- a/test/beacon/content_test.exs +++ b/test/beacon/content_test.exs @@ -262,6 +262,52 @@ defmodule Beacon.ContentTest do assert_receive :lifecycle_after_create_page assert_receive :lifecycle_after_publish_page end + + test "save raw_schema" do + layout = layout_fixture(site: :raw_schema_test) + + assert %Page{raw_schema: [%{"foo" => "bar"}]} = + Content.create_page!(%{ + site: "my_site", + path: "/", + template: "

page

", + layout_id: layout.id, + raw_schema: [%{"foo" => "bar"}] + }) + end + + test "update raw_schema" do + layout = layout_fixture(site: :raw_schema_test) + + page = + Content.create_page!(%{ + site: "my_site", + path: "/", + template: "

page

", + layout_id: layout.id, + raw_schema: [%{"foo" => "bar"}] + }) + + assert {:ok, %Page{raw_schema: [%{"@type" => "BlogPosting"}]}} = Content.update_page(page, %{"raw_schema" => [%{"@type" => "BlogPosting"}]}) + end + + test "validate raw_schema" do + layout = layout_fixture(site: :raw_schema_test) + + assert {:error, + %{ + errors: [ + raw_schema: {"expected a list of map or a map, got: [nil]", [type: Beacon.Types.JsonArrayMap, validation: :cast]} + ] + }} = + Content.create_page(%{ + site: "my_site", + path: "/", + template: "

page

", + layout_id: layout.id, + raw_schema: [nil] + }) + end end describe "snippets" do diff --git a/test/beacon/template/heex/json_encoder_test.exs b/test/beacon/template/heex/json_encoder_test.exs index d54b5f864..430743b52 100644 --- a/test/beacon/template/heex/json_encoder_test.exs +++ b/test/beacon/template/heex/json_encoder_test.exs @@ -9,6 +9,10 @@ defmodule Beacon.Template.HEEx.JSONEncoderTest do assert encoded == expected end + test "nil template cast to empty string" do + assert_output(nil, []) + end + test "html elements with attrs" do assert_output(~S|
content
|, [%{"attrs" => %{}, "content" => ["content"], "tag" => "div"}]) assert_output(~S|contact|, [%{"attrs" => %{"href" => "/contact"}, "content" => ["contact"], "tag" => "a"}]) diff --git a/test/beacon/template/heex/tokenizer_test.exs b/test/beacon/template/heex/tokenizer_test.exs new file mode 100644 index 000000000..f10af2bd4 --- /dev/null +++ b/test/beacon/template/heex/tokenizer_test.exs @@ -0,0 +1,69 @@ +defmodule Beacon.Template.HEEx.TokenizerTest do + use ExUnit.Case, async: true + + alias Beacon.Template.HEEx.Tokenizer + + test "tokenizes a complex template" do + template = ~S| +
+

<%= user.name %>

+ <%= if true do %> +

this

+ <% else %> +

that

+ <% end %> +
+ + | + + assert Tokenizer.tokenize(template) == { + :ok, + [ + { + :tag_block, + "section", + [], + [ + {:text, "\n ", %{newlines: 1}}, + {:tag_block, "p", [], [{:eex, "user.name", %{column: 10, line: 3, opt: ~c"="}}], %{mode: :block}}, + {:text, "\n ", %{newlines: 1}}, + { + :eex_block, + "if true do", + [ + { + [ + {:text, "\n ", %{newlines: 1}}, + {:tag_block, "p", [], [{:text, "this", %{newlines: 0}}], %{mode: :block}}, + {:text, "\n ", %{newlines: 1}} + ], + "else" + }, + { + [ + {:text, "\n ", %{newlines: 1}}, + {:tag_block, "p", [], [{:text, "that", %{newlines: 0}}], %{mode: :block}}, + {:text, "\n ", %{newlines: 1}} + ], + "end" + } + ] + }, + {:text, "\n ", %{newlines: 1}} + ], + %{mode: :block} + }, + {:text, "\n ", %{newlines: 1}}, + { + :tag_self_close, + "BeaconWeb.Components.image_set", + [ + {"asset", {:expr, "@beacon_live_data[:img1]", %{column: 44, line: 10}}, %{column: 37, line: 10}}, + {"sources", {:expr, "[\"480w\"]", %{column: 79, line: 10}}, %{column: 70, line: 10}}, + {"width", {:string, "200px", %{delimiter: 34}}, %{column: 89, line: 10}} + ] + } + ] + } + end +end diff --git a/test/beacon/template/markdown_test.exs b/test/beacon/template/markdown_test.exs index 0529a9444..579beca0c 100644 --- a/test/beacon/template/markdown_test.exs +++ b/test/beacon/template/markdown_test.exs @@ -6,9 +6,7 @@ defmodule Beacon.Template.MarkdownTest do test "convert to html" do expected = ~s|

Test

Paragraph

-
-
-defmodule MyApp do
+
defmodule MyApp do
   @moduledoc "Test"
 
   def foo, do: :bar
diff --git a/test/beacon/template/tokenizer_test.exs b/test/beacon/template/tokenizer_test.exs
deleted file mode 100644
index b45593324..000000000
--- a/test/beacon/template/tokenizer_test.exs
+++ /dev/null
@@ -1,55 +0,0 @@
-defmodule Beacon.Template.HEEx.TokenizerTest do
-  use ExUnit.Case, async: true
-
-  alias Beacon.Template.HEEx.Tokenizer
-
-  test "tokenizes a complex template" do
-    {:ok, result} =
-      Tokenizer.tokenize(
-        ~s(
\n

<%= user.name %>

\n <%= if true do %>

this

<% else %>

that

<% end %>\n
\n) - ) - - assert result == - [ - { - :tag_block, - "section", - [], - [ - {:text, "\n ", %{newlines: 1}}, - { - :tag_block, - "p", - [], - [{:eex, "user.name", %{column: 6, line: 2, opt: ~c"="}}], - %{mode: :block} - }, - {:text, "\n ", %{newlines: 1}}, - { - :eex_block, - "if true do", - [ - { - [ - {:text, " ", %{newlines: 0}}, - {:tag_block, "p", [], [{:text, "this", %{newlines: 0}}], %{mode: :block}} - ], - "else" - }, - {[{:tag_block, "p", [], [{:text, "that", %{newlines: 0}}], %{mode: :block}}], "end"} - ] - }, - {:text, "\n", %{newlines: 1}} - ], - %{mode: :block} - }, - {:text, "\n", %{newlines: 1}}, - {:tag_self_close, "BeaconWeb.Components.image_set", - [ - {"asset", {:expr, "@beacon_live_data[:img1]", %{column: 40, line: 5}}, %{column: 33, line: 5}}, - {"sources", {:expr, "[\"480w\"]", %{column: 75, line: 5}}, %{column: 66, line: 5}}, - {"width", {:string, "200px", %{delimiter: 34}}, %{column: 85, line: 5}} - ]} - ] - end -end diff --git a/test/beacon/types/atom_test.exs b/test/beacon/types/atom_test.exs new file mode 100644 index 000000000..c05bb2601 --- /dev/null +++ b/test/beacon/types/atom_test.exs @@ -0,0 +1,24 @@ +defmodule Beacon.Types.AtomTest do + use ExUnit.Case, async: true + + alias Beacon.Types.Atom + + _ = :site + + test "cast" do + assert Atom.cast("site") == {:ok, :site} + assert Atom.cast(:site) == {:ok, :site} + assert Atom.cast(0) == {:error, [message: "invalid site 0"]} + end + + test "dump" do + assert Atom.dump("site") == {:ok, "site"} + assert Atom.dump(:site) == {:ok, "site"} + assert Atom.dump(0) == :error + end + + test "load" do + assert Atom.load("site") == {:ok, :site} + assert Atom.load(0) == :error + end +end diff --git a/test/beacon/types/binary_test.exs b/test/beacon/types/binary_test.exs new file mode 100644 index 000000000..2fe8f7596 --- /dev/null +++ b/test/beacon/types/binary_test.exs @@ -0,0 +1,23 @@ +defmodule Beacon.Types.BinaryTest do + use ExUnit.Case, async: true + + alias Beacon.Types.Binary + + @term %{"foo" => :bar} + @binary :erlang.term_to_binary(@term) + + test "cast" do + assert Binary.cast(@binary) == {:ok, @binary} + assert Binary.cast(@term) == {:ok, @binary} + end + + test "dump" do + assert Binary.dump(@binary) == {:ok, @binary} + assert Binary.dump(@term) == {:ok, @binary} + end + + test "load" do + assert Binary.load(@binary) == {:ok, @term} + assert Binary.load(@term) == :error + end +end diff --git a/test/beacon/types/json_array_map_test.exs b/test/beacon/types/json_array_map_test.exs new file mode 100644 index 000000000..34ecb3271 --- /dev/null +++ b/test/beacon/types/json_array_map_test.exs @@ -0,0 +1,35 @@ +defmodule Beacon.Types.JsonArrayMapTest do + use ExUnit.Case, async: true + + alias Beacon.Types.JsonArrayMap + + @map %{"foo" => "bar"} + + test "cast" do + assert JsonArrayMap.cast([]) == {:ok, []} + assert JsonArrayMap.cast(@map) == {:ok, [@map]} + assert JsonArrayMap.cast([@map]) == {:ok, [@map]} + assert JsonArrayMap.cast(~s|[{"foo": "bar"}]|) == {:ok, [@map]} + assert JsonArrayMap.cast(nil) == {:error, [{:message, "expected a list of map or a map, got: nil"}]} + assert JsonArrayMap.cast([1]) == {:error, [{:message, "expected a list of map or a map, got: [1]"}]} + assert JsonArrayMap.cast("") == {:error, [message: "expected a list of map or a map, got error: unexpected end of input at position 0"]} + end + + test "dump" do + assert JsonArrayMap.dump([]) == {:ok, []} + assert JsonArrayMap.dump(@map) == {:ok, [@map]} + assert JsonArrayMap.dump([@map]) == {:ok, [@map]} + assert JsonArrayMap.dump(~s|[{"foo": "bar"}]|) == {:ok, [@map]} + assert JsonArrayMap.dump(nil) == :error + assert JsonArrayMap.dump([1]) == :error + assert JsonArrayMap.dump("") == :error + end + + test "load" do + assert JsonArrayMap.load(@map) == {:ok, [@map]} + assert JsonArrayMap.load([@map]) == {:ok, [@map]} + assert JsonArrayMap.load(~s|[{"foo": "bar"}]|) == {:ok, [@map]} + assert JsonArrayMap.load(nil) == :error + assert JsonArrayMap.load("") == :error + end +end diff --git a/test/beacon/types/site_test.exs b/test/beacon/types/site_test.exs new file mode 100644 index 000000000..924e4fb0f --- /dev/null +++ b/test/beacon/types/site_test.exs @@ -0,0 +1,27 @@ +defmodule Beacon.Types.SiteTest do + use ExUnit.Case, async: true + + alias Beacon.Types.Site + import Beacon.Types.Site, only: [valid?: 1] + + doctest Site, only: [valid?: 1] + + _ = :site + + test "cast" do + assert Site.cast("site") == {:ok, :site} + assert Site.cast(:site) == {:ok, :site} + assert Site.cast(0) == {:error, [message: "invalid site 0"]} + end + + test "dump" do + assert Site.dump("site") == {:ok, "site"} + assert Site.dump(:site) == {:ok, "site"} + assert Site.dump(0) == :error + end + + test "load" do + assert Site.load("site") == {:ok, :site} + assert Site.load(0) == :error + end +end