prosemirror 0.5.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3717422ef93a6f35012d264a8d82b3fe9c2982631d5b2f39a499be9ad2da79a7
4
- data.tar.gz: 05af2f5ff65a7a07e0f1134627fd6e4110d089264b9df1ad78f66000c4bda30e
3
+ metadata.gz: 8aae1987528266c7bd7c4b3c3d8f7c82e74f74cf89ee9e7ac87b1732ff76634d
4
+ data.tar.gz: d5cb877b264eb0d39cd40cd8b4909ec94b423ac439a6719df23c70eb6cde6182
5
5
  SHA512:
6
- metadata.gz: 950422c7a48833915def7682efdb216a93c416acbfcb57ea712da29d220280a269d524013cc5bff57c0cc706fa243b7e0cd7080985bc8bd129224f182b1e4c62
7
- data.tar.gz: 3914858a0eadcabd1d17dc253259a2c4786cb22332aff14b66c3d8fe89f9ea99de7c0ea4c6e70255fb23b9b992b543bd22bde54fdb2f55dbb6232185ba306168
6
+ metadata.gz: 947eab5fdc041f059be90be81b94bc66bb1505118b983ed982100faf1ccf2748fdcf753f83384385af72e80cc610473e24eeca31aa6a404ed93f431a8d3138d2
7
+ data.tar.gz: 4a56748f16cc7ac1af9ad1660518870dfded5f22b089e714417cf74afb970e0ec8b9f53d2d9e1adc82662efac63c449ba1dff10d07988ed8ca110413143c2452
data/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.6.0] - 2026-10-24
4
+
5
+ - Tiptap's YouTube and Twitch embeds, in `Schema.tiptap`: `youtube` and `twitch`, with the video's URL in `src`, rendered as an iframe of its embed URL, built from the video's id. They are imported from Tiptap's markup, and from iframes of embed URLs without it. Subclasses set Twitch's `parent` domain and YouTube's nocookie host. Markdown export writes a link to the video, or the iframe with `html: true`.
6
+ - Tiptap's details, in `Schema.tiptap`: `details`, `details_summary` and `details_content`, rendered and imported as Tiptap does, including `<details>` without Tiptap's content wrapper. `open`, which Tiptap keeps with `persist: true`, is rendered and imported. Markdown export writes them as `<details>` HTML around Markdown content, as GitHub reads it.
7
+ - `Schema.tiptap(text_align: true)` adds Tiptap's TextAlign extension to headings and paragraphs: a `textAlign` attribute, rendered as a `text-align` style, and imported from it or from the old `align` attribute. Other types get it by including `Prosemirror::TextAlign`.
8
+ - Tiptap's task lists, in `Schema.tiptap`: `task_list` and `task_item`, with a `checked` attribute. They render Tiptap's HTML, and are imported from it and from GitHub's. Markdown import reads GitHub Flavored Markdown task lists: a bullet list whose items all have a checkbox is a task list, and other checkboxes stay as text. Markdown export writes `- [x]` items.
9
+ - Tiptap's mention node, in `Schema.tiptap`: rendered as `<span data-type="mention">@label</span>`, with its `id`, `label` and `mentionSuggestionChar` in `data-` attributes, and imported from that HTML. Its text is `@label`, or `@id` without a label. In Markdown, it's written as text, or as HTML with `html: true`.
10
+
3
11
  ## [0.5.0] - 2026-10-24
4
12
 
5
13
  - `Document#to_markdown` writes Markdown, with GitHub Flavored Markdown tables and strikethrough, like prosemirror-markdown. Node types write themselves in `to_markdown`, and mark types declare their syntax with `markdown`. `html: true` writes underline, highlight, subscript, superscript and image sizes as HTML.
data/README.md CHANGED
@@ -109,10 +109,10 @@ bundle add commonmarker
109
109
  doc = Prosemirror.from_markdown("## Hello\n\nSome **bold** text.")
