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
This commit is contained in:
Jonatan Kłosko 2021-02-16 18:39:52 +01:00 committed by GitHub
parent 309c9c8a51
commit 6ac7f94897
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 1611 additions and 9 deletions

View file

@ -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.";
}

View file

@ -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 `<!--live_book:json_object-->` 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
#
# <!--live_book:{"readonly":true}-->
# ```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

View file

@ -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)
"<!--live_book:#{metadata_json}-->"
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

View file

@ -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

View file

@ -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</#{tag}>"
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)
"<!-- #{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(["<!--" | lines] ++ ["-->"], "\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

View file

@ -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"

View file

@ -19,7 +19,7 @@ defmodule LiveBook.Notebook.Cell do
type: type(),
source: String.t(),
outputs: list(),
metadata: %{atom() => term()}
metadata: %{String.t() => term()}
}
@doc """

View file

@ -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 """

View file

@ -3,7 +3,7 @@ defmodule LiveBookWeb.CellComponent do
def render(assigns) do
~L"""
<div class="flex flex-col relative mr-10 border-l-4 pl-4 -ml-4 border-blue-100 border-opacity-0 hover:border-opacity-100 <%= if @focused, do: "border-blue-300 border-opacity-100"%>"
<div class="cell flex flex-col relative mr-10 border-l-4 pl-4 -ml-4 border-blue-100 border-opacity-0 hover:border-opacity-100 <%= if @focused, do: "border-blue-300 border-opacity-100"%>"
id="cell-<%= @cell.id %>"
phx-hook="Cell"
data-cell-id="<%= @cell.id %>"

View file

@ -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

View file

@ -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"},

View file

@ -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 = """
<!--live_book:{"author":"Sherlock Holmes"}-->
# My Notebook
<!--live_book:{"created_at":"2021-02-15"}-->
## Section 1
<!--live_book:{"updated_at":"2021-02-15"}-->
Make sure to install:
* Erlang
* Elixir
* PostgreSQL
<!--live_book:{"readonly":true}-->
```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

View file

@ -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 = """
<!--live_book:{"author":"Sherlock Holmes"}-->
# My Notebook
<!--live_book:{"created_at":"2021-02-15"}-->
## Section 1
<!--live_book:{"updated_at":"2021-02-15"}-->
Make sure to install:
* Erlang
* Elixir
* PostgreSQL
<!--live_book:{"readonly":true}-->
```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.
<!--live_book:{"author":"Sherlock Holmes"}-->
# 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

View file

@ -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{<img src="https://example.com" alt="Mrs Hudson" width="300" />}
assert markdown == reformat(markdown)
end
test "oneline comment" do
markdown = "<!-- The Game, Mrs Hudson, is on! -->"
assert markdown == reformat(markdown)
end
test "multiline comment" do
markdown = """
<!--
The Game, Mrs Hudson,
is on!
-->\
"""
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 = """
<div class="box" aria-label="box">
Some content
</div>\
"""
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