From 6ac7f94897ee54ae2a2f652718024a95cc25d25a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonatan=20K=C5=82osko?= Date: Tue, 16 Feb 2021 18:39:52 +0100 Subject: [PATCH] Define notebook file format (#27) * Initial file import/export * Add renderer tests * Refactor renderer * Depend only on EarmarkParser * Add test for export * Add import tests * Improve import * Document the ExMd file format * Rename ExMd to ExMarkdown * Rename ExMarkdown to LiveMarkdown * Build iodata when exporting a notebook * Persist metadata as a single JSON object * Move Markdown to LiveMarkdown.MarkdownHelpers * Make LiveMarkdown private * Always move primary heading to the top during import * Hint the user not to use heading 1 and 2 * Return a list of messages from the import function * Update headings warning * Add import and export test for non-elixir snippets * Merge markdown renderer into MarkdownHelpers * Add import messages on AST rewrites --- assets/css/markdown.css | 29 +- lib/live_book/live_markdown.ex | 50 +++ lib/live_book/live_markdown/export.ex | 81 ++++ lib/live_book/live_markdown/import.ex | 187 +++++++++ .../live_markdown/markdown_helpers.ex | 374 +++++++++++++++++ lib/live_book/notebook.ex | 2 +- lib/live_book/notebook/cell.ex | 2 +- lib/live_book/notebook/section.ex | 2 +- lib/live_book_web/live/cell_component.ex | 2 +- mix.exs | 3 +- mix.lock | 5 +- test/live_book/live_markdown/export_test.exs | 224 +++++++++++ test/live_book/live_markdown/import_test.exs | 378 ++++++++++++++++++ .../live_markdown/markdown_helpers_test.exs | 281 +++++++++++++ 14 files changed, 1611 insertions(+), 9 deletions(-) create mode 100644 lib/live_book/live_markdown.ex create mode 100644 lib/live_book/live_markdown/export.ex create mode 100644 lib/live_book/live_markdown/import.ex create mode 100644 lib/live_book/live_markdown/markdown_helpers.ex create mode 100644 test/live_book/live_markdown/export_test.exs create mode 100644 test/live_book/live_markdown/import_test.exs create mode 100644 test/live_book/live_markdown/markdown_helpers_test.exs diff --git a/assets/css/markdown.css b/assets/css/markdown.css index bbaba8507..b9511d418 100644 --- a/assets/css/markdown.css +++ b/assets/css/markdown.css @@ -5,17 +5,29 @@ } .markdown h1 { - @apply text-gray-900 font-semibold text-3xl my-4; + @apply text-gray-900 font-semibold text-4xl my-4; } .markdown h2 { - @apply text-gray-900 font-semibold text-2xl my-4; + @apply text-gray-900 font-semibold text-3xl my-4; } .markdown h3 { + @apply text-gray-900 font-semibold text-2xl my-4; +} + +.markdown h4 { @apply text-gray-900 font-semibold text-xl my-4; } +.markdown h5 { + @apply text-gray-900 font-semibold text-lg my-4; +} + +.markdown h6 { + @apply text-gray-900 font-semibold text-base my-4; +} + .markdown p { @apply my-4; } @@ -97,3 +109,16 @@ .markdown :last-child { @apply mb-0; } + +/* Overrides for user-entered markdown */ + +.cell .markdown h1, +.cell .markdown h2 { + font-size: 0; +} + +.cell .markdown h1:after, +.cell .markdown h2:after { + @apply text-red-400 text-base font-medium; + content: "warning: heading levels 1 and 2 are reserved for notebook and section names, please use heading 3 and above."; +} diff --git a/lib/live_book/live_markdown.ex b/lib/live_book/live_markdown.ex new file mode 100644 index 000000000..6b703322f --- /dev/null +++ b/lib/live_book/live_markdown.ex @@ -0,0 +1,50 @@ +defmodule LiveBook.LiveMarkdown do + @moduledoc false + + # Notebook file format used by LiveBook. + # + # The format is based off of Markdown and preserves compatibility, + # in the sense that every LiveMarkdown file is a valid Markdown file. + # LiveMarkdown uses HTML comments for storing metadata, so a Markdown standard + # supporting that is assumed. Not every Markdown file is a valid LiveMarkdown file, + # but may be converted to such by applying tiny changes, which the import function does. + # + # Currently the format is straightforward and specifies the following: + # + # 1. The file should have a leading *Heading 1* holding the notebook name. + # 2. Every *Heading 2* starts a new section. + # 3. Every Elixir code block maps to an Elixir cell. + # 4. Adjacent regular Markdown text maps to a Markdown cell. + # 5. Comments of the form `` hold metadata + # any apply to the element they directly precede (e.g. an Elixir cell). + # + # ## Example + # + # Here's an example LiveMarkdown file: + # + # # My Notebook + # + # ## Section 1 + # + # Make sure to install: + # + # * Erlang + # * Elixir + # * PostgreSQL + # + # + # ```elixir + # Enum.to_list(1..10) + # ``` + # + # This is it for this section. + # + # ## Section 2 + # + # ```elixir + # # More Elixir code + # ``` + # + # This file defines a notebook named *My Notebook* with two sections. + # The first section includes 3 cells and the second section includes 1 Elixir cell. +end diff --git a/lib/live_book/live_markdown/export.ex b/lib/live_book/live_markdown/export.ex new file mode 100644 index 000000000..4748ca17a --- /dev/null +++ b/lib/live_book/live_markdown/export.ex @@ -0,0 +1,81 @@ +defmodule LiveBook.LiveMarkdown.Export do + alias LiveBook.Notebook + alias LiveBook.LiveMarkdown.MarkdownHelpers + + @doc """ + Converts the given notebook into a Markdown document. + """ + @spec notebook_to_markdown(Notebook.t()) :: String.t() + def notebook_to_markdown(notebook) do + iodata = render_notebook(notebook) + # Add trailing newline + IO.iodata_to_binary([iodata, "\n"]) + end + + defp render_notebook(notebook) do + name = "# #{notebook.name}" + sections = Enum.map(notebook.sections, &render_section/1) + + [name | sections] + |> Enum.intersperse("\n\n") + |> prepend_metadata(notebook.metadata) + end + + defp render_section(section) do + name = "## #{section.name}" + cells = Enum.map(section.cells, &render_cell/1) + + [name | cells] + |> Enum.intersperse("\n\n") + |> prepend_metadata(section.metadata) + end + + defp render_cell(%{type: :markdown} = cell) do + cell.source + |> format_markdown_source() + |> prepend_metadata(cell.metadata) + end + + defp render_cell(%{type: :elixir} = cell) do + """ + ```elixir + #{cell.source} + ```\ + """ + |> prepend_metadata(cell.metadata) + end + + defp render_metadata(metadata) do + metadata_json = Jason.encode!(metadata) + "" + end + + defp prepend_metadata(iodata, metadata) when metadata == %{}, do: iodata + + defp prepend_metadata(iodata, metadata) do + content = render_metadata(metadata) + [content, "\n", iodata] + end + + defp format_markdown_source(markdown) do + markdown + |> EarmarkParser.as_ast() + |> elem(1) + |> rewrite_ast() + |> MarkdownHelpers.markdown_from_ast() + end + + # Alters AST of the user-entered markdown. + defp rewrite_ast(ast) do + ast + |> remove_reserved_headings() + end + + defp remove_reserved_headings(ast) do + Enum.filter(ast, fn + {"h1", _, _, _} -> false + {"h2", _, _, _} -> false + _ast_node -> true + end) + end +end diff --git a/lib/live_book/live_markdown/import.ex b/lib/live_book/live_markdown/import.ex new file mode 100644 index 000000000..894e47668 --- /dev/null +++ b/lib/live_book/live_markdown/import.ex @@ -0,0 +1,187 @@ +defmodule LiveBook.LiveMarkdown.Import do + alias LiveBook.Notebook + alias LiveBook.LiveMarkdown.MarkdownHelpers + + @doc """ + Converts the given Markdown document into a notebook data structure. + + Returns the notebook structure and list if informative messages/warnings + related to the imported input. + """ + @spec notebook_from_markdown(String.t()) :: {Notebook.t(), list(String.t())} + def notebook_from_markdown(markdown) do + {_, ast, earmark_messages} = EarmarkParser.as_ast(markdown) + earmark_messages = Enum.map(earmark_messages, &earmark_message_to_string/1) + + {ast, rewrite_messages} = rewrite_ast(ast) + + notebook = + ast + |> group_elements() + |> build_notebook() + + {notebook, earmark_messages ++ rewrite_messages} + end + + defp earmark_message_to_string({_severity, line_number, message}) do + "Line #{line_number}: #{message}" + end + + # Does initial pre-processing of the AST, so that it conforms to the expected form. + # Returns {altered_ast, messages}. + defp rewrite_ast(ast) do + {ast, messages1} = rewrite_multiple_primary_headings(ast) + {ast, messages2} = move_primary_heading_top(ast) + + {ast, messages1 ++ messages2} + end + + # There should be only one h1 tag indicating notebook name, + # if there are many we downgrade all headings. + # This doesn't apply to documents exported from LiveBook, + # but may be the case for an arbitrary markdown file, + # so we do our best to preserve the intent. + defp rewrite_multiple_primary_headings(ast) do + primary_headings = Enum.count(ast, &(tag(&1) == "h1")) + + if primary_headings > 1 do + ast = Enum.map(ast, &downgrade_heading/1) + + message = + "Downgrading all headings, because #{primary_headings} instances of heading 1 were found" + + {ast, [message]} + else + {ast, []} + end + end + + defp downgrade_heading({"h1", attrs, content, meta}), do: {"h2", attrs, content, meta} + defp downgrade_heading({"h2", attrs, content, meta}), do: {"h3", attrs, content, meta} + defp downgrade_heading({"h3", attrs, content, meta}), do: {"h4", attrs, content, meta} + defp downgrade_heading({"h4", attrs, content, meta}), do: {"h5", attrs, content, meta} + defp downgrade_heading({"h5", attrs, content, meta}), do: {"h6", attrs, content, meta} + defp downgrade_heading({"h6", attrs, content, meta}), do: {"strong", attrs, content, meta} + defp downgrade_heading(ast_node), do: ast_node + + # This moves h1 together with any preceding comments to the top. + defp move_primary_heading_top(ast) do + case Enum.split_while(ast, &(tag(&1) != "h1")) do + {_ast, []} -> + {ast, []} + + {leading, [heading | rest]} -> + {leading, comments} = split_while_right(leading, &(tag(&1) == :comment)) + + if leading == [] do + {ast, []} + else + ast = comments ++ [heading] ++ leading ++ rest + message = "Moving heading 1 to the top of the notebook" + {ast, [message]} + end + end + end + + defp tag(ast_node) + defp tag({tag, _, _, _}), do: tag + defp tag(_), do: nil + + defp split_while_right(list, fun) do + {right_rev, left_rev} = list |> Enum.reverse() |> Enum.split_while(fun) + {Enum.reverse(left_rev), Enum.reverse(right_rev)} + end + + # Builds a list of classified elements from the AST. + defp group_elements(ast, elems \\ []) + + defp group_elements([], elems), do: elems + + defp group_elements([{"h1", _, content, %{}} | ast], elems) do + group_elements(ast, [{:notebook_name, content} | elems]) + end + + defp group_elements([{"h2", _, content, %{}} | ast], elems) do + group_elements(ast, [{:section_name, content} | elems]) + end + + defp group_elements( + [{:comment, _, ["live_book:" <> metadata_json], %{comment: true}} | ast], + elems + ) do + group_elements(ast, [{:metadata, metadata_json} | elems]) + end + + defp group_elements( + [{"pre", _, [{"code", [{"class", "elixir"}], [source], %{}}], %{}} | ast], + elems + ) do + group_elements(ast, [{:cell, :elixir, source} | elems]) + end + + defp group_elements([ast_node | ast], [{:cell, :markdown, md_ast} | rest]) do + group_elements(ast, [{:cell, :markdown, [ast_node | md_ast]} | rest]) + end + + defp group_elements([ast_node | ast], elems) do + group_elements(ast, [{:cell, :markdown, [ast_node]} | elems]) + end + + # Builds a notebook from the list of elements obtained in the previous step. + # Note that the list of elements is reversed: + # first we group elements by traversing Earmark AST top-down + # and then aggregate elements into data strictures going bottom-up. + defp build_notebook(elems, cells \\ [], sections \\ []) + + defp build_notebook([{:cell, :elixir, source} | elems], cells, sections) do + {metadata, elems} = grab_metadata(elems) + cell = %{Notebook.Cell.new(:elixir) | source: source, metadata: metadata} + build_notebook(elems, [cell | cells], sections) + end + + defp build_notebook([{:cell, :markdown, md_ast} | elems], cells, sections) do + {metadata, elems} = grab_metadata(elems) + source = md_ast |> Enum.reverse() |> MarkdownHelpers.markdown_from_ast() + cell = %{Notebook.Cell.new(:markdown) | source: source, metadata: metadata} + build_notebook(elems, [cell | cells], sections) + end + + defp build_notebook([{:section_name, content} | elems], cells, sections) do + name = MarkdownHelpers.text_from_ast(content) + {metadata, elems} = grab_metadata(elems) + section = %{Notebook.Section.new() | name: name, cells: cells, metadata: metadata} + build_notebook(elems, [], [section | sections]) + end + + # If there are section-less cells, put them in a default one. + defp build_notebook([{:notebook_name, _content} | _] = elems, cells, sections) + when cells != [] do + section = %{Notebook.Section.new() | cells: cells} + build_notebook(elems, [], [section | sections]) + end + + # If there are section-less cells, put them in a default one. + defp build_notebook([] = elems, cells, sections) when cells != [] do + section = %{Notebook.Section.new() | cells: cells} + build_notebook(elems, [], [section | sections]) + end + + defp build_notebook([{:notebook_name, content} | elems], [], sections) do + name = MarkdownHelpers.text_from_ast(content) + {metadata, []} = grab_metadata(elems) + %{Notebook.new() | name: name, sections: sections, metadata: metadata} + end + + # If there's no explicit notebook heading, use the defaults. + defp build_notebook([], [], sections) do + %{Notebook.new() | sections: sections} + end + + # Takes optional leading metadata JSON object and returns {metadata, rest}. + defp grab_metadata([{:metadata, metadata_json} | elems]) do + metadata = Jason.decode!(metadata_json) + {metadata, elems} + end + + defp grab_metadata(elems), do: {%{}, elems} +end diff --git a/lib/live_book/live_markdown/markdown_helpers.ex b/lib/live_book/live_markdown/markdown_helpers.ex new file mode 100644 index 000000000..9852b175c --- /dev/null +++ b/lib/live_book/live_markdown/markdown_helpers.ex @@ -0,0 +1,374 @@ +defmodule LiveBook.LiveMarkdown.MarkdownHelpers do + @doc """ + Reformats the given markdown document. + """ + @spec reformat(String.t()) :: String.t() + def reformat(markdown) do + markdown + |> EarmarkParser.as_ast() + |> elem(1) + |> markdown_from_ast() + end + + @doc """ + Extracts plain text from the given AST ignoring all the tags. + """ + @spec text_from_ast(EarmarkParser.ast()) :: String.t() + def text_from_ast(ast) + + def text_from_ast(ast) when is_list(ast) do + ast + |> Enum.map(&text_from_ast/1) + |> Enum.join("") + end + + def text_from_ast(ast) when is_binary(ast), do: ast + def text_from_ast({_, _, ast, _}), do: text_from_ast(ast) + + @doc """ + Renders Markdown string from the given `EarmarkParser` AST. + """ + @spec markdown_from_ast(EarmarkParser.ast()) :: String.t() + def markdown_from_ast(ast) do + build_md([], ast) + |> IO.iodata_to_binary() + |> String.trim() + end + + defp build_md(iodata, ast) + + defp build_md(iodata, []), do: iodata + + defp build_md(iodata, [string | ast]) when is_binary(string) do + string + |> append_inline(iodata) + |> build_md(ast) + end + + defp build_md(iodata, [{tag, attrs, lines, %{verbatim: true}} | ast]) do + render_html(tag, attrs, lines) + |> append_block(iodata) + |> build_md(ast) + end + + defp build_md(iodata, [{"em", _, content, %{}} | ast]) do + render_emphasis(content) + |> append_inline(iodata) + |> build_md(ast) + end + + defp build_md(iodata, [{"strong", _, content, %{}} | ast]) do + render_strong(content) + |> append_inline(iodata) + |> build_md(ast) + end + + defp build_md(iodata, [{"del", _, content, %{}} | ast]) do + render_strikethrough(content) + |> append_inline(iodata) + |> build_md(ast) + end + + defp build_md(iodata, [{"code", _, content, %{}} | ast]) do + render_code_inline(content) + |> append_inline(iodata) + |> build_md(ast) + end + + defp build_md(iodata, [{"a", attrs, content, %{}} | ast]) do + render_link(content, attrs) + |> append_inline(iodata) + |> build_md(ast) + end + + defp build_md(iodata, [{"img", attrs, [], %{}} | ast]) do + render_image(attrs) + |> append_inline(iodata) + |> build_md(ast) + end + + defp build_md(iodata, [{:comment, _, lines, %{comment: true}} | ast]) do + render_comment(lines) + |> append_inline(iodata) + |> build_md(ast) + end + + defp build_md(iodata, [{"hr", attrs, [], %{}} | ast]) do + render_ruler(attrs) + |> append_block(iodata) + |> build_md(ast) + end + + defp build_md(iodata, [{"p", _, content, %{}} | ast]) do + render_paragraph(content) + |> append_block(iodata) + |> build_md(ast) + end + + defp build_md(iodata, [{"h" <> n, _, content, %{}} | ast]) + when n in ["1", "2", "3", "4", "5", "6"] do + n = String.to_integer(n) + + render_heading(n, content) + |> append_block(iodata) + |> build_md(ast) + end + + defp build_md(iodata, [{"pre", _, [{"code", attrs, [content], %{}}], %{}} | ast]) do + render_code_block(content, attrs) + |> append_block(iodata) + |> build_md(ast) + end + + defp build_md(iodata, [{"blockquote", [], content, %{}} | ast]) do + render_blockquote(content) + |> append_block(iodata) + |> build_md(ast) + end + + defp build_md(iodata, [{"table", _, content, %{}} | ast]) do + render_table(content) + |> append_block(iodata) + |> build_md(ast) + end + + defp build_md(iodata, [{"ul", _, content, %{}} | ast]) do + render_unordered_list(content) + |> append_block(iodata) + |> build_md(ast) + end + + defp build_md(iodata, [{"ol", _, content, %{}} | ast]) do + render_ordered_list(content) + |> append_block(iodata) + |> build_md(ast) + end + + defp append_inline(md, iodata), do: [iodata, md] + defp append_block(md, iodata), do: [iodata, "\n", md, "\n"] + + # Renderers + + # https://www.w3.org/TR/2011/WD-html-markup-20110113/syntax.html#void-element + @void_elements ~W(area base br col command embed hr img input keygen link meta param source track wbr) + + defp render_html(tag, attrs, []) when tag in @void_elements do + "<#{tag} #{attrs_to_string(attrs)} />" + end + + defp render_html(tag, attrs, lines) do + inner = Enum.join(lines, "\n") + "<#{tag} #{attrs_to_string(attrs)}>\n#{inner}\n" + end + + defp render_emphasis(content) do + inner = markdown_from_ast(content) + "*#{inner}*" + end + + defp render_strong(content) do + inner = markdown_from_ast(content) + "**#{inner}**" + end + + defp render_strikethrough(content) do + inner = markdown_from_ast(content) + "~~#{inner}~~" + end + + defp render_code_inline(content) do + inner = markdown_from_ast(content) + "`#{inner}`" + end + + defp render_link(content, attrs) do + caption = markdown_from_ast(content) + href = get_attr(attrs, "href", "") + "[#{caption}](#{href})" + end + + defp render_image(attrs) do + alt = get_attr(attrs, "alt", "") + src = get_attr(attrs, "src", "") + title = get_attr(attrs, "title", "") + + if title == "" do + "![#{alt}](#{src})" + else + ~s/![#{alt}](#{src} "#{title}")/ + end + end + + defp render_comment([line]) do + line = String.trim(line) + "" + end + + defp render_comment(lines) do + lines = + lines + |> Enum.drop_while(&blank?/1) + |> Enum.reverse() + |> Enum.drop_while(&blank?/1) + |> Enum.reverse() + + Enum.join([""], "\n") + end + + defp render_ruler(attrs) do + class = get_attr(attrs, "class", "thin") + + case class do + "thin" -> "---" + "medium" -> "___" + "thick" -> "***" + end + end + + defp render_paragraph(content), do: markdown_from_ast(content) + + defp render_heading(n, content) do + title = markdown_from_ast(content) + String.duplicate("#", n) <> " " <> title + end + + defp render_code_block(content, attrs) do + language = get_attr(attrs, "class", "") + "```#{language}\n#{content}\n```" + end + + defp render_blockquote(content) do + inner = markdown_from_ast(content) + + inner + |> String.split("\n") + |> Enum.map(&("> " <> &1)) + |> Enum.join("\n") + end + + defp render_table([{"thead", _, [head_row], %{}}, {"tbody", _, body_rows, %{}}]) do + alignments = alignments_from_row(head_row) + cell_grid = cell_grid_from_rows([head_row | body_rows]) + column_widths = max_length_per_column(cell_grid) + [head_cells | body_cell_grid] = Enum.map(cell_grid, &pad_whitespace(&1, column_widths)) + separator_cells = build_separator_cells(alignments, column_widths) + cell_grid_to_md_table([head_cells, separator_cells | body_cell_grid]) + end + + defp render_table([{"tbody", _, body_rows, %{}}]) do + cell_grid = cell_grid_from_rows(body_rows) + column_widths = max_length_per_column(cell_grid) + cell_grid = Enum.map(cell_grid, &pad_whitespace(&1, column_widths)) + cell_grid_to_md_table(cell_grid) + end + + defp cell_grid_from_rows(rows) do + Enum.map(rows, fn {"tr", _, columns, %{}} -> + Enum.map(columns, fn {tag, _, content, %{}} when tag in ["th", "td"] -> + markdown_from_ast(content) + end) + end) + end + + defp alignments_from_row({"tr", _, columns, %{}}) do + Enum.map(columns, fn {tag, attrs, _, %{}} when tag in ["th", "td"] -> + style = get_attr(attrs, "style", nil) + + case style do + "text-align: left;" -> :left + "text-align: center;" -> :center + "text-align: right;" -> :right + end + end) + end + + defp build_separator_cells(alignments, widths) do + alignments + |> Enum.zip(widths) + |> Enum.map(fn + {:left, length} -> String.duplicate("-", length) + {:center, length} -> ":" <> String.duplicate("-", length - 2) <> ":" + {:right, length} -> String.duplicate("-", length - 1) <> ":" + end) + end + + defp max_length_per_column(cell_grid) do + cell_grid + |> List.zip() + |> Enum.map(&Tuple.to_list/1) + |> Enum.map(fn cells -> + cells + |> Enum.map(&String.length/1) + |> Enum.max() + end) + end + + defp pad_whitespace(cells, widths) do + cells + |> Enum.zip(widths) + |> Enum.map(fn {cell, width} -> + String.pad_trailing(cell, width, " ") + end) + end + + defp cell_grid_to_md_table(cell_grid) do + cell_grid + |> Enum.map(fn cells -> + "| " <> Enum.join(cells, " | ") <> " |" + end) + |> Enum.join("\n") + end + + defp render_unordered_list(content) do + marker_fun = fn _index -> "* " end + render_list(content, marker_fun, " ") + end + + defp render_ordered_list(content) do + marker_fun = fn index -> "#{index + 1}. " end + render_list(content, marker_fun, " ") + end + + defp render_list(items, marker_fun, indent) do + spaced? = spaced_list_items?(items) + item_separator = if(spaced?, do: "\n\n", else: "\n") + + items + |> Enum.map(fn {"li", _, content, %{}} -> markdown_from_ast(content) end) + |> Enum.with_index() + |> Enum.map(fn {inner, index} -> + [first_line | lines] = String.split(inner, "\n") + + first_line = marker_fun.(index) <> first_line + + lines = + Enum.map(lines, fn + "" -> "" + line -> indent <> line + end) + + Enum.join([first_line | lines], "\n") + end) + |> Enum.join(item_separator) + end + + defp spaced_list_items?([{"li", _, [{"p", _, _content, %{}} | _], %{}} | _items]), do: true + defp spaced_list_items?([_ | items]), do: spaced_list_items?(items) + defp spaced_list_items?([]), do: false + + # Helpers + + defp get_attr(attrs, key, default) do + Enum.find_value(attrs, default, fn {attr_key, attr_value} -> + attr_key == key && attr_value + end) + end + + defp attrs_to_string(attrs) do + attrs + |> Enum.map(fn {key, value} -> ~s/#{key}="#{value}"/ end) + |> Enum.join(" ") + end + + defp blank?(string), do: String.trim(string) == "" +end diff --git a/lib/live_book/notebook.ex b/lib/live_book/notebook.ex index ef95f9ef2..ea76b76ba 100644 --- a/lib/live_book/notebook.ex +++ b/lib/live_book/notebook.ex @@ -19,7 +19,7 @@ defmodule LiveBook.Notebook do name: String.t(), version: String.t(), sections: list(Section.t()), - metadata: %{atom() => term()} + metadata: %{String.t() => term()} } @version "1.0" diff --git a/lib/live_book/notebook/cell.ex b/lib/live_book/notebook/cell.ex index bc540333c..50bfdbc80 100644 --- a/lib/live_book/notebook/cell.ex +++ b/lib/live_book/notebook/cell.ex @@ -19,7 +19,7 @@ defmodule LiveBook.Notebook.Cell do type: type(), source: String.t(), outputs: list(), - metadata: %{atom() => term()} + metadata: %{String.t() => term()} } @doc """ diff --git a/lib/live_book/notebook/section.ex b/lib/live_book/notebook/section.ex index f80b59d73..84afe8de7 100644 --- a/lib/live_book/notebook/section.ex +++ b/lib/live_book/notebook/section.ex @@ -17,7 +17,7 @@ defmodule LiveBook.Notebook.Section do id: id(), name: String.t(), cells: list(Cell.t()), - metadata: %{atom() => term()} + metadata: %{String.t() => term()} } @doc """ diff --git a/lib/live_book_web/live/cell_component.ex b/lib/live_book_web/live/cell_component.ex index 04785d517..5f3e50e1b 100644 --- a/lib/live_book_web/live/cell_component.ex +++ b/lib/live_book_web/live/cell_component.ex @@ -3,7 +3,7 @@ defmodule LiveBookWeb.CellComponent do def render(assigns) do ~L""" -
" +
" id="cell-<%= @cell.id %>" phx-hook="Cell" data-cell-id="<%= @cell.id %>" diff --git a/mix.exs b/mix.exs index 8e5ebab20..a421ff360 100644 --- a/mix.exs +++ b/mix.exs @@ -41,7 +41,8 @@ defmodule LiveBook.MixProject do {:telemetry_metrics, "~> 0.4"}, {:telemetry_poller, "~> 0.4"}, {:jason, "~> 1.0"}, - {:plug_cowboy, "~> 2.0"} + {:plug_cowboy, "~> 2.0"}, + {:earmark_parser, "~> 1.4"} ] end diff --git a/mix.lock b/mix.lock index 31d2a80ec..b30517609 100644 --- a/mix.lock +++ b/mix.lock @@ -2,15 +2,16 @@ "cowboy": {:hex, :cowboy, "2.8.0", "f3dc62e35797ecd9ac1b50db74611193c29815401e53bac9a5c0577bd7bc667d", [:rebar3], [{:cowlib, "~> 2.9.1", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "~> 1.7.1", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "4643e4fba74ac96d4d152c75803de6fad0b3fa5df354c71afdd6cbeeb15fac8a"}, "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.3.1", "ebd1a1d7aff97f27c66654e78ece187abdc646992714164380d8a041eda16754", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "3a6efd3366130eab84ca372cbd4a7d3c3a97bdfcfb4911233b035d117063f0af"}, "cowlib": {:hex, :cowlib, "2.9.1", "61a6c7c50cf07fdd24b2f45b89500bb93b6686579b069a89f88cb211e1125c78", [:rebar3], [], "hexpm", "e4175dc240a70d996156160891e1c62238ede1729e45740bdd38064dad476170"}, + "earmark_parser": {:hex, :earmark_parser, "1.4.12", "b245e875ec0a311a342320da0551da407d9d2b65d98f7a9597ae078615af3449", [:mix], [], "hexpm", "711e2cc4d64abb7d566d43f54b78f7dc129308a63bc103fbd88550d2174b3160"}, "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, - "floki": {:hex, :floki, "0.29.0", "b1710d8c93a2f860dc2d7adc390dd808dc2fb8f78ee562304457b75f4c640881", [:mix], [{:html_entities, "~> 0.5.0", [hex: :html_entities, repo: "hexpm", optional: false]}], "hexpm", "008585ce64b9f74c07d32958ec9866f4b8a124bf4da1e2941b28e41384edaaad"}, + "floki": {:hex, :floki, "0.30.0", "22ebbe681a5d3777cdd830ca091b1b806d33c3449c26312eadca7f7be685c0c8", [:mix], [{:html_entities, "~> 0.5.0", [hex: :html_entities, repo: "hexpm", optional: false]}], "hexpm", "a9e128a4ca9bb71f11affa315b6768a9ad326d5996ff1e92acf1d7a01a10076a"}, "html_entities": {:hex, :html_entities, "0.5.1", "1c9715058b42c35a2ab65edc5b36d0ea66dd083767bef6e3edb57870ef556549", [:mix], [], "hexpm", "30efab070904eb897ff05cd52fa61c1025d7f8ef3a9ca250bc4e6513d16c32de"}, "jason": {:hex, :jason, "1.2.2", "ba43e3f2709fd1aa1dce90aaabfd039d000469c05c56f0b8e31978e03fa39052", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "18a228f5f0058ee183f29f9eae0805c6e59d61c3b006760668d8d18ff0d12179"}, "mime": {:hex, :mime, "1.5.0", "203ef35ef3389aae6d361918bf3f952fa17a09e8e43b5aa592b93eba05d0fb8d", [:mix], [], "hexpm", "55a94c0f552249fc1a3dd9cd2d3ab9de9d3c89b559c2bd01121f824834f24746"}, "phoenix": {:hex, :phoenix, "1.5.7", "2923bb3af924f184459fe4fa4b100bd25fa6468e69b2803dfae82698269aa5e0", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_html, "~> 2.13", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:plug, "~> 1.10", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 1.0 or ~> 2.2", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.1.2 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "774cd64417c5a3788414fdbb2be2eb9bcd0c048d9e6ad11a0c1fd67b7c0d0978"}, "phoenix_html": {:hex, :phoenix_html, "2.14.3", "51f720d0d543e4e157ff06b65de38e13303d5778a7919bcc696599e5934271b8", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "efd697a7fff35a13eeeb6b43db884705cba353a1a41d127d118fda5f90c8e80f"}, "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.3.0", "f35f61c3f959c9a01b36defaa1f0624edd55b87e236b606664a556d6f72fd2e7", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "02c1007ae393f2b76ec61c1a869b1e617179877984678babde131d716f95b582"}, - "phoenix_live_view": {:hex, :phoenix_live_view, "0.15.3", "70c7917e5c421e32d1a1c8ddf8123378bb741748cd8091eb9d557fb4be92a94f", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.5.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 0.5", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "cabcfb6738419a08600009219a5f0d861de97507fc1232121e1d5221aba849bd"}, + "phoenix_live_view": {:hex, :phoenix_live_view, "0.15.4", "86908dc9603cc81c07e84725ee42349b5325cb250c9c20d3533856ff18dbb7dc", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.5.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 0.5", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "35d78f3c35fe10a995dca5f4ab50165b7a90cbe02e23de245381558f821e9462"}, "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.0.0", "a1ae76717bb168cdeb10ec9d92d1480fec99e3080f011402c0a2d68d47395ffb", [:mix], [], "hexpm", "c52d948c4f261577b9c6fa804be91884b381a7f8f18450c5045975435350f771"}, "plug": {:hex, :plug, "1.11.0", "f17217525597628298998bc3baed9f8ea1fa3f1160aa9871aee6df47a6e4d38e", [:mix], [{:mime, "~> 1.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", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2d9c633f0499f9dc5c2fd069161af4e2e7756890b81adcbb2ceaa074e8308876"}, "plug_cowboy": {:hex, :plug_cowboy, "2.4.1", "779ba386c0915027f22e14a48919a9545714f849505fa15af2631a0d298abf0f", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d72113b6dff7b37a7d9b2a5b68892808e3a9a752f2bf7e503240945385b70507"}, diff --git a/test/live_book/live_markdown/export_test.exs b/test/live_book/live_markdown/export_test.exs new file mode 100644 index 000000000..927837271 --- /dev/null +++ b/test/live_book/live_markdown/export_test.exs @@ -0,0 +1,224 @@ +defmodule LiveBook.LiveMarkdown.ExportTest do + use ExUnit.Case, async: true + + alias LiveBook.LiveMarkdown.Export + alias LiveBook.Notebook + + test "acceptance" do + notebook = %{ + Notebook.new() + | name: "My Notebook", + metadata: %{"author" => "Sherlock Holmes"}, + sections: [ + %{ + Notebook.Section.new() + | name: "Section 1", + metadata: %{"created_at" => "2021-02-15"}, + cells: [ + %{ + Notebook.Cell.new(:markdown) + | metadata: %{"updated_at" => "2021-02-15"}, + source: """ + Make sure to install: + + * Erlang + * Elixir + * PostgreSQL\ + """ + }, + %{ + Notebook.Cell.new(:elixir) + | metadata: %{"readonly" => true}, + source: """ + Enum.to_list(1..10)\ + """ + }, + %{ + Notebook.Cell.new(:markdown) + | metadata: %{}, + source: """ + This is it for this section.\ + """ + } + ] + }, + %{ + Notebook.Section.new() + | name: "Section 2", + metadata: %{}, + cells: [ + %{ + Notebook.Cell.new(:elixir) + | metadata: %{}, + source: """ + # More Elixir code\ + """ + } + ] + } + ] + } + + expected_document = """ + + # My Notebook + + + ## Section 1 + + + Make sure to install: + + * Erlang + * Elixir + * PostgreSQL + + + ```elixir + Enum.to_list(1..10) + ``` + + This is it for this section. + + ## Section 2 + + ```elixir + # More Elixir code + ``` + """ + + document = Export.notebook_to_markdown(notebook) + + assert expected_document == document + end + + test "reformats markdown cells" do + notebook = %{ + Notebook.new() + | name: "My Notebook", + metadata: %{}, + sections: [ + %{ + Notebook.Section.new() + | name: "Section 1", + metadata: %{}, + cells: [ + %{ + Notebook.Cell.new(:markdown) + | metadata: %{}, + source: """ + |State|Abbrev|Capital| + | --: | :-: | --- | + | Texas | TX | Austin | + | Maine | ME | Augusta | + """ + } + ] + } + ] + } + + expected_document = """ + # My Notebook + + ## Section 1 + + | State | Abbrev | Capital | + | ----: | :----: | ------- | + | Texas | TX | Austin | + | Maine | ME | Augusta | + """ + + document = Export.notebook_to_markdown(notebook) + + assert expected_document == document + end + + test "drops heading 1 and 2 in markdown cells" do + notebook = %{ + Notebook.new() + | name: "My Notebook", + metadata: %{}, + sections: [ + %{ + Notebook.Section.new() + | name: "Section 1", + metadata: %{}, + cells: [ + %{ + Notebook.Cell.new(:markdown) + | metadata: %{}, + source: """ + # Heading 1 + + ## Heading 2 + + ### Heading 3 + """ + } + ] + } + ] + } + + expected_document = """ + # My Notebook + + ## Section 1 + + ### Heading 3 + """ + + document = Export.notebook_to_markdown(notebook) + + assert expected_document == document + end + + test "keeps non-elixir code snippets" do + notebook = %{ + Notebook.new() + | name: "My Notebook", + metadata: %{}, + sections: [ + %{ + Notebook.Section.new() + | name: "Section 1", + metadata: %{}, + cells: [ + %{ + Notebook.Cell.new(:markdown) + | metadata: %{}, + source: """ + ```shell + mix deps.get + ``` + + ```erlang + spawn_link(fun() -> io:format("Hiya") end). + ``` + """ + } + ] + } + ] + } + + expected_document = """ + # My Notebook + + ## Section 1 + + ```shell + mix deps.get + ``` + + ```erlang + spawn_link(fun() -> io:format("Hiya") end). + ``` + """ + + document = Export.notebook_to_markdown(notebook) + + assert expected_document == document + end +end diff --git a/test/live_book/live_markdown/import_test.exs b/test/live_book/live_markdown/import_test.exs new file mode 100644 index 000000000..08fe02477 --- /dev/null +++ b/test/live_book/live_markdown/import_test.exs @@ -0,0 +1,378 @@ +defmodule LiveBook.LiveMarkdown.ImportTest do + use ExUnit.Case, async: true + + alias LiveBook.LiveMarkdown.Import + alias LiveBook.Notebook + + test "acceptance" do + markdown = """ + + # My Notebook + + + ## Section 1 + + + Make sure to install: + + * Erlang + * Elixir + * PostgreSQL + + + ```elixir + Enum.to_list(1..10) + ``` + + This is it for this section. + + ## Section 2 + + ```elixir + # More Elixir code + ``` + """ + + {notebook, []} = Import.notebook_from_markdown(markdown) + + # Match only on the relevant fields as some may be generated (ids). + + assert %Notebook{ + name: "My Notebook", + metadata: %{"author" => "Sherlock Holmes"}, + sections: [ + %Notebook.Section{ + name: "Section 1", + metadata: %{"created_at" => "2021-02-15"}, + cells: [ + %Notebook.Cell{ + type: :markdown, + metadata: %{"updated_at" => "2021-02-15"}, + source: """ + Make sure to install: + + * Erlang + * Elixir + * PostgreSQL\ + """ + }, + %Notebook.Cell{ + type: :elixir, + metadata: %{"readonly" => true}, + source: """ + Enum.to_list(1..10)\ + """ + }, + %Notebook.Cell{ + type: :markdown, + metadata: %{}, + source: """ + This is it for this section.\ + """ + } + ] + }, + %Notebook.Section{ + name: "Section 2", + metadata: %{}, + cells: [ + %Notebook.Cell{ + metadata: %{}, + source: """ + # More Elixir code\ + """ + } + ] + } + ] + } = notebook + end + + test "reformats markdown cells" do + markdown = """ + # My Notebook + + ## Section 1 + + |State|Abbrev|Capital| + | --: | :-: | --- | + | Texas | TX | Austin | + | Maine | ME | Augusta | + """ + + {notebook, []} = Import.notebook_from_markdown(markdown) + + assert %Notebook{ + name: "My Notebook", + sections: [ + %Notebook.Section{ + name: "Section 1", + metadata: %{}, + cells: [ + %Notebook.Cell{ + type: :markdown, + metadata: %{}, + source: """ + | State | Abbrev | Capital | + | ----: | :----: | ------- | + | Texas | TX | Austin | + | Maine | ME | Augusta |\ + """ + } + ] + } + ] + } = notebook + end + + test "uses default name if there is no primary heading" do + markdown = """ + ## Section 1 + + Some markdown. + """ + + {notebook, []} = Import.notebook_from_markdown(markdown) + + assert %Notebook{ + name: "Untitled notebook", + sections: [ + %Notebook.Section{ + name: "Section 1" + } + ] + } = notebook + end + + test "given multiple primary heading, downgrades all headings" do + markdown = """ + # Probably section 1 + + ## Heading + + Some markdown. + + # Probably section 2 + + ###### Tiny heading + """ + + {notebook, messages} = Import.notebook_from_markdown(markdown) + + assert %Notebook{ + name: "Untitled notebook", + sections: [ + %Notebook.Section{ + name: "Probably section 1", + cells: [ + %Notebook.Cell{ + type: :markdown, + metadata: %{}, + source: """ + ### Heading + + Some markdown.\ + """ + } + ] + }, + %Notebook.Section{ + name: "Probably section 2", + cells: [ + %Notebook.Cell{ + type: :markdown, + metadata: %{}, + source: """ + **Tiny heading**\ + """ + } + ] + } + ] + } = notebook + + assert ["Downgrading all headings, because 2 instances of heading 1 were found"] == messages + end + + test "ignores markdown modifiers in notebok/section names" do + markdown = """ + # My *Notebook* + + ## [Section 1](https://example.com) + """ + + {notebook, []} = Import.notebook_from_markdown(markdown) + + assert %Notebook{ + name: "My Notebook", + sections: [ + %Notebook.Section{ + name: "Section 1" + } + ] + } = notebook + end + + test "adds a default section if there is some section-less content" do + markdown = """ + # My Notebook + + Some markdown. + + ## Actual section + """ + + {notebook, []} = Import.notebook_from_markdown(markdown) + + assert %Notebook{ + name: "My Notebook", + sections: [ + %Notebook.Section{ + name: "Section", + cells: [ + %Notebook.Cell{ + type: :markdown, + source: """ + Some markdown.\ + """ + } + ] + }, + %Notebook.Section{ + name: "Actual section" + } + ] + } = notebook + end + + test "uses defaults if there are no headings" do + markdown = """ + ```elixir + Enum.to_list(1..10) + ``` + """ + + {notebook, []} = Import.notebook_from_markdown(markdown) + + assert %Notebook{ + name: "Untitled notebook", + sections: [ + %Notebook.Section{ + name: "Section", + cells: [ + %Notebook.Cell{ + type: :elixir, + source: """ + Enum.to_list(1..10)\ + """ + } + ] + } + ] + } = notebook + end + + test "moves the primary heading and preceding comments to the top" do + markdown = """ + Cool notebook. + + + # My Notebook + + Some markdown. + """ + + {notebook, messages} = Import.notebook_from_markdown(markdown) + + assert %Notebook{ + name: "My Notebook", + metadata: %{"author" => "Sherlock Holmes"}, + sections: [ + %Notebook.Section{ + name: "Section", + cells: [ + %Notebook.Cell{ + type: :markdown, + source: """ + Cool notebook. + + Some markdown.\ + """ + } + ] + } + ] + } = notebook + + assert ["Moving heading 1 to the top of the notebook"] == messages + end + + test "includes parsing warnings in the returned message list" do + markdown = """ + # My notebook + + ` + + Some markdown. + """ + + {_notebook, messages} = Import.notebook_from_markdown(markdown) + + assert ["Line 3: Closing unclosed backquotes ` at end of input"] == messages + end + + test "imports non-elixir code snippets as part of markdown cells" do + markdown = """ + # My Notebook + + ## Section 1 + + ```shell + mix deps.get + ``` + + ```elixir + Enum.to_list(1..10) + ``` + + ```erlang + spawn_link(fun() -> io:format("Hiya") end). + ``` + """ + + {notebook, []} = Import.notebook_from_markdown(markdown) + + assert %Notebook{ + name: "My Notebook", + sections: [ + %Notebook.Section{ + name: "Section 1", + cells: [ + %Notebook.Cell{ + type: :markdown, + source: """ + ```shell + mix deps.get + ```\ + """ + }, + %Notebook.Cell{ + type: :elixir, + source: """ + Enum.to_list(1..10)\ + """ + }, + %Notebook.Cell{ + type: :markdown, + source: """ + ```erlang + spawn_link(fun() -> io:format("Hiya") end). + ```\ + """ + } + ] + } + ] + } = notebook + end +end diff --git a/test/live_book/live_markdown/markdown_helpers_test.exs b/test/live_book/live_markdown/markdown_helpers_test.exs new file mode 100644 index 000000000..feee417c5 --- /dev/null +++ b/test/live_book/live_markdown/markdown_helpers_test.exs @@ -0,0 +1,281 @@ +defmodule LiveBook.LiveMarkdown.MarkdownHelpersTest do + use ExUnit.Case, async: true + + alias LiveBook.LiveMarkdown.MarkdownHelpers + + describe "markdown_from_ast/1" do + test "emphasis" do + markdown = "The Game, *Mrs Hudson*, is on!" + assert markdown == reformat(markdown) + end + + test "bold" do + markdown = "The Game, **Mrs Hudson**, is on!" + assert markdown == reformat(markdown) + end + + test "strikethrough" do + markdown = "The Game, ~~Mrs Hudson~~, is on!" + assert markdown == reformat(markdown) + end + + test "inline code" do + markdown = "The Game, `Mrs Hudson`, is on!" + assert markdown == reformat(markdown) + end + + test "combined" do + markdown = "The Game, ~~***`Mrs Hudson`***~~, is on!" + assert markdown == reformat(markdown) + end + + test "link" do + markdown = "The Game, [Mrs Hudson](https://youtu.be/M-KqaO1oH2E), is on!" + assert markdown == reformat(markdown) + end + + test "basic image" do + markdown = "The Game, ![Mrs Hudson](https://example.com), is on!" + assert markdown == reformat(markdown) + end + + test "image with title" do + markdown = "The Game, ![Mrs Hudson](https://example.com \"Title\"), is on!" + assert markdown == reformat(markdown) + end + + test "img tag with additional attributes is kept as a tag" do + markdown = ~s{Mrs Hudson} + assert markdown == reformat(markdown) + end + + test "oneline comment" do + markdown = "" + assert markdown == reformat(markdown) + end + + test "multiline comment" do + markdown = """ + \ + """ + + assert markdown == reformat(markdown) + end + + test "ruler" do + markdown = "---" + assert markdown == reformat(markdown) + end + + test "paragraph" do + markdown = """ + First paragrpah. + + Second paragraph.\ + """ + + assert markdown == reformat(markdown) + end + + test "heading" do + markdown = """ + # Heading 1 + + ## Heading 2 + + ### Heading 3 + + #### Heading 4 + + ##### Heading 5 + + ###### Heading 6\ + """ + + assert markdown == reformat(markdown) + end + + test "code block" do + markdown = """ + ```elixir + Enum.to_list(1..10) + ```\ + """ + + assert markdown == reformat(markdown) + end + + test "blockquote" do + markdown = """ + > The Game, Mrs Hudson, + > is on!\ + """ + + assert markdown == reformat(markdown) + end + + test "table with header" do + markdown = """ + | State | Abbrev | Capital | + | ----: | :----: | ------- | + | Texas | TX | Austin | + | Maine | ME | Augusta |\ + """ + + assert markdown == reformat(markdown) + end + + test "table without header" do + markdown = """ + | Texas | TX | Austin | + | Maine | ME | Augusta |\ + """ + + assert markdown == reformat(markdown) + end + + test "basic unordered list" do + markdown = """ + * Olafur Arnalds + * Hans Zimmer + * Philip Glass\ + """ + + assert markdown == reformat(markdown) + end + + test "spaced unordered list" do + markdown = """ + * Olafur Arnalds + + * Hans Zimmer + + * Philip Glass\ + """ + + assert markdown == reformat(markdown) + end + + test "unordered list with block items" do + markdown = """ + * Quote + + > We can't control what happens to us, only how it affects us and the choices we make. + + * Code + + ```elixir + Enum.to_list(1..10) + ```\ + """ + + assert markdown == reformat(markdown) + end + + test "nested unordered list" do + markdown = """ + * Olafur Arnalds + * Particles + * Doria + * Hans Zimmer + * Time + * Philip Glass + * Opening + * The Poet Acts\ + """ + + assert markdown == reformat(markdown) + end + + test "basic ordered list" do + markdown = """ + 1. Olafur Arnalds + 2. Hans Zimmer + 3. Philip Glass\ + """ + + assert markdown == reformat(markdown) + end + + test "spaced ordered list" do + markdown = """ + 1. Olafur Arnalds + + 2. Hans Zimmer + + 3. Philip Glass\ + """ + + assert markdown == reformat(markdown) + end + + test "with block items ordered list" do + markdown = """ + 1. Quote + + > We can't control what happens to us, only how it affects us and the choices we make. + + 2. Code + + ```elixir + Enum.to_list(1..10) + ```\ + """ + + assert markdown == reformat(markdown) + end + + test "ordered list: nested" do + markdown = """ + 1. Olafur Arnalds + 1. Particles + 2. Doria + 2. Hans Zimmer + 1. Time + 3. Philip Glass + 1. Opening + 2. The Poet Acts\ + """ + + assert markdown == reformat(markdown) + end + + test "raw html" do + markdown = """ +
+ Some content +
\ + """ + + assert markdown == reformat(markdown) + end + + test "separates blocks with a single line" do + markdown = """ + The first paragraph, + with multiple lines. + + > We can't control what happens to us, + > only how it affects us and the choices we make. + + ```elixir + Enum.to_list(1..10) + ``` + + Another paragraph.\ + """ + + assert markdown == reformat(markdown) + end + + # By reformatting we can assert correct rendering + # by comparing against the original content. + defp reformat(markdown) do + {:ok, ast, []} = EarmarkParser.as_ast(markdown) + MarkdownHelpers.markdown_from_ast(ast) + end + end +end