110
110
  ```
111
111
 
112
- It reads GitHub Flavored Markdown: tables, strikethrough and autolinks. Each Markdown node becomes the element commonmarker renders for it, and the schema's parse rules apply as in HTML import, so custom types can match Markdown too: a type that matches `img` also gets Markdown images. For the built-in types, it gives the same JSON as rendering the Markdown to HTML and importing it in Tiptap, except that:
112
+ It reads GitHub Flavored Markdown: tables, strikethrough, autolinks and task lists. Each Markdown node becomes the element commonmarker renders for it, and the schema's parse rules apply as in HTML import, so custom types can match Markdown too: a type that matches `img` also gets Markdown images. For the built-in types, it gives the same JSON as rendering the Markdown to HTML and importing it in Tiptap, except that:
113
113
 
114
114
  - code blocks lose their final newline, as in prosemirror-markdown;
115
- - task list items (`- [ ] todo`) stay as list items starting with `[ ] ` or `[x] `.
115
+ - a bullet list whose items all have a checkbox (`- [ ] todo`) is a task list. In other lists, which Tiptap's task lists can't hold, checkboxes stay as text at the start of their item: `[ ] ` or `[x] `.
116
116
 
117
117
  Raw HTML in Markdown goes through HTML import when the herb gem is installed, and stays as text otherwise.
118
118
 
@@ -120,17 +120,19 @@ YAML front matter, between `---` lines at the start, is dropped. Footnotes aren'
120
120
 
121
121
  ## Markdown export
122
122
 
123
- `to_markdown` writes the document as Markdown, with GitHub Flavored Markdown for tables and strikethrough, like [prosemirror-markdown](https://github.com/ProseMirror/prosemirror-markdown). It needs no gem.
123
+ `to_markdown` writes the document as Markdown, with GitHub Flavored Markdown for tables, strikethrough and task lists, like [prosemirror-markdown](https://github.com/ProseMirror/prosemirror-markdown). It needs no gem.
124
124
 
125
125
  ```ruby
126
126
  doc.to_markdown # => "## Hello\n\n**World**\n"
127
- doc.to_markdown(html: true) # underline, highlight, subscript and superscript as HTML
127
+ doc.to_markdown(html: true) # underline, highlight, subscript, superscript and mentions as HTML
128
128
  ```
129
129
 
130
130
  - Text is escaped, so that it reads back as text: `*`, `_`, `[`, a `#` or `-` that would start a block, HTML-like `<b>`, and URLs that GitHub would turn into links.
131
- - Markdown has no syntax for underline, highlight, subscript, superscript or image sizes: by default, only their text or image is written. With `html: true`, they are written as HTML.
131
+ - Markdown has no syntax for underline, highlight, subscript, superscript, image sizes or mentions: by default, only their text or image is written, like `@Ada` for a mention. With `html: true`, they are written as HTML.
132
132
  - Emphasis that CommonMark can't write where it is, like bold on a comma between two letters, is dropped and its text kept.
133
133
  - Tables are written as GFM tables. A cell's paragraphs and line breaks are joined with `<br>`, and its other blocks, like lists, are written as HTML.
134
+ - Details are written as `<details>` HTML, as on GitHub, with their content in Markdown.
135
+ - YouTube and Twitch embeds are written as links to the video. With `html: true`, they are written as their iframe.
134
136
  - Empty paragraphs are left out.
135
137
 
136
138
  Importing the Markdown gives the document back, as far as Markdown can hold it. Custom types write their own Markdown: see [Node and mark types](TYPES.md#markdown-export).
@@ -148,8 +150,13 @@ Blank lines separate paragraphs, and other line breaks become hard breaks. Space
148
150
  Two schemas are included:
149
151
 
150
152
  ```ruby
151
- Prosemirror::Schema.tiptap # Tiptap's StarterKit, tables, images, underline, subscript, superscript, highlight (default)
152
- Prosemirror::Schema.prosemirror # prosemirror-schema-basic and prosemirror-schema-list
153
+ # The default: Tiptap's StarterKit, with tables, images, task lists,
154
+ # details, YouTube and Twitch embeds, mentions, underline, subscript,
155
+ # superscript and highlight
156
+ Prosemirror::Schema.tiptap
157
+
158
+ # prosemirror-schema-basic and prosemirror-schema-list
159
+ Prosemirror::Schema.prosemirror
153
160
  ```
154
161
 
155
162
  Type names are written in snake_case (`bullet_list`). Use `case: :camel` to write camelCase (`bulletList`), as Tiptap does. Both cases are always accepted on input.
@@ -158,6 +165,26 @@ Type names are written in snake_case (`bullet_list`). Use `case: :camel` to writ
158
165
  Prosemirror.schema = Prosemirror::Schema.tiptap(case: :camel)
159
166
  ```
160
167
 
168
+ `text_align: true` adds Tiptap's TextAlign extension, configured for headings and paragraphs: a `textAlign` attribute, `left`, `center`, `right` or `justify`, rendered as a `text-align` style. HTML import also reads the old `align` attribute. Markdown has no syntax for it: Markdown export leaves it out. Without the option, `textAlign` is kept in `to_h`, but not rendered.
169
+
170
+ ```ruby
171
+ Prosemirror::Schema.tiptap(text_align: true)
172
+ ```
173
+
174
+ YouTube and Twitch embeds store the video's URL in `src`, and render an iframe of its embed URL, built from the video's id: other URLs render nothing. HTML import reads Tiptap's markup, and also iframes of embed URLs without it. Twitch only plays embeds on the domain they name, so set it in a subclass, as Tiptap's `parent` option. YouTube's `nocookie` option is a subclass too:
175
+
176
+ ```ruby
177
+ class Twitch < Prosemirror::Nodes::Twitch
178
+ def parent = "example.com"
179
+ end
180
+
181
+ class Youtube < Prosemirror::Nodes::Youtube
182
+ def embed_host = "https://www.youtube-nocookie.com"
183
+ end
184
+
185
+ Prosemirror.schema = Prosemirror::Schema.tiptap.with(Twitch, Youtube)
186
+ ```
187
+
161
188
  ## Custom nodes and marks
162
189
 
163
190
  A node type is a Phlex component, and its attributes are Literal properties. The type name comes from the class name (`ImageBlock` → `image_block`).
data/ROADMAP.md CHANGED
@@ -199,9 +199,11 @@ They differ in more than names:
199
199
  - Custom node types write themselves in `to_markdown`, and mark types declare their syntax with `markdown`.
200
200
  - Importing the Markdown gives the document back, as far as Markdown can hold it, tested on real-world Markdown and HTML.
201
201
 
202
- ### 0.6: More Tiptap node types
202
+ ### 0.6: More Tiptap node types (released)
203
203
 
204
- - Task lists, text alignment, mentions, details, embeds.
204
+ - Mentions, task lists, details, and YouTube and Twitch embeds, in `Schema.tiptap`, with HTML that gives the same JSON as Tiptap's `generateJSON`.
205
+ - Text alignment, as an option: `Schema.tiptap(text_align: true)`.
206
+ - GitHub Flavored Markdown task lists in Markdown import and export. Details are written as GitHub's `<details>` HTML.
205
207
 
206
208
  ### 1.0
207
209
 
data/TYPES.md CHANGED
@@ -259,3 +259,15 @@ Prosemirror.schema = Prosemirror::Schema.tiptap.with(Paragraph)
259
259
  ```
260
260
 
261
261
  A subclass that declares parse rules replaces the rules of its parent. See the [cookbook](COOKBOOK.md) for more examples.
262
+
263
+ With `Schema.tiptap(text_align: true)`, paragraphs and headings are `Prosemirror::TextAlign::Paragraph` and `Prosemirror::TextAlign::Heading`: subclass these to keep the alignment. Other types get it by including `Prosemirror::TextAlign`, which declares the `text_align` attribute, and rendering `text_align_style`:
264
+
265
+ ```ruby
266
+ class Callout < Prosemirror::NodeType
267
+ include Prosemirror::TextAlign
268
+ group :block
269
+ content "inline*"
270
+
271
+ def view_template(&) = aside(style: text_align_style, &)
272
+ end
273
+ ```
@@ -6,7 +6,8 @@ module Prosemirror
6
6
  # (https://github.com/gjtorikian/commonmarker), so that the schema's parse rules apply to Markdown too.
7
7
  # Each Markdown node becomes the element commonmarker renders for it, without going through HTML.
8
8
  #
9
- # - GitHub Flavored Markdown: tables, strikethrough and autolinks. Task list items stay as text (`[ ] `).
9
+ # - GitHub Flavored Markdown: tables, strikethrough, autolinks and task lists. A bullet list whose items
10
+ # all have a checkbox is a task list. In other lists, the checkboxes stay as text (`[ ] `).
10
11
  # - Raw HTML can span several Markdown blocks, like `<del>` around a paragraph: with the herb gem, a
11
12
  # document holding raw HTML is rendered to HTML by commonmarker and imported. Without it, raw HTML
12
13
  # stays as text.
@@ -15,16 +16,21 @@ module Prosemirror
15
16
  class Builder
16
17
  MAX_DEPTH = HTML::Builder::MAX_DEPTH
17
18
  EXTENSIONS = {
18
- strikethrough: true, table: true, autolink: true, tasklist: false, shortcodes: false, header_ids: nil,
19
+ strikethrough: true, table: true, autolink: true, tasklist: true, shortcodes: false, header_ids: nil,
19
20
  front_matter_delimiter: "---"
20
21
  }.freeze
21
- RENDER_OPTIONS = {extension: EXTENSIONS, render: {unsafe: true, hardbreaks: false, github_pre_lang: false}}.freeze
22
+ # `tasklist_classes` gives task lists the classes of GitHub's HTML, which the task list type reads.
23
+ RENDER_OPTIONS = {
24
+ extension: EXTENSIONS, render: {unsafe: true, hardbreaks: false, github_pre_lang: false, tasklist_classes: true}
25
+ }.freeze
22
26
  TAGS = {
23
27
  paragraph: "p", block_quote: "blockquote", item: "li", thematic_break: "hr", table_row: "tr",
24
28
  emph: "em", strong: "strong", strikethrough: "s", linebreak: "br"
25
29
  }.freeze
26
30
  # A table's delimiter row, like `| :-- | --: |`, at the end of its line.
27
31
  DELIMITER_ROW = /\|?(?:\s*:?-+:?\s*\|)*\s*:?-+:?\s*\|?\s*\z/
32
+ # A task item's marker and checkbox, at its first line.
33
+ TASK_MARKER = /\A(?:[-+*]|\d+[.)])[ \t]+(\[[ xX]\])/
28
34
  # The bytes commonmarker escapes in URLs, as comrak's `escape_href`: all but these, and `%` when it
29
35
  # doesn't start an escape. `&` and `'` are kept: comrak writes them as HTML references.
30
36
  URL_UNSAFE = %r{%(?!\h\h)|[^-_.+!*(),#@?=;:/$~A-Za-z0-9&'%]}n
@@ -87,6 +93,8 @@ module Prosemirror
87
93
  # Adds a node to the target. Returns the element to add its children to, when they need adding.
88
94
  def add(node, target)
89
95
  type = node.type # commonmarker allocates for each call
96
+ return checkbox_text(node, type, target) if @checkbox
97
+
90
98
  if (tag = TAGS[type])
91
99
  return HTML::Element.new(tag)
92
100
  end
@@ -94,6 +102,7 @@ module Prosemirror
94
102
  case type
95
103
  when :heading then HTML::Element.new("h#{node.header_level}")
96
104
  when :list then list(node)
105
+ when :taskitem then task_item(node, target)
97
106
  when :code_block then code_block(node, target)
98
107
  when :table then table(node)
99
108
  when :table_cell then table_cell(node)
@@ -110,11 +119,44 @@ module Prosemirror
110
119
  end
111
120
 
112
121
  def list(node)
113
- return HTML::Element.new("ul") unless node.list_type == :ordered
122
+ if node.list_type != :ordered
123
+ return HTML::Element.new("ul", (node.all? { |item| item.type == :taskitem }) ? {"data-type" => "taskList"} : {})
124
+ end
114
125
 
115
126
  HTML::Element.new("ol", (node.list_start == 1) ? {} : {"start" => node.list_start.to_s})
116
127
  end
117
128
 
129
+ # commonmarker doesn't say whether a task item is checked: its checkbox is read from its first line.
130
+ # Outside a task list, the checkbox is written as text, at the start of the item's first paragraph.
131
+ def task_item(node, target)
132
+ position = node.source_position
133
+ checkbox = @lines[position[:start_line] - 1].to_s.byteslice((position[:start_column] - 1)..).to_s[TASK_MARKER, 1] || "[ ]"
134
+ if target["data-type"] == "taskList"
135
+ return HTML::Element.new("li", {"data-type" => "taskItem", "data-checked" => checkbox.match?(/x/i).to_s})
136
+ end
137
+
138
+ item = HTML::Element.new("li")
139
+ if node.first_child
140
+ @checkbox = checkbox
141
+ else
142
+ text(item.append(HTML::Element.new("p")), "#{checkbox} ")
143
+ end
144
+ item
145
+ end
146
+
147
+ # The first child of a task item outside a task list: its paragraph starts with the checkbox, or a
148
+ # paragraph holding it goes before.
149
+ def checkbox_text(node, type, target)
150
+ checkbox = "#{@checkbox} "
151
+ @checkbox = nil
152
+ paragraph = HTML::Element.new("p")
153
+ text(paragraph, checkbox)
154
+ return paragraph if type == :paragraph
155
+
156
+ target.append(paragraph)
157
+ add(node, target)
158
+ end
159
+
118
160
  def code_block(node, target)
119
161
  language = node.fence_info.to_s.split.first
120
162
  code = HTML::Element.new("code", language ? {"class" => "language-#{language}"} : {})
@@ -213,10 +213,12 @@ module Prosemirror
213
213
  end
214
214
 
215
215
  # Whether the list follows a list of the same type, which it would continue in Markdown with the same
216
- # marker. Alternating lists in a row switch markers.
217
- def list_after_list?(parent, index)
216
+ # marker. Alternating lists in a row switch markers. The block, called with the siblings before, tells
217
+ # which lists share markers, when other types than the list's do.
218
+ def list_after_list?(parent, index, &same)
219
+ same ||= ->(sibling) { sibling.type == parent.content[index].type }
218
220
  run = 0
219
- run += 1 while index - run - 1 >= 0 && parent.content[index - run - 1].type == parent.content[index].type
221
+ run += 1 while index - run - 1 >= 0 && same.call(parent.content[index - run - 1])
220
222
  run.odd?
221
223
  end
222
224
 
@@ -9,11 +9,20 @@ module Prosemirror
9
9
 
10
10
  def view_template(&) = ul(&)
11
11
 
12
- # `-` items, or `*` right after another bullet list, which would otherwise continue it.
12
+ # `-` items, or `*` right after another bullet or task list, which would otherwise continue it.
13
13
  def to_markdown(markdown, parent, index)
14
- marker = markdown.list_after_list?(parent, index) ? "* " : "- "
14
+ marker = self.class.marker(markdown, parent, index)
15
15
  markdown.render_list(node, " ") { marker }
16
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
17
26
  end
18
27
  end
19
28
  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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Prosemirror
6
+ module Nodes
7
+ # Tiptap's YouTube embed. `src` is the video's URL, like `https://www.youtube.com/watch?v=…`, rendered as
8
+ # an iframe of its embed URL. Without a YouTube URL, it renders nothing. Override `embed_host` for
9
+ # `https://www.youtube-nocookie.com`, as Tiptap's `nocookie` option.
10
+ class Youtube < NodeType
11
+ # The URLs Tiptap accepts: on youtube.com, youtu.be and youtube-nocookie.com.
12
+ URL = %r{\A(?:(?:https?:)?//)?(?:(?:www|m|music)\.)?(?:youtube\.com|youtu\.be|youtube-nocookie\.com)(?:/(?:[\w-]+\?v=|embed/|v/)?)[\w-]+\S*\z}
13
+ EMBED_HOSTS = %w[youtube.com youtube-nocookie.com].freeze
14
+
15
+ group :block
16
+
17
+ prop :src, _String(length: 1..), parse_html: ->(element) { Youtube.from_embed_url(element["src"])&.fetch(:src) || element["src"] }
18
+ prop :start, _Integer(0..), default: 0,
19
+ parse_html: ->(element) { Youtube.from_embed_url(element["src"])&.fetch(:start) }, &Coerce::INTEGER
20
+ prop :width, _Integer?(1..), default: 640, &Coerce::INTEGER
21
+ prop :height, _Integer?(1..), default: 480, &Coerce::INTEGER
22
+
23
+ # Tiptap's markup, `<div data-youtube-video><iframe>`, or an iframe of a YouTube embed URL.
24
+ parse_html(tag: "iframe[src]") do |element|
25
+ (element.parent&.key?("data-youtube-video") || Youtube.from_embed_url(element["src"])) ? {} : false
26
+ end
27
+
28
+ def view_template
29
+ src = safe_url(self.class.embed_url(@src, start: @start, host: embed_host))
30
+ return unless src
31
+
32
+ div(data_youtube_video: "") { iframe(src:, width: @width, height: @height, allowfullscreen: true) }
33
+ end
34
+
35
+ def embed_host = "https://www.youtube.com"
36
+
37
+ # The URL, as text that Markdown links. With `html: true`, the iframe.
38
+ def to_markdown(markdown, _parent, _index)
39
+ markdown.write(markdown.html? ? markdown.node_html(node) : "<#{markdown.url(@src)}>")
40
+ markdown.close_block(node)
41
+ end
42
+
43
+ # The embed URL of a video or playlist, as Tiptap builds it, or nil.
44
+ def self.embed_url(src, start: 0, host: "https://www.youtube.com")
45
+ return unless src.match?(URL)
46
+
47
+ if (playlist = src[/[?&]list=([\w-]+)/, 1])
48
+ return "#{host}/embed/videoseries?list=#{playlist}"
49
+ end
50
+
51
+ id = src[%r{(?:youtu\.be/|/embed/|/v/|/shorts/|/live/|[?&]v=)([\w-]+)}, 1]
52
+ id && "#{host}/embed/#{id}#{"?start=#{start}" if start.positive?}"
53
+ end
54
+
55
+ # The video URL and start time of an embed URL, as Tiptap reads them, or nil.
56
+ def self.from_embed_url(url)
57
+ uri = URI.parse(url.to_s)
58
+ return unless uri.is_a?(URI::HTTP) && EMBED_HOSTS.include?(uri.host.to_s.delete_prefix("www."))
59
+
60
+ query = URI.decode_www_form(uri.query.to_s).to_h
61
+ src = case uri.path
62
+ when "/embed/videoseries" then query["list"]&.then { |list| "https://www.youtube.com/playlist?list=#{list}" }
63
+ when %r{\A/embed/([\w-]+)\z} then "https://www.youtube.com/watch?v=#{Regexp.last_match(1)}"
64
+ end
65
+ {src:, start: query["start"].to_i} if src&.match?(URL)
66
+ rescue URI::Error, ArgumentError
67
+ nil
68
+ end
69
+ end
70
+ end
71
+ end
@@ -24,13 +24,17 @@ module Prosemirror
24
24
  # Called with an `InvalidDocument` error for each problem fixed.
25
25
  prop :on_invalid, _Callable?, reader: false
26
26
 
27
- # Tiptap's StarterKit and common extensions.
28
- def self.tiptap(**)
27
+ # Tiptap's StarterKit and common extensions. `text_align: true` adds the TextAlign extension, configured
28
+ # for headings and paragraphs.
29
+ def self.tiptap(text_align: false, **)
29
30
  new(
30
31
  nodes: [
31
- Nodes::Doc, Nodes::Paragraph, Nodes::Text, Nodes::Heading, Nodes::Blockquote, Nodes::BulletList,
32
+ Nodes::Doc, text_align ? TextAlign::Paragraph : Nodes::Paragraph, Nodes::Text,
33
+ text_align ? TextAlign::Heading : Nodes::Heading, Nodes::Blockquote, Nodes::BulletList,
32
34
  Nodes::OrderedList, Nodes::ListItem, Nodes::CodeBlock, Nodes::HardBreak, Nodes::HorizontalRule,
33
- Nodes::Image, Nodes::Table, Nodes::TableRow, Nodes::TableHeader, Nodes::TableCell
35
+ Nodes::Image, Nodes::Table, Nodes::TableRow, Nodes::TableHeader, Nodes::TableCell, Nodes::TaskList,
36
+ Nodes::TaskItem, Nodes::Details, Nodes::DetailsSummary, Nodes::DetailsContent, Nodes::Youtube,
37
+ Nodes::Twitch, Nodes::Mention
34
38
  ],
35
39
  # In Tiptap's order, which sorts and nests marks.
36
40
  marks: [
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prosemirror
4
+ # Tiptap's TextAlign extension: a `textAlign` attribute, rendered as a `text-align` style. Included in the
5
+ # paragraphs and headings of `Schema.tiptap(text_align: true)`. A custom type includes it, and renders
6
+ # `style: text_align_style`.
7
+ module TextAlign
8
+ ALIGNMENTS = %w[left center right justify].freeze
9
+
10
+ def self.included(type)
11
+ # From the `text-align` style, or the old `align` attribute, which Tiptap doesn't read.
12
+ type.prop :text_align, type._Union?(*ALIGNMENTS), json: "textAlign", parse_html: lambda { |element|
13
+ (element.style["text-align"] || element["align"]).to_s.strip.downcase.then { |value| value if ALIGNMENTS.include?(value) }
14
+ }
15
+ end
16
+
17
+ def text_align_style = @text_align && "text-align: #{@text_align}"
18
+
19
+ class Paragraph < Nodes::Paragraph
20
+ include TextAlign
21
+
22
+ def view_template(&) = p(style: text_align_style, &)
23
+ end
24
+
25
+ class Heading < Nodes::Heading
26
+ include TextAlign
27
+
28
+ def view_template(&) = tag(:"h#{@level}", style: text_align_style, &)
29
+ end
30
+ end
31
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Prosemirror
4
- VERSION = "0.5.0"
4
+ VERSION = "0.6.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: prosemirror
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Hans Lemuet
@@ -107,22 +107,31 @@ files:
107
107
  - lib/prosemirror/nodes/blockquote.rb
108
108
  - lib/prosemirror/nodes/bullet_list.rb
109
109
  - lib/prosemirror/nodes/code_block.rb
110
+ - lib/prosemirror/nodes/details.rb
111
+ - lib/prosemirror/nodes/details_content.rb
112
+ - lib/prosemirror/nodes/details_summary.rb
110
113
  - lib/prosemirror/nodes/doc.rb
111
114
  - lib/prosemirror/nodes/hard_break.rb
112
115
  - lib/prosemirror/nodes/heading.rb
113
116
  - lib/prosemirror/nodes/horizontal_rule.rb
114
117
  - lib/prosemirror/nodes/image.rb
115
118
  - lib/prosemirror/nodes/list_item.rb
119
+ - lib/prosemirror/nodes/mention.rb
116
120
  - lib/prosemirror/nodes/ordered_list.rb
117
121
  - lib/prosemirror/nodes/paragraph.rb
118
122
  - lib/prosemirror/nodes/table.rb
119
123
  - lib/prosemirror/nodes/table_cell.rb
120
124
  - lib/prosemirror/nodes/table_header.rb
121
125
  - lib/prosemirror/nodes/table_row.rb
126
+ - lib/prosemirror/nodes/task_item.rb
127
+ - lib/prosemirror/nodes/task_list.rb
122
128
  - lib/prosemirror/nodes/text.rb
129
+ - lib/prosemirror/nodes/twitch.rb
130
+ - lib/prosemirror/nodes/youtube.rb
123
131
  - lib/prosemirror/parse_rule.rb
124
132
  - lib/prosemirror/rendering.rb
125
133
  - lib/prosemirror/schema.rb
134
+ - lib/prosemirror/text_align.rb
126
135
  - lib/prosemirror/text_serializer.rb
127
136
  - lib/prosemirror/transformer.rb
128
137
  - lib/prosemirror/type_definition.rb