prosemirror 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +14 -0
  3. data/README.md +49 -4
  4. data/ROADMAP.md +9 -5
  5. data/TYPES.md +55 -0
  6. data/lib/prosemirror/checker.rb +2 -2
  7. data/lib/prosemirror/coerce.rb +7 -0
  8. data/lib/prosemirror/document.rb +6 -0
  9. data/lib/prosemirror/html/element.rb +1 -1
  10. data/lib/prosemirror/html/selector.rb +12 -4
  11. data/lib/prosemirror/html_parser.rb +9 -2
  12. data/lib/prosemirror/json_parser.rb +71 -40
  13. data/lib/prosemirror/mark.rb +8 -3
  14. data/lib/prosemirror/mark_type.rb +15 -0
  15. data/lib/prosemirror/markdown/builder.rb +65 -13
  16. data/lib/prosemirror/markdown_serializer.rb +518 -0
  17. data/lib/prosemirror/marks/basic/code.rb +2 -0
  18. data/lib/prosemirror/marks/basic/em.rb +2 -0
  19. data/lib/prosemirror/marks/basic/link.rb +2 -0
  20. data/lib/prosemirror/marks/basic/strong.rb +2 -0
  21. data/lib/prosemirror/marks/bold.rb +2 -0
  22. data/lib/prosemirror/marks/code.rb +2 -0
  23. data/lib/prosemirror/marks/italic.rb +2 -0
  24. data/lib/prosemirror/marks/link.rb +2 -0
  25. data/lib/prosemirror/marks/strike.rb +2 -0
  26. data/lib/prosemirror/node.rb +21 -6
  27. data/lib/prosemirror/node_type.rb +13 -0
  28. data/lib/prosemirror/nodes/basic/code_block.rb +4 -0
  29. data/lib/prosemirror/nodes/basic/image.rb +4 -0
  30. data/lib/prosemirror/nodes/basic/ordered_list.rb +4 -0
  31. data/lib/prosemirror/nodes/blockquote.rb +4 -0
  32. data/lib/prosemirror/nodes/bullet_list.rb +15 -0
  33. data/lib/prosemirror/nodes/code_block.rb +15 -0
  34. data/lib/prosemirror/nodes/details.rb +27 -0
  35. data/lib/prosemirror/nodes/details_content.rb +12 -0
  36. data/lib/prosemirror/nodes/details_summary.rb +12 -0
  37. data/lib/prosemirror/nodes/hard_break.rb +5 -0
  38. data/lib/prosemirror/nodes/heading.rb +6 -0
  39. data/lib/prosemirror/nodes/horizontal_rule.rb +5 -0
  40. data/lib/prosemirror/nodes/image.rb +16 -0
  41. data/lib/prosemirror/nodes/list_item.rb +4 -0
  42. data/lib/prosemirror/nodes/mention.rb +36 -0
  43. data/lib/prosemirror/nodes/ordered_list.rb +11 -0
  44. data/lib/prosemirror/nodes/paragraph.rb +8 -0
  45. data/lib/prosemirror/nodes/table.rb +36 -0
  46. data/lib/prosemirror/nodes/task_item.rb +40 -0
  47. data/lib/prosemirror/nodes/task_list.rb +24 -0
  48. data/lib/prosemirror/nodes/text.rb +4 -0
  49. data/lib/prosemirror/nodes/twitch.rb +94 -0
  50. data/lib/prosemirror/nodes/youtube.rb +71 -0
  51. data/lib/prosemirror/schema.rb +22 -6
  52. data/lib/prosemirror/text_align.rb +31 -0
  53. data/lib/prosemirror/type_definition.rb +33 -13
  54. data/lib/prosemirror/version.rb +1 -1
  55. metadata +11 -1
@@ -10,6 +10,8 @@ module Prosemirror
10
10
  parse_html style: "font-weight=400", clear_mark: true
11
11
  parse_html(style: "font-weight") { |value| value.match?(Bold::BOLD) && {} }
12
12
 
13
+ markdown open: "**", close: "**", mixable: true, expel_enclosing_whitespace: true
14
+
13
15
  def view_template(&) = strong(&)
14
16
  end
15
17
  end
@@ -13,6 +13,8 @@ module Prosemirror
13
13
  parse_html style: "font-weight=400", clear_mark: true
