diff --git a/popdoc/lib/popdoc/markdown.ex b/popdoc/lib/popdoc/markdown.ex index 39b13d66..5df23677 100644 --- a/popdoc/lib/popdoc/markdown.ex +++ b/popdoc/lib/popdoc/markdown.ex @@ -10,6 +10,12 @@ defmodule Popdoc.Markdown do @popcorn_code_class "elixir-popcorn" @eval_class "popcorn-eval" + @iex_code_class "iex-popcorn" + @iex_class "popcorn-iex" + + @iex_prompt_re ~r/^iex(?:\(\d+\))?> ?/ + @cont_prompt_re ~r/^\.\.\.(?:\(\d+\))?> ?/ + def available? do case earmark_adapter() do {:ok, module} -> apply(module, :available?, []) @@ -26,12 +32,27 @@ defmodule Popdoc.Markdown do defp walk(list) when is_list(list), do: walk_list(list, []) defp walk({:pre, pre_attrs, [{:code, code_attrs, code_children, code_meta}], pre_meta}) do - if popcorn_code_block?(code_attrs) do - {:pre, add_class(pre_attrs, @eval_class), - [{:code, normalize_code_attrs(code_attrs), walk(code_children), code_meta}], pre_meta} - else - {:pre, pre_attrs, [{:code, code_attrs, walk(code_children), code_meta}], pre_meta} - end + {pre_attrs, code_attrs} = + cond do + popcorn_code_block?(code_attrs) -> + {add_class(pre_attrs, @eval_class), normalize_code_attrs(code_attrs)} + + iex_code_block?(code_attrs) -> + commands_json = + code_children |> extract_text() |> parse_iex_commands() |> commands_to_json() + + new_pre_attrs = + pre_attrs + |> add_class(@iex_class) + |> Keyword.put(:"data-popcorn-iex-commands", commands_json) + + {new_pre_attrs, normalize_code_attrs(code_attrs)} + + true -> + {pre_attrs, code_attrs} + end + + {:pre, pre_attrs, [{:code, code_attrs, walk(code_children), code_meta}], pre_meta} end defp walk({tag, attrs, children, meta}), do: {tag, attrs, walk(children), meta} @@ -46,12 +67,19 @@ defmodule Popdoc.Markdown do |> Enum.member?(@popcorn_code_class) end + defp iex_code_block?(attrs) do + attrs + |> class_tokens() + |> Enum.member?(@iex_code_class) + end + defp normalize_code_attrs(attrs) do classes = attrs |> class_tokens() |> Enum.map(fn @popcorn_code_class -> "elixir" + @iex_code_class -> "elixir" class -> class end) |> Enum.uniq() @@ -60,6 +88,54 @@ defmodule Popdoc.Markdown do Keyword.put(attrs, :class, classes) end + defp extract_text(children) when is_list(children) do + Enum.map_join(children, "", fn + text when is_binary(text) -> text + _ -> "" + end) + end + + defp parse_iex_commands(text) do + {commands, current} = + text + |> String.split("\n") + |> Enum.reduce({[], nil}, fn line, {commands, current} -> + trimmed = String.trim_leading(line) + + cond do + code = strip_prompt(trimmed, @iex_prompt_re) -> + acc = if current != nil, do: [current | commands], else: commands + {acc, code} + + (cont = strip_prompt(trimmed, @cont_prompt_re)) && current != nil -> + {commands, current <> "\n" <> cont} + + true -> + {commands, current} + end + end) + + final = if current != nil, do: [current | commands], else: commands + Enum.reverse(final) + end + + defp strip_prompt(line, prompt_re) do + case Regex.run(prompt_re, line) do + [prompt] -> String.replace_prefix(line, prompt, "") + nil -> nil + end + end + + # ExDoc.Utils.to_json escapes every control character; a raw byte < 0x20 + # in a doc line would otherwise make JSON.parse throw and silently disable + # the block. ExDoc is guaranteed loaded here: to_ast/2 already requires its + # Earmark adapter. + defp commands_to_json(commands) do + Module.concat([ExDoc, Utils]) + |> apply(:to_json, [commands]) + |> IO.iodata_to_binary() + end + defp class_tokens(attrs) do attrs |> Keyword.get(:class, "") diff --git a/popdoc/mix.lock b/popdoc/mix.lock index 5e7e4664..76bdfa0f 100644 --- a/popdoc/mix.lock +++ b/popdoc/mix.lock @@ -1,8 +1,8 @@ %{ "atomvm_packbeam": {:hex, :atomvm_packbeam, "0.8.2", "f981c2874df98187ee5685e5fd65e0fdc2848b35d418dce5eb0dac0a78a7574b", [:rebar3], [], "hexpm", "e60a55e2184698ec485fb1d02166b4cd173bbce5c99c7bced49a6e9be5f948ad"}, - "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, - "ex_doc": {:hex, :ex_doc, "0.40.2", "f50edec428c4b0a457a167de42414c461122a3585a99515a69d09fff19e5597e", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "4fa426e2beb47854a162e2c488727fdec51cd4692e319b23810c2804cb1a40fe"}, - "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, + "earmark_parser": {:hex, :earmark_parser, "1.4.45", "cba8369ab2a1342e419bc2760eec731b17be828941dcf494045d44766227e1d5", [:mix], [], "hexpm", "d3ec045bf122965db20c0bdb420e19ee1415843135327124918473feb4b328e8"}, + "ex_doc": {:hex, :ex_doc, "0.40.3", "4a972ffe64bc07dc605af487e98fc19b72a4185f55ca031b94c0552d6071c1d9", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "2756e357742fecd9749b489b85d67c9ce99c465f2e75728d9e6dc8d704b973de"}, + "makeup": {:hex, :makeup, "1.2.2", "882d46dc0905e9ff7abf2aab61a7e6b3dcc555533977d8a23b06019e6c89ac94", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "9a1a24e5b343b8ae16abea0822c10a6f75da27af7fa802ada5251f7579bfccfa"}, "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [: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", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, "makeup_erlang": {:hex, :makeup_erlang, "1.1.0", "835f7e60792e08824cda445639555d7bf1bbbddb1b60b306e33cb6f6db24dc74", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "1cd6780fb1dd1a03979abaed0fe82712b0625118fd5257d3ebbf73f960c73c3c"}, "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, diff --git a/popdoc/test/popdoc/markdown_test.exs b/popdoc/test/popdoc/markdown_test.exs index f2b70864..b299d5fc 100644 --- a/popdoc/test/popdoc/markdown_test.exs +++ b/popdoc/test/popdoc/markdown_test.exs @@ -24,6 +24,84 @@ defmodule Popdoc.MarkdownTest do assert code_classes(pre) == ["elixir"] end + test "adds the iex class to code blocks marked by iex-popcorn" do + ast = + """ + ```iex-popcorn + iex> x = 1 + 1 + 2 + iex> x * 10 + 20 + ``` + """ + |> Markdown.to_ast([]) + + assert [pre] = ast + assert pre_classes(pre) == ["popcorn-iex"] + assert code_classes(pre) == ["elixir"] + end + + test "extracts ordered iex commands into data-popcorn-iex-commands" do + ast = + """ + ```iex-popcorn + iex> x = 1 + 1 + 2 + iex> x * 10 + 20 + ``` + """ + |> Markdown.to_ast([]) + + assert [pre] = ast + assert data_attr(pre, :"data-popcorn-iex-commands") == ~s(["x = 1 + 1","x * 10"]) + end + + test "joins continuation lines into a single command" do + ast = + """ + ```iex-popcorn + iex> x = + ...> 1 + 2 + 3 + ``` + """ + |> Markdown.to_ast([]) + + assert [pre] = ast + assert data_attr(pre, :"data-popcorn-iex-commands") == "[\"x =\\n 1 + 2\"]" + end + + test "recognizes numbered prompts and continuations" do + ast = + """ + ```iex-popcorn + iex(1)> a = 1 + iex(2)> b = + ...(2)> 2 + ``` + """ + |> Markdown.to_ast([]) + + assert [pre] = ast + assert data_attr(pre, :"data-popcorn-iex-commands") == "[\"a = 1\",\"b =\\n 2\"]" + end + + test "escapes control characters in commands" do + ast = + """ + ```iex-popcorn + iex> IO.puts("a\fb") + ``` + """ + |> Markdown.to_ast([]) + + assert [pre] = ast + json = data_attr(pre, :"data-popcorn-iex-commands") + refute json =~ <<0x0C>> + assert json == "[\"IO.puts(\\\"a\\fb\\\")\"]" + end + test "leaves HTML comments untouched" do ast = """ @@ -50,4 +128,8 @@ defmodule Popdoc.MarkdownTest do |> Keyword.get(:class, "") |> String.split() end + + defp data_attr({:pre, attrs, _children, _meta}, key) do + Keyword.get(attrs, key) + end end