Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 82 additions & 6 deletions popdoc/lib/popdoc/markdown.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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?, [])
Expand All @@ -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)}
Comment on lines +41 to +49

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This feels like it mixes a lot of abstraction. What about:

  • type(code_attrs) -> :iex | :code_block | :other
  • add_data(attrs, key, value)
  • call normalize_code_attrs after the cond/case block (maybe with rename to dedup())


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}
Expand All @@ -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()
Expand All @@ -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
Comment on lines +105 to +115

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This needs to change, assignments in cond, we use &&, and a lot of nils everywhere. Most likely this can be rewritten by tagging strip_prompt with different atoms.

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
Comment on lines +122 to +127

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This feels like it could be Regex.replace if we change logic a bit and remove nil (which could improve rest of the code).


# 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])
Comment on lines +134 to +135

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't we depend on ExDoc?

|> IO.iodata_to_binary()
end

defp class_tokens(attrs) do
attrs
|> Keyword.get(:class, "")
Expand Down
6 changes: 3 additions & 3 deletions popdoc/mix.lock
Original file line number Diff line number Diff line change
@@ -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"},
Expand Down
82 changes: 82 additions & 0 deletions popdoc/test/popdoc/markdown_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
"""
Expand All @@ -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
Loading