mirror of
https://github.com/livebook-dev/livebook.git
synced 2025-10-12 14:36:20 +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 {
|
.markdown h1 {
|
||||||
@apply text-gray-900 font-semibold text-3xl my-4;
|
@apply text-gray-900 font-semibold text-4xl my-4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdown h2 {
|
.markdown h2 {
|
||||||
@apply text-gray-900 font-semibold text-2xl my-4;
|
@apply text-gray-900 font-semibold text-3xl my-4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdown h3 {
|
.markdown h3 {
|
||||||
|
@apply text-gray-900 font-semibold text-2xl my-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown h4 {
|
||||||
@apply text-gray-900 font-semibold text-xl my-4;
|
@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 {
|
.markdown p {
|
||||||
@apply my-4;
|
@apply my-4;
|
||||||
}
|
}
|
||||||
|
@ -97,3 +109,16 @@
|
||||||
.markdown :last-child {
|
.markdown :last-child {
|
||||||
@apply mb-0;
|
@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(),
|
name: String.t(),
|
||||||
version: String.t(),
|
version: String.t(),
|
||||||
sections: list(Section.t()),
|
sections: list(Section.t()),
|
||||||
metadata: %{atom() => term()}
|
metadata: %{String.t() => term()}
|
||||||
}
|
}
|
||||||
|
|
||||||
@version "1.0"
|
@version "1.0"
|
||||||
|
|
|
@ -19,7 +19,7 @@ defmodule LiveBook.Notebook.Cell do
|
||||||
type: type(),
|
type: type(),
|
||||||
source: String.t(),
|
source: String.t(),
|
||||||
outputs: list(),
|
outputs: list(),
|
||||||
metadata: %{atom() => term()}
|
metadata: %{String.t() => term()}
|
||||||
}
|
}
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
|
|
|
@ -17,7 +17,7 @@ defmodule LiveBook.Notebook.Section do
|
||||||
id: id(),
|
id: id(),
|
||||||
name: String.t(),
|
name: String.t(),
|
||||||
cells: list(Cell.t()),
|
cells: list(Cell.t()),
|
||||||
metadata: %{atom() => term()}
|
metadata: %{String.t() => term()}
|
||||||
}
|
}
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
|
|
|
@ -3,7 +3,7 @@ defmodule LiveBookWeb.CellComponent do
|
||||||
|
|
||||||
def render(assigns) do
|
def render(assigns) do
|
||||||
~L"""
|
~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 %>"
|
id="cell-<%= @cell.id %>"
|
||||||
phx-hook="Cell"
|
phx-hook="Cell"
|
||||||
data-cell-id="<%= @cell.id %>"
|
data-cell-id="<%= @cell.id %>"
|
||||||
|
|
3
mix.exs
3
mix.exs
|
@ -41,7 +41,8 @@ defmodule LiveBook.MixProject do
|
||||||
{:telemetry_metrics, "~> 0.4"},
|
{:telemetry_metrics, "~> 0.4"},
|
||||||
{:telemetry_poller, "~> 0.4"},
|
{:telemetry_poller, "~> 0.4"},
|
||||||
{:jason, "~> 1.0"},
|
{:jason, "~> 1.0"},
|
||||||
{:plug_cowboy, "~> 2.0"}
|
{:plug_cowboy, "~> 2.0"},
|
||||||
|
{:earmark_parser, "~> 1.4"}
|
||||||
]
|
]
|
||||||
end
|
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": {: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"},
|
"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"},
|
"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"},
|
"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"},
|
"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"},
|
"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"},
|
"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": {: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_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_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"},
|
"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": {: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"},
|
"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…
Add table
Reference in a new issue