Markdown snippets (#56)

* Extend LiveMarkdown format to support Elixir snippets in Markdown cells

* Highlight Markdown code blocks using Monaco editor API

* Use livebook metadata for forcing markdown as well
This commit is contained in:
Jonatan Kłosko 2021-02-24 15:41:00 +01:00 committed by GitHub
parent cc630dc9da
commit 9fed524ed5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 243 additions and 34 deletions

View file

@ -71,7 +71,7 @@
@apply hidden; @apply hidden;
} }
.radio-button .radio-button__input[checked] + .radio-button__label { .radio-button .radio-button__input[checked] + .radio-button__label {
@apply bg-gray-700 text-white; @apply bg-gray-700 text-white;
} }

View file

@ -198,8 +198,8 @@ const ElixirMonarchLanguage = {
"@declarationKeywords": "keyword.declaration", "@declarationKeywords": "keyword.declaration",
"@namespaceKeywords": "keyword", "@namespaceKeywords": "keyword",
"@otherKeywords": "keyword", "@otherKeywords": "keyword",
"@default": "function.call" "@default": "function.call",
} },
}, },
], ],
[ [

View file

@ -1,6 +1,23 @@
import marked from "marked"; import marked from "marked";
import morphdom from "morphdom"; import morphdom from "morphdom";
import DOMPurify from "dompurify"; import DOMPurify from "dompurify";
import monaco from "./live_editor/monaco";
// Reuse Monaco highlighter for Markdown code blocks
marked.setOptions({
highlight: (code, lang, callback) => {
monaco.editor
.colorize(code, lang)
.then((result) => {
// `colorize` always adds additional newline, so we remove it
result = result.replace(/<br\/>$/, "");
callback(null, result);
})
.catch((error) => {
callback(error, null);
});
},
});
/** /**
* Renders markdown content in the given container. * Renders markdown content in the given container.
@ -19,27 +36,31 @@ class Markdown {
} }
__render() { __render() {
const html = this.__getHtml(); this.__getHtml().then((html) => {
// Wrap the HTML in another element, so that we // Wrap the HTML in another element, so that we
// can use morphdom's childrenOnly option. // can use morphdom's childrenOnly option.
const wrappedHtml = `<div>${html}</div>`; const wrappedHtml = `<div>${html}</div>`;
morphdom(this.container, wrappedHtml, { childrenOnly: true }); morphdom(this.container, wrappedHtml, { childrenOnly: true });
});
} }
__getHtml() { __getHtml() {
const html = marked(this.content); return new Promise((resolve, reject) => {
const sanitizedHtml = DOMPurify.sanitize(html); marked(this.content, (error, html) => {
const sanitizedHtml = DOMPurify.sanitize(html);
if (sanitizedHtml) { if (sanitizedHtml) {
return sanitizedHtml; resolve(sanitizedHtml);
} else { } else {
return ` resolve(`
<div class="text-gray-300"> <div class="text-gray-300">
Empty markdown cell Empty markdown cell
</div> </div>
`; `);
} }
});
});
} }
} }

View file

@ -15,8 +15,10 @@ defmodule LiveBook.LiveMarkdown do
# 2. Every *Heading 2* starts a new section. # 2. Every *Heading 2* starts a new section.
# 3. Every Elixir code block maps to an Elixir cell. # 3. Every Elixir code block maps to an Elixir cell.
# 4. Adjacent regular Markdown text maps to a Markdown cell. # 4. Adjacent regular Markdown text maps to a Markdown cell.
# 5. Comments of the form `<!--live_book:json_object-->` hold metadata # 5. Comments of the form `<!-- livebook:json_object -->` hold metadata
# any apply to the element they directly precede (e.g. an Elixir cell). # any apply to the element they directly precede (e.g. an Elixir cell).
# Such comments may appear anywhere, for instance `<!-- livebook:{"force_markdown":true} -->`
# forces the next Markdown block to be treated as prat of Markdown cell (even if it's Elixir code block).
# #
# ## Example # ## Example
# #
@ -32,7 +34,8 @@ defmodule LiveBook.LiveMarkdown do
# * Elixir # * Elixir
# * PostgreSQL # * PostgreSQL
# #
# <!--live_book:{"readonly":true}--> # <!-- livebook:{"readonly":true} -->
#
# ```elixir # ```elixir
# Enum.to_list(1..10) # Enum.to_list(1..10)
# ``` # ```

View file

@ -47,14 +47,14 @@ defmodule LiveBook.LiveMarkdown.Export do
defp render_metadata(metadata) do defp render_metadata(metadata) do
metadata_json = Jason.encode!(metadata) metadata_json = Jason.encode!(metadata)
"<!--live_book:#{metadata_json}-->" "<!-- livebook:#{metadata_json} -->"
end end
defp prepend_metadata(iodata, metadata) when metadata == %{}, do: iodata defp prepend_metadata(iodata, metadata) when metadata == %{}, do: iodata
defp prepend_metadata(iodata, metadata) do defp prepend_metadata(iodata, metadata) do
content = render_metadata(metadata) content = render_metadata(metadata)
[content, "\n", iodata] [content, "\n\n", iodata]
end end
defp format_markdown_source(markdown) do defp format_markdown_source(markdown) do
@ -69,6 +69,7 @@ defmodule LiveBook.LiveMarkdown.Export do
defp rewrite_ast(ast) do defp rewrite_ast(ast) do
ast ast
|> remove_reserved_headings() |> remove_reserved_headings()
|> add_markdown_annotation_before_elixir_block()
end end
defp remove_reserved_headings(ast) do defp remove_reserved_headings(ast) do
@ -78,4 +79,14 @@ defmodule LiveBook.LiveMarkdown.Export do
_ast_node -> true _ast_node -> true
end) end)
end end
defp add_markdown_annotation_before_elixir_block(ast) do
Enum.flat_map(ast, fn
{"pre", _, [{"code", [{"class", "elixir"}], [_source], %{}}], %{}} = ast_node ->
[{:comment, [], [~s/livebook:{"force_markdown":true}/], %{comment: true}}, ast_node]
ast_node ->
[ast_node]
end)
end
end end

View file

@ -32,6 +32,7 @@ defmodule LiveBook.LiveMarkdown.Import do
defp rewrite_ast(ast) do defp rewrite_ast(ast) do
{ast, messages1} = rewrite_multiple_primary_headings(ast) {ast, messages1} = rewrite_multiple_primary_headings(ast)
{ast, messages2} = move_primary_heading_top(ast) {ast, messages2} = move_primary_heading_top(ast)
ast = trim_comments(ast)
{ast, messages1 ++ messages2} {ast, messages1 ++ messages2}
end end
@ -92,6 +93,18 @@ defmodule LiveBook.LiveMarkdown.Import do
{Enum.reverse(left_rev), Enum.reverse(right_rev)} {Enum.reverse(left_rev), Enum.reverse(right_rev)}
end end
# Trims one-line comments to allow nice pattern matching
# on LiveBook-specific annotations with no regard to surrounding whitespace.
defp trim_comments(ast) do
Enum.map(ast, fn
{:comment, attrs, [line], %{comment: true}} ->
{:comment, attrs, [String.trim(line)], %{comment: true}}
ast_node ->
ast_node
end)
end
# Builds a list of classified elements from the AST. # Builds a list of classified elements from the AST.
defp group_elements(ast, elems \\ []) defp group_elements(ast, elems \\ [])
@ -105,8 +118,30 @@ defmodule LiveBook.LiveMarkdown.Import do
group_elements(ast, [{:section_name, content} | elems]) group_elements(ast, [{:section_name, content} | elems])
end end
# The <!-- livebook:{"force_markdown":true} --> annotation forces the next node
# to be interpreted as Markdown cell content.
defp group_elements( defp group_elements(
[{:comment, _, ["live_book:" <> metadata_json], %{comment: true}} | ast], [
{:comment, _, [~s/livebook:{"force_markdown":true}/], %{comment: true}},
ast_node | ast
],
[{:cell, :markdown, md_ast} | rest]
) do
group_elements(ast, [{:cell, :markdown, [ast_node | md_ast]} | rest])
end
defp group_elements(
[
{:comment, _, [~s/livebook:{"force_markdown":true}/], %{comment: true}},
ast_node | ast
],
elems
) do
group_elements(ast, [{:cell, :markdown, [ast_node]} | elems])
end
defp group_elements(
[{:comment, _, ["livebook:" <> metadata_json], %{comment: true}} | ast],
elems elems
) do ) do
group_elements(ast, [{:metadata, metadata_json} | elems]) group_elements(ast, [{:metadata, metadata_json} | elems])

View file

@ -89,7 +89,7 @@ defmodule LiveBook.LiveMarkdown.MarkdownHelpers do
defp build_md(iodata, [{:comment, _, lines, %{comment: true}} | ast]) do defp build_md(iodata, [{:comment, _, lines, %{comment: true}} | ast]) do
render_comment(lines) render_comment(lines)
|> append_inline(iodata) |> append_block(iodata)
|> build_md(ast) |> build_md(ast)
end end

View file

@ -60,20 +60,24 @@ defmodule LiveBook.LiveMarkdown.ExportTest do
} }
expected_document = """ expected_document = """
<!--live_book:{"author":"Sherlock Holmes"}--> <!-- livebook:{"author":"Sherlock Holmes"} -->
# My Notebook # My Notebook
<!--live_book:{"created_at":"2021-02-15"}--> <!-- livebook:{"created_at":"2021-02-15"} -->
## Section 1 ## Section 1
<!--live_book:{"updated_at":"2021-02-15"}--> <!-- livebook:{"updated_at":"2021-02-15"} -->
Make sure to install: Make sure to install:
* Erlang * Erlang
* Elixir * Elixir
* PostgreSQL * PostgreSQL
<!--live_book:{"readonly":true}--> <!-- livebook:{"readonly":true} -->
```elixir ```elixir
Enum.to_list(1..10) Enum.to_list(1..10)
``` ```
@ -221,4 +225,74 @@ defmodule LiveBook.LiveMarkdown.ExportTest do
assert expected_document == document assert expected_document == document
end end
test "marks elixir snippets in markdown cells as such" do
notebook = %{
Notebook.new()
| name: "My Notebook",
metadata: %{},
sections: [
%{
Notebook.Section.new()
| name: "Section 1",
metadata: %{},
cells: [
%{
Notebook.Cell.new(:markdown)
| metadata: %{},
source: """
```elixir
[1, 2, 3]
```\
"""
}
]
},
%{
Notebook.Section.new()
| name: "Section 2",
metadata: %{},
cells: [
%{
Notebook.Cell.new(:markdown)
| metadata: %{},
source: """
Some markdown.
```elixir
[1, 2, 3]
```\
"""
}
]
}
]
}
expected_document = """
# My Notebook
## Section 1
<!-- livebook:{"force_markdown":true} -->
```elixir
[1, 2, 3]
```
## Section 2
Some markdown.
<!-- livebook:{"force_markdown":true} -->
```elixir
[1, 2, 3]
```
"""
document = Export.notebook_to_markdown(notebook)
assert expected_document == document
end
end end

View file

@ -6,20 +6,24 @@ defmodule LiveBook.LiveMarkdown.ImportTest do
test "acceptance" do test "acceptance" do
markdown = """ markdown = """
<!--live_book:{"author":"Sherlock Holmes"}--> <!-- livebook:{"author":"Sherlock Holmes"} -->
# My Notebook # My Notebook
<!--live_book:{"created_at":"2021-02-15"}--> <!-- livebook:{"created_at":"2021-02-15"} -->
## Section 1 ## Section 1
<!--live_book:{"updated_at":"2021-02-15"}--> <!-- livebook:{"updated_at":"2021-02-15"} -->
Make sure to install: Make sure to install:
* Erlang * Erlang
* Elixir * Elixir
* PostgreSQL * PostgreSQL
<!--live_book:{"readonly":true}--> <!-- livebook:{"readonly":true} -->
```elixir ```elixir
Enum.to_list(1..10) Enum.to_list(1..10)
``` ```
@ -276,7 +280,8 @@ defmodule LiveBook.LiveMarkdown.ImportTest do
markdown = """ markdown = """
Cool notebook. Cool notebook.
<!--live_book:{"author":"Sherlock Holmes"}--> <!-- livebook:{"author":"Sherlock Holmes"} -->
# My Notebook # My Notebook
Some markdown. Some markdown.
@ -375,4 +380,64 @@ defmodule LiveBook.LiveMarkdown.ImportTest do
] ]
} = notebook } = notebook
end end
test "imports elixir snippets as part of markdown cells if marked as such" do
markdown = """
# My Notebook
## Section 1
<!-- livebook:{"force_markdown":true} -->
```elixir
[1, 2, 3]
```
## Section 2
Some markdown.
<!-- livebook:{"force_markdown":true} -->
```elixir
[1, 2, 3]
```
"""
{notebook, []} = Import.notebook_from_markdown(markdown)
assert %Notebook{
name: "My Notebook",
sections: [
%Notebook.Section{
name: "Section 1",
cells: [
%Notebook.Cell{
type: :markdown,
source: """
```elixir
[1, 2, 3]
```\
"""
}
]
},
%Notebook.Section{
name: "Section 2",
cells: [
%Notebook.Cell{
type: :markdown,
source: """
Some markdown.
```elixir
[1, 2, 3]
```\
"""
}
]
}
]
} = notebook
end
end end