14
14
  parse_html(style: "font-weight") { |value| value.match?(BOLD) && {} }
15
15
 
16
+ markdown open: "**", close: "**", mixable: true, expel_enclosing_whitespace: true
17
+
16
18
  def view_template(&) = strong(&)
17
19
  end
18
20
  end
@@ -10,6 +10,8 @@ module Prosemirror
10
10
  parse_html tag: "code"
11
11
  parse_html tag: "tt"
12
12
 
13
+ markdown(**MarkdownSerializer::CODE)
14
+
13
15
  def view_template(&) = code(&)
14
16
  end
15
17
  end
@@ -9,6 +9,8 @@ module Prosemirror
9
9
  parse_html style: "font-style=normal", clear_mark: true
10
10
  parse_html style: "font-style=italic"
11
11
 
12
+ markdown open: "*", close: "*", mixable: true, expel_enclosing_whitespace: true
13
+
12
14
  def view_template(&) = em(&)
13
15
  end
14
16
  end
@@ -11,6 +11,8 @@ module Prosemirror
11
11
  prop :title, _String?
12
12
  parse_html(tag: "a[href]") { |element, schema| schema.allowed_url?(element["href"]) && {} }
13
13
 
14
+ markdown(**MarkdownSerializer::LINK)
15
+
14
16
  def view_template(&)
15
17
  href = safe_url(@href)
16
18
  return yield unless href
@@ -8,6 +8,8 @@ module Prosemirror
8
8
  parse_html tag: "strike"
9
9
  parse_html(style: "text-decoration", consuming: false) { |value| value.include?("line-through") ? {} : false }
10
10
 
11
+ markdown open: "~~", close: "~~", mixable: true, expel_enclosing_whitespace: true
12
+
11
13
  def view_template(&) = s(&)
12
14
  end
13
15
  end
@@ -14,15 +14,30 @@ module Prosemirror
14
14
  # Whitespace, including non-breaking spaces.
15
15
  BLANK = /\A[[:space:]]*\z/
16
16
 
17
- prop :type, Symbol, &Coerce::SYMBOL
18
- prop :attrs, _Hash(Symbol, _Any?), default: {}.freeze, &Coerce::FROZEN
19
- prop :unknown_attrs, _Hash(String, _Any?), default: {}.freeze, &Coerce::FROZEN
20
- prop :content, _Array(Node), default: [].freeze, &Coerce::FROZEN
21
- prop :marks, _Array(Mark), default: [].freeze, &Coerce::FROZEN
22
- prop :text, _String?, &Coerce::FROZEN
17
+ EMPTY_HASH = {}.freeze
18
+ EMPTY_ARRAY = [].freeze
19
+
20
+ prop :type, Symbol
21
+ prop :attrs, _Hash(Symbol, _Any?), default: EMPTY_HASH
22
+ prop :unknown_attrs, _Hash(String, _Any?), default: EMPTY_HASH
23
+ prop :content, _Array(Node), default: EMPTY_ARRAY
24
+ prop :marks, _Array(Mark), default: EMPTY_ARRAY
25
+ prop :text, _String?
26
+
27
+ # The type as a Symbol, and frozen values. Done here rather than with Literal coercion blocks, which
28
+ # cost an `instance_exec` each, for each of the many nodes a document has.
29
+ def initialize(type:, attrs: EMPTY_HASH, unknown_attrs: EMPTY_HASH, content: EMPTY_ARRAY, marks: EMPTY_ARRAY, text: nil)
30
+ super(type: type.is_a?(String) ? type.to_sym : type, attrs: Coerce.frozen(attrs), unknown_attrs: Coerce.frozen(unknown_attrs),
31
+ content: Coerce.frozen(content), marks: Coerce.frozen(marks), text: Coerce.frozen(text))
32
+ end
23
33
 
24
34
  # The nodes with adjacent text nodes that have the same marks joined, as in ProseMirror.
25
35
  def self.join_text(nodes)
36
+ return nodes unless (1...nodes.size).any? do |index|
37
+ previous, node = nodes[index - 1], nodes[index]
38
+ previous.text? && node.text? && previous.marks == node.marks
39
+ end
40
+
26
41
  nodes.each_with_object([]) do |node, joined|
27
42
  previous = joined.last
28
43
  if previous&.text? && node.text? && previous.marks == node.marks
@@ -119,5 +119,18 @@ module Prosemirror
119
119
  def view_template
120
120
  yield if block_given?
121
121
  end
122
+
123
+ # Write the node as Markdown, with the MarkdownSerializer's methods. `parent` and `index` locate the node.
124
+ # By default, the content is written, as a block for block nodes.
125
+ def to_markdown(markdown, _parent, _index)
126
+ return if self.class.leaf?
127
+
128
+ if markdown.schema.textblock?(self.class)
129
+ markdown.render_inline(node)
130
+ else
131
+ markdown.render_content(node)
132
+ end
133
+ markdown.close_block(node) if self.class.block?
134
+ end
122
135
  end
123
136
  end
@@ -14,6 +14,10 @@ module Prosemirror
14
14
  def view_template(&)
15
15
  pre { code(&) }
16
16
  end
17
+
18
+ def to_markdown(markdown, _parent, _index)
19
+ Nodes::CodeBlock.fenced(markdown, node, nil)
20
+ end
17
21
  end
18
22
  end
19
23
  end
@@ -17,6 +17,10 @@ module Prosemirror
17
17
  src = safe_url(@src)
18
18
  img(src: src, alt: @alt, title: @title) if src
19
19
  end
20
+
21
+ def to_markdown(markdown, _parent, _index)
22
+ markdown.write(Nodes::Image.markdown_image(markdown, @src, @alt, @title))
23
+ end
20
24
  end
21
25
  end
22
26
  end
@@ -16,6 +16,10 @@ module Prosemirror
16
16
  def view_template(&)
17
17
  ol(start: (@order unless @order == 1), &)
18
18
  end
19
+
20
+ def to_markdown(markdown, parent, index)
21
+ Nodes::OrderedList.numbered(markdown, node, @order, markdown.list_after_list?(parent, index) ? ")" : ".")
22
+ end
19
23
  end
20
24
  end
21
25
  end
@@ -8,6 +8,10 @@ module Prosemirror
8
8
  parse_html tag: "blockquote"
9
9
 
10
10
  def view_template(&) = blockquote(&)
11
+
12
+ def to_markdown(markdown, _parent, _index)
13
+ markdown.wrap_block("> ", nil, node) { markdown.render_content(node) }
14
+ end
11
15
  end
12
16
  end
13
17
  end
@@ -8,6 +8,21 @@ module Prosemirror
8
8
  parse_html tag: "ul"
9
9
 
10
10
  def view_template(&) = ul(&)
11
+
12
+ # `-` items, or `*` right after another bullet or task list, which would otherwise continue it.
13
+ def to_markdown(markdown, parent, index)
14
+ marker = self.class.marker(markdown, parent, index)
15
+ markdown.render_list(node, " ") { marker }
16
+ end
17
+
18
+ # Bullet and task lists share markers.
19
+ def self.marker(markdown, parent, index)
20
+ after_list = markdown.list_after_list?(parent, index) do |sibling|
21
+ type = markdown.schema.node_type(sibling.type)
22
+ !type.nil? && (type <= BulletList || type <= TaskList)
23
+ end
24
+ after_list ? "* " : "- "
25
+ end
11
26
  end
12
27
  end
13
28
  end
@@ -19,6 +19,21 @@ module Prosemirror
19
19
  def view_template(&)
20
20
  pre { code(class: @language && "language-#{@language}", &) }
21
21
  end
22
+
23
+ def to_markdown(markdown, _parent, _index)
24
+ self.class.fenced(markdown, node, @language)
25
+ end
26
+
27
+ # A fenced code block, with a fence longer than any run of backticks in the code.
28
+ def self.fenced(markdown, node, language)
29
+ code = node.text_content
30
+ fence = "`" * [3, (code.scan(/`{3,}/).map(&:length).max || 0) + 1].max
31
+ markdown.write("#{fence}#{language}\n")
32
+ markdown.text(code, escape: false)
33
+ markdown.ensure_new_line
34
+ markdown.write(fence)
35
+ markdown.close_block(node)
36
+ end
22
37
  end
23
38
  end
24
39
  end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prosemirror
4
+ module Nodes
5
+ # Tiptap's details: a summary, and content shown when it's open. `open` is the attribute Tiptap keeps with
6
+ # `persist: true`.
7
+ class Details < NodeType
8
+ group :block
9
+ content "details_summary details_content"
10
+
11
+ prop :open, _Boolean, default: false, parse_html: ->(element) { element.key?("open") }
12
+ parse_html tag: "details"
13
+
14
+ def view_template(&) = details(open: @open, &)
15
+
16
+ # As HTML, which GitHub and Markdown import read, with the content in Markdown between blank lines.
17
+ def to_markdown(markdown, _parent, _index)
18
+ summary, content = node.content
19
+ markdown.text("<details#{" open" if @open}>\n#{markdown.node_html(summary)}", escape: false)
20
+ markdown.close_block(node)
21
+ markdown.render(content, node, 1)
22
+ markdown.text("</details>", escape: false)
23
+ markdown.close_block(node)
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prosemirror
4
+ module Nodes
5
+ class DetailsContent < NodeType
6
+ content "block+"
7
+ parse_html tag: %(div[data-type="detailsContent"])
8
+
9
+ def view_template(&) = div(data_type: "detailsContent", &)
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prosemirror
4
+ module Nodes
5
+ class DetailsSummary < NodeType
6
+ content "text*"
7
+ parse_html tag: "summary"
8
+
9
+ def view_template(&) = summary(&)
10
+ end
11
+ end
12
+ end
@@ -10,6 +10,11 @@ module Prosemirror
10
10
  parse_html tag: "br"
11
11
 
12
12
  def view_template = br
13
+
14
+ # A backslash before the newline, unless only line breaks follow.
15
+ def to_markdown(markdown, parent, index)
16
+ markdown.write("\\\n") if parent.content[(index + 1)..].any? { |sibling| sibling.type != node.type }
17
+ end
13
18
  end
14
19
  end
15
20
  end
@@ -10,6 +10,12 @@ module Prosemirror
10
10
  (1..6).each { |level| parse_html tag: "h#{level}", attrs: {level:} }
11
11
 
12
12
  def view_template(&) = tag(:"h#{@level}", &)
13
+
14
+ def to_markdown(markdown, _parent, _index)
15
+ markdown.write("#{"#" * @level} ")
16
+ markdown.render_inline(node, from_block_start: false)
17
+ markdown.close_block(node)
18
+ end
13
19
  end
14
20
  end
15
21
  end
@@ -7,6 +7,11 @@ module Prosemirror
7
7
  parse_html tag: "hr"
8
8
 
9
9
  def view_template = hr
10
+
11
+ def to_markdown(markdown, _parent, _index)
12
+ markdown.write("---")
13
+ markdown.close_block(node)
14
+ end
10
15
  end
11
16
  end
12
17
  end
@@ -17,6 +17,22 @@ module Prosemirror
17
17
  src = safe_url(@src)
18
18
  img(src: src, alt: @alt, title: @title, width: @width, height: @height) if src
19
19
  end
20
+
21
+ # `![alt](src "title")`, or HTML with `html: true` when it has dimensions. Images in a row share a line,
22
+ # as Markdown would give them back: in separate paragraphs, each would come back after an empty one.
23
+ def to_markdown(markdown, parent, index)
24
+ markdown.write(" ") if index.positive? && parent.content[index - 1].type == node.type
25
+ if markdown.html? && (@width || @height)
26
+ markdown.write(markdown.node_html(node))
27
+ else
28
+ markdown.write(self.class.markdown_image(markdown, @src, @alt, @title))
29
+ end
30
+ markdown.close_block(node) unless parent.content[index + 1]&.type == node.type
31
+ end
32
+
33
+ def self.markdown_image(markdown, src, alt, title)
34
+ "![#{markdown.esc(alt.to_s)}](#{markdown.url(src)}#{markdown.title(title)})"
35
+ end
20
36
  end
21
37
  end
22
38
  end
@@ -7,6 +7,10 @@ module Prosemirror
7
7
  parse_html tag: "li"
8
8
 
9
9
  def view_template(&) = li(&)
10
+
11
+ def to_markdown(markdown, _parent, _index)
12
+ markdown.render_content(node)
13
+ end
10
14
  end
11
15
  end
12
16
  end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prosemirror
4
+ module Nodes
5
+ # Tiptap's mention: `@label`, or `@id` without a label. `mentionSuggestionChar` is the character typed
6
+ # to insert it, `@` by default, which tells mentions of different kinds apart.
7
+ class Mention < NodeType
8
+ group :inline
9
+ inline
10
+
11
+ prop :id, _Union?(String, Integer), parse_html: ->(element) { element["data-id"] }
12
+ prop :label, _String?, parse_html: ->(element) { element["data-label"] }
13
+ prop :mention_suggestion_char, _String(length: 1..), default: "@", json: "mentionSuggestionChar",
14
+ parse_html: ->(element) { element["data-mention-suggestion-char"] }
15
+ leaf_text { |node| display_text(node.attrs) }
16
+ parse_html tag: %(span[data-type="mention"])
17
+
18
+ def view_template
19
+ span(data: {type: "mention", id: @id, label: @label, mention_suggestion_char: @mention_suggestion_char}) do
20
+ self.class.display_text(node.attrs)
21
+ end
22
+ end
23
+
24
+ # Its text, or with `html: true`, its HTML, which Markdown import reads back.
25
+ def to_markdown(markdown, _parent, _index)
26
+ if markdown.html?
27
+ markdown.write(markdown.node_html(node))
28
+ else
29
+ markdown.text(self.class.display_text(node.attrs))
30
+ end
31
+ end
32
+
33
+ def self.display_text(attrs) = "#{attrs[:mention_suggestion_char] || "@"}#{attrs[:label] || attrs[:id]}"
34
+ end
35
+ end
36
+ end
@@ -31,6 +31,17 @@ module Prosemirror
31
31
  def view_template(&)
32
32
  ol(start: (@start unless @start == 1), type: @type, &)
33
33
  end
34
+
35
+ def to_markdown(markdown, parent, index)
36
+ self.class.numbered(markdown, node, @start, markdown.list_after_list?(parent, index) ? ")" : ".")
37
+ end
38
+
39
+ # Items numbered from `start`, with the numbers aligned. `)` instead of `.` keeps a list right after
40
+ # another ordered list apart from it.
41
+ def self.numbered(markdown, node, start, delimiter = ".")
42
+ width = (start + node.content.size - 1).to_s.length
43
+ markdown.render_list(node, " " * (width + 2)) { |index| "#{(start + index).to_s.rjust(width)}#{delimiter} " }
44
+ end
34
45
  end
35
46
  end
36
47
  end
@@ -8,6 +8,14 @@ module Prosemirror
8
8
  parse_html tag: "p"
9
9
 
10
10
  def view_template(&) = p(&)
11
+
12
+ # Markdown has no empty paragraphs: they are left out.
13
+ def to_markdown(markdown, _parent, _index)
14
+ return if node.content.empty?
15
+
16
+ markdown.render_inline(node)
17
+ markdown.close_block(node)
18
+ end
11
19
  end
12
20
  end
13
21
  end
@@ -10,6 +10,42 @@ module Prosemirror
10
10
  def view_template(&)
11
11
  table { tbody(&) }
12
12
  end
13
+
14
+ # A GitHub Flavored Markdown table: the first row is the header, and gives the columns' alignments. A
15
+ # cell's paragraphs, images and line breaks are joined with `<br>`, and its other blocks, like lists, are
16
+ # written as HTML, which Markdown import reads back with the herb gem.
17
+ def to_markdown(markdown, _parent, _index)
18
+ rows = node.content.map { |row| row.content.map { |cell| cell_markdown(markdown, cell) } }
19
+ columns = rows.map(&:size).max.to_i
20
+ return markdown.close_block(node) if columns.zero?
21
+
22
+ aligns = node.content.first.content.map { |cell| cell.attrs[:align] }
23
+ delimiter = Array.new(columns) do |index|
24
+ {"left" => ":--", "center" => ":-:", "right" => "--:"}.fetch(aligns[index].to_s, "---")
25
+ end
26
+ lines = [rows.first, delimiter, *rows.drop(1)].map do |cells|
27
+ "| #{Array.new(columns) { |index| cells[index].to_s }.join(" | ")} |"
28
+ end
29
+ markdown.text(lines.join("\n"), escape: false)
30
+ markdown.close_block(node)
31
+ end
32
+
33
+ private
34
+
35
+ def cell_markdown(markdown, cell)
36
+ parts = cell.content.map do |block|
37
+ type = markdown.schema.node_type(block.type)
38
+ if type && (type <= Paragraph || type <= Image)
39
+ serializer = MarkdownSerializer.new(markdown.schema, html: markdown.html?)
40
+ serializer.render(block, cell, 0)
41
+ serializer.out.strip.gsub(/\\\n|\n+/, "<br>")
42
+ else
43
+ markdown.node_html(block).gsub("\n", "&#10;")
44
+ end
45
+ end
46
+ # A space after a line break is dropped, as in HTML.
47
+ parts.join("<br>").gsub(/(?:<br>[[:space:]]*)+/, "<br>").delete_prefix("<br>").delete_suffix("<br>").gsub("|", "\\|")
48
+ end
13
49
  end
14
50
  end
15
51
  end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prosemirror
4
+ module Nodes
5
+ # Tiptap's task item, with its content in a `div` after the checkbox. Its content is Tiptap's with
6
+ # `nested: true`, which also accepts documents written without it.
7
+ class TaskItem < NodeType
8
+ content "paragraph block*"
9
+
10
+ # From Tiptap's `data-checked`, or the checkbox.
11
+ prop :checked, _Boolean, default: false, parse_html: lambda { |element|
12
+ if element.key?("data-checked")
13
+ ["", "true"].include?(element["data-checked"])
14
+ else
15
+ element.query(%(input[type="checkbox"]))&.key?("checked") || false
16
+ end
17
+ }
18
+
19
+ parse_html tag: %(li[data-type="taskItem"]), priority: 51, content_element: "div"
20
+ parse_html tag: "li.task-list-item", priority: 51, context: "task_list/"
21
+
22
+ def view_template(&)
23
+ li(data_checked: @checked.to_s, data_type: "taskItem") do
24
+ label do
25
+ input(type: "checkbox", checked: @checked)
26
+ span
27
+ end
28
+ div(&)
29
+ end
30
+ end
31
+
32
+ # `[x] ` before its content, without the space when its first paragraph is empty.
33
+ def to_markdown(markdown, _parent, _index)
34
+ markdown.write(@checked ? "[x]" : "[ ]")
35
+ markdown.write(" ") unless node.content.first&.content.to_a.empty?
36
+ markdown.render_content(node)
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prosemirror
4
+ module Nodes
5
+ # Tiptap's task list. Also imported from GitHub's HTML, as commonmarker renders it, when every item has a
6
+ # checkbox.
7
+ class TaskList < NodeType
8
+ group :block, :list
9
+ content "task_item+"
10
+ parse_html tag: %(ul[data-type="taskList"]), priority: 51
11
+ parse_html(tag: "ul.contains-task-list", priority: 51) do |element|
12
+ element.element_children.all? { |item| item.matches?("li.task-list-item") } && {}
13
+ end
14
+
15
+ def view_template(&) = ul(data_type: "taskList", &)
16
+
17
+ # `- [x]` items. Its markers alternate with bullet lists', or Markdown would join them.
18
+ def to_markdown(markdown, parent, index)
19
+ marker = BulletList.marker(markdown, parent, index)
20
+ markdown.render_list(node, " ") { marker }
21
+ end
22
+ end
23
+ end
24
+ end
@@ -5,6 +5,10 @@ module Prosemirror
5
5
  # Text. Text nodes are rendered by the renderer, not by this type.
6
6
  class Text < NodeType
7
7
  group :inline
8
+
9
+ def to_markdown(markdown, _parent, _index)
10
+ markdown.text(node.text, escape: !markdown.in_autolink)
11
+ end
8
12
  end
9
13
  end
10
14
  end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Prosemirror
6
+ module Nodes
7
+ # Tiptap's Twitch embed. `src` is the URL of a video, clip or channel, rendered as an iframe of its embed
8
+ # URL. Without a Twitch URL, it renders nothing. Twitch only plays embeds on the domain named by `parent`:
9
+ # override `parent` in a subclass, as Tiptap's `parent` option, which defaults to `localhost`.
10
+ class Twitch < NodeType
11
+ # The URLs Tiptap accepts.
12
+ URL = %r{\A(?:https?://)?(?:www\.)?(?:twitch\.tv|clips\.twitch\.tv)/(?:videos/\d+|\w+/clip/[\w-]+|[\w-]+/?)?(?:\?.*)?\z}
13
+
14
+ group :block
15
+
16
+ prop :src, _String(length: 1..), parse_html: ->(element) { Twitch.from_embed_url(element["src"])&.fetch(:src) || element["src"] }
17
+ prop :width, _Integer?(1..), default: 640, &Coerce::INTEGER
18
+ prop :height, _Integer?(1..), default: 480, &Coerce::INTEGER
19
+ prop :autoplay, _Boolean, default: false, parse_html: ->(element) { Twitch.from_embed_url(element["src"])&.fetch(:autoplay) }
20
+ prop :muted, _Boolean, default: false, parse_html: ->(element) { Twitch.from_embed_url(element["src"])&.fetch(:muted) }
21
+ prop :time, _String?, parse_html: ->(element) { Twitch.from_embed_url(element["src"])&.fetch(:time) }
22
+
23
+ # Tiptap's markup, `<div data-twitch-video><iframe>`, or an iframe of a Twitch embed URL.
24
+ parse_html(tag: "iframe[src]") do |element|
25
+ (element.parent&.key?("data-twitch-video") || Twitch.from_embed_url(element["src"])) ? {} : false
26
+ end
27
+
28
+ def view_template
29
+ src = safe_url(self.class.embed_url(@src, parent:, autoplay: @autoplay, muted: @muted, time: @time))
30
+ return unless src
31
+
32
+ div(data_twitch_video: "") { iframe(src:, width: @width, height: @height, allowfullscreen: true) }
33
+ end
34
+
35
+ # The domain of the site the embed is on.
36
+ def parent = "localhost"
37
+
38
+ # The URL, as text that Markdown links. With `html: true`, the iframe.
39
+ def to_markdown(markdown, _parent, _index)
40
+ markdown.write(markdown.html? ? markdown.node_html(node) : "<#{markdown.url(@src)}>")
41
+ markdown.close_block(node)
42
+ end
43
+
44
+ # The video, clip or channel of a Twitch URL, as `[kind, id]`, or nil.
45
+ def self.identify(src)
46
+ return unless src.match?(URL)
47
+
48
+ path = src.split("?").first
49
+ if (clip = path[%r{clips\.twitch\.tv/([\w-]+)}, 1] || path[%r{twitch\.tv/[\w-]+/clip/([\w-]+)}, 1])
50
+ [:clip, clip]
51
+ elsif (video = path[%r{twitch\.tv/videos/(\d+)}, 1])
52
+ [:video, video]
53
+ elsif (channel = path[%r{twitch\.tv/([\w-]+)/?\z}, 1])
54
+ [:channel, channel]
55
+ end
56
+ end
57
+
58
+ # The embed URL, as Tiptap builds it, or nil.
59
+ def self.embed_url(src, parent:, autoplay: false, muted: false, time: nil)
60
+ kind, id = identify(src)
61
+ return unless kind
62
+
63
+ options = {"autoplay" => ("true" if autoplay), "muted" => ("true" if muted)}
64
+ query = if kind == :clip
65
+ {"clip" => id, "parent" => parent, **options}
66
+ else
67
+ {kind.to_s => id, "parent" => parent, "allowfullscreen" => "true", **options, "time" => (time if kind == :video)}
68
+ end
69
+ base = (kind == :clip) ? "https://clips.twitch.tv/embed" : "https://player.twitch.tv/"
70
+ "#{base}?#{URI.encode_www_form(query.compact)}"
71
+ end
72
+
73
+ # The URL and options of an embed URL, as Tiptap reads them, or nil.
74
+ def self.from_embed_url(url)
75
+ uri = URI.parse(url.to_s)
76
+ return unless uri.is_a?(URI::HTTP)
77
+
78
+ query = URI.decode_www_form(uri.query.to_s).to_h
79
+ src = case [uri.host.to_s.delete_prefix("www."), uri.path]
80
+ in ["player.twitch.tv", _]
81
+ if query["video"] then "https://www.twitch.tv/videos/#{query["video"]}"
82
+ elsif query["channel"] then "https://www.twitch.tv/#{query["channel"]}"
83
+ end
84
+ in ["clips.twitch.tv", "/embed"]
85
+ query["clip"]&.then { |clip| "https://clips.twitch.tv/#{clip}" }
86
+ else nil
87
+ end
88
+ {src:, autoplay: query["autoplay"] == "true", muted: query["muted"] == "true", time: query["time"]} if src&.match?(URL)
89
+ rescue URI::Error, ArgumentError
90
+ nil
91
+ end
92
+ end
93
+ end
94
+ end