mirror of
https://github.com/livebook-dev/livebook.git
synced 2025-02-26 07:52:59 +08:00
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:
parent
309c9c8a51
commit
6ac7f94897
14 changed files with 1611 additions and 9 deletions
|
@ -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.";
|
||||
}
|
||||
|
|
50
lib/live_book/live_markdown.ex
Normal file
50
lib/live_book/live_markdown.ex
Normal 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
|
81
lib/live_book/live_markdown/export.ex
Normal file
81
lib/live_book/live_markdown/export.ex
Normal 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
|
187
lib/live_book/live_markdown/import.ex
Normal file
187
lib/live_book/live_markdown/import.ex
Normal 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
|
374
lib/live_book/live_markdown/markdown_helpers.ex
Normal file
374
lib/live_book/live_markdown/markdown_helpers.ex
Normal 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
|
||||
""
|
||||
else
|
||||
~s//
|
||||
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
|
|
@ -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"
|
||||
|
|
|
@ -19,7 +19,7 @@ defmodule LiveBook.Notebook.Cell do
|
|||
type: type(),
|
||||
source: String.t(),
|
||||
outputs: list(),
|
||||
metadata: %{atom() => term()}
|
||||
metadata: %{String.t() => term()}
|
||||
}
|
||||
|
||||
@doc """
|
||||
|
|
|
@ -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 """
|
||||
|
|
|
@ -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 %>"
|
||||
|
|
3
mix.exs
3
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
|
||||
|
||||
|
|
5
mix.lock
5
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"},
|
||||
|
|
224
test/live_book/live_markdown/export_test.exs
Normal file
224
test/live_book/live_markdown/export_test.exs
Normal 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
|
378
test/live_book/live_markdown/import_test.exs
Normal file
378
test/live_book/live_markdown/import_test.exs
Normal 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
|
281
test/live_book/live_markdown/markdown_helpers_test.exs
Normal file
281
test/live_book/live_markdown/markdown_helpers_test.exs
Normal 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, , is on!"
|
||||
assert markdown == reformat(markdown)
|
||||
end
|
||||
|
||||
test "image with title" do
|
||||
markdown = "The Game, , 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
|
Loading…
Reference in a new issue