prosemirror 0.5.0 → 0.7.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 +4 -4
- data/CHANGELOG.md +12 -0
- data/COOKBOOK.md +20 -3
- data/MIGRATING.md +131 -0
- data/README.md +37 -8
- data/ROADMAP.md +9 -2
- data/TYPES.md +14 -1
- data/lib/prosemirror/document.rb +18 -0
- data/lib/prosemirror/html_renderer.rb +2 -0
- data/lib/prosemirror/markdown/builder.rb +46 -4
- data/lib/prosemirror/markdown_serializer.rb +5 -3
- data/lib/prosemirror/marks/basic/link.rb +1 -1
- data/lib/prosemirror/marks/link.rb +1 -1
- data/lib/prosemirror/nodes/basic/image.rb +1 -1
- data/lib/prosemirror/nodes/bullet_list.rb +11 -2
- data/lib/prosemirror/nodes/details.rb +27 -0
- data/lib/prosemirror/nodes/details_content.rb +12 -0
- data/lib/prosemirror/nodes/details_summary.rb +12 -0
- data/lib/prosemirror/nodes/image.rb +1 -1
- data/lib/prosemirror/nodes/mention.rb +36 -0
- data/lib/prosemirror/nodes/task_item.rb +40 -0
- data/lib/prosemirror/nodes/task_list.rb +24 -0
- data/lib/prosemirror/nodes/twitch.rb +94 -0
- data/lib/prosemirror/nodes/youtube.rb +71 -0
- data/lib/prosemirror/rendering.rb +3 -2
- data/lib/prosemirror/schema.rb +33 -7
- data/lib/prosemirror/text_align.rb +31 -0
- data/lib/prosemirror/type_definition.rb +11 -4
- data/lib/prosemirror/version.rb +1 -1
- metadata +11 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 76385bda3d9b696b80e976154bf40c5d33ad2b9ac79b48e5f3fa8a6f7bf224e8
|
|
4
|
+
data.tar.gz: 7acda4288b7bbb2d731a111143dc26b715177551476829ffa6416a7a298180c6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7c9fdb3905684864d3662c4bdb25c2cee8efb055d59a64b28b4dd414b4a08759deae97a5f071ab605e8c94ce235356c30bb54c364053d5cc2d0b105eee151c0a
|
|
7
|
+
data.tar.gz: 42054ca794261591e076b654aac9068bf629f7aeb82d18a5eb51fdbf4602d95dd5199907a4caa5363ad883b6b1628490ab848f32310aea4dbcbd10eadd7b7577
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.7.0] - 2026-10-24
|
|
4
|
+
|
|
5
|
+
- `resolve_urls:` resolves all the URLs of a document at once, before rendering it, to look up records in one query, where `resolve_url:` is called once per URL. Attributes holding URLs are declared with `prop … url: true`, like links' `href` and images' `src`, and `Document#urls` lists them.
|
|
6
|
+
|
|
7
|
+
## [0.6.0] - 2026-10-24
|
|
8
|
+
|
|
9
|
+
- 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`.
|
|
10
|
+
- 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.
|
|
11
|
+
- `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`.
|
|
12
|
+
- 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.
|
|
13
|
+
- 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`.
|
|
14
|
+
|
|
3
15
|
## [0.5.0] - 2026-10-24
|
|
4
16
|
|
|
5
17
|
- `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/COOKBOOK.md
CHANGED
|
@@ -104,7 +104,26 @@ end
|
|
|
104
104
|
|
|
105
105
|
## Links to records with GlobalID
|
|
106
106
|
|
|
107
|
-
Store links to records as GlobalIDs (`gid://app/Page/1`), so they keep working when a record's URL changes, and turn them into URLs when rendering:
|
|
107
|
+
Store links to records as GlobalIDs (`gid://app/Page/1`), so they keep working when a record's URL changes, and turn them into URLs when rendering. `resolve_urls` gets all the URLs of a document before rendering it, so its records load in one query:
|
|
108
|
+
|
|
109
|
+
```ruby
|
|
110
|
+
Prosemirror::Schema.tiptap(
|
|
111
|
+
resolve_urls: lambda do |urls|
|
|
112
|
+
gids = urls.select { |url| url.start_with?("gid://") }
|
|
113
|
+
records = GlobalID::Locator.locate_many(gids, ignore_missing: true).index_by { |record| record.to_global_id.to_s }
|
|
114
|
+
|
|
115
|
+
urls.to_h do |url|
|
|
116
|
+
next [url, url] unless url.start_with?("gid://")
|
|
117
|
+
|
|
118
|
+
[url, records[url]&.then { |record| Rails.application.routes.url_helpers.polymorphic_path(record) }]
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
)
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
URLs are resolved before the allowed URL schemes are checked, so `gid://` doesn't need to be allowed. When a record doesn't exist, its link is removed, its text is kept, and the problem goes to `on_invalid`.
|
|
125
|
+
|
|
126
|
+
`resolve_url` is simpler, but runs one query per link:
|
|
108
127
|
|
|
109
128
|
```ruby
|
|
110
129
|
Prosemirror::Schema.tiptap(
|
|
@@ -116,5 +135,3 @@ Prosemirror::Schema.tiptap(
|
|
|
116
135
|
end
|
|
117
136
|
)
|
|
118
137
|
```
|
|
119
|
-
|
|
120
|
-
`resolve_url` runs before the allowed URL schemes are checked, so `gid://` doesn't need to be allowed. When the record doesn't exist, the link is removed, its text is kept, and the problem goes to `on_invalid`. Each link runs one query.
|
data/MIGRATING.md
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# Migrating from prosemirror_to_html and html_to_prosemirror
|
|
2
|
+
|
|
3
|
+
`prosemirror` replaces [`prosemirror_to_html`](https://github.com/etaminstudio/prosemirror_to_html) and [`html_to_prosemirror`](https://github.com/etaminstudio/html_to_prosemirror), which have the same maintainers. Rendering and import use one schema, so a custom type is declared once for both.
|
|
4
|
+
|
|
5
|
+
```ruby
|
|
6
|
+
# Gemfile
|
|
7
|
+
gem "prosemirror"
|
|
8
|
+
gem "herb" # optional, only if you need HTML import
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Rendering, from prosemirror_to_html
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
# Before
|
|
15
|
+
ProsemirrorToHtml::Renderer.new.render(json)
|
|
16
|
+
|
|
17
|
+
# After
|
|
18
|
+
Prosemirror.parse(json).to_html
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
`parse` takes the same input: a JSON string, or a Hash with String or Symbol keys. Type names can be in snake_case or camelCase.
|
|
22
|
+
|
|
23
|
+
### Custom nodes and marks
|
|
24
|
+
|
|
25
|
+
A node type is a [Phlex](https://www.phlex.fun) component, and its attributes are declared with `prop`. It's registered in a schema instead of a renderer:
|
|
26
|
+
|
|
27
|
+
```ruby
|
|
28
|
+
# Before
|
|
29
|
+
class Callout < ProsemirrorToHtml::Nodes::Node
|
|
30
|
+
@node_type = "callout"
|
|
31
|
+
|
|
32
|
+
def tag
|
|
33
|
+
[{tag: "aside", attrs: {class: "callout callout-#{@node[:attrs][:tone]}"}}]
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
renderer = ProsemirrorToHtml::Renderer.new.add_node(Callout)
|
|
38
|
+
renderer.render(json)
|
|
39
|
+
|
|
40
|
+
# After
|
|
41
|
+
class Callout < Prosemirror::NodeType
|
|
42
|
+
group :block
|
|
43
|
+
content "block+"
|
|
44
|
+
prop :tone, _String?
|
|
45
|
+
|
|
46
|
+
def view_template(&) = aside(class: ["callout", @tone && "callout-#{@tone}"], &)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
schema = Prosemirror::Schema.tiptap.with(Callout)
|
|
50
|
+
Prosemirror.parse(json, schema:).to_html
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
| prosemirror_to_html | prosemirror |
|
|
54
|
+
|---|---|
|
|
55
|
+
| `@node_type = "callout"` | The class name (`Callout` → `callout`), or `type_name :callout` |
|
|
56
|
+
| `@tag_name`, `def tag` | `def view_template(&)`, which yields to render the content |
|
|
57
|
+
| `@node[:attrs][:tone]` | `prop :tone, _String?`, then `@tone` |
|
|
58
|
+
| `def self_closing = true` | No `content`: the node is a leaf |
|
|
59
|
+
| `def text` | `view_template` renders whatever the node shows |
|
|
60
|
+
| `renderer.add_node(Callout)`, `add_mark` | `Prosemirror::Schema.tiptap.with(Callout)` |
|
|
61
|
+
| `renderer.replace_node(Old, New)` | A subclass of the built-in type, registered with `with`, which replaces it |
|
|
62
|
+
| `with_nodes([...])`, `with_marks([...])` | `Prosemirror::Schema.new(nodes: [...], marks: [...])` |
|
|
63
|
+
|
|
64
|
+
Mark types work the same way, with `Prosemirror::MarkType`. See [Node and mark types](TYPES.md) for everything a type can declare.
|
|
65
|
+
|
|
66
|
+
### Differences in the HTML
|
|
67
|
+
|
|
68
|
+
prosemirror_to_html built HTML strings, then cleaned them up with Loofah. `prosemirror` builds the HTML with Phlex, which escapes text and attribute values, and cleans nothing up afterwards:
|
|
69
|
+
|
|
70
|
+
- **Only declared attributes are rendered.** prosemirror_to_html wrote every attribute of a heading, link, image, ordered list or table cell into the HTML, so `{"level": 2, "id": "intro"}` gave `<h2 id="intro">`. `prosemirror` renders the attributes each type declares. To render more, subclass the type (see [Changing built-in types](README.md#changing-built-in-types)). Other attributes are kept in `to_h`.
|
|
71
|
+
- **Unsafe URLs are dropped.** A link whose `href` isn't allowed renders as its text, instead of an `<a>` without `href`, and an image without an allowed `src` renders nothing. See [URLs](README.md#urls).
|
|
72
|
+
- **Custom types render as written.** Loofah removed elements and attributes it didn't know, like `<iframe>`, `<picture>` or `srcset`. They now render. Pass URLs from the document through `safe_url`.
|
|
73
|
+
- **Invalid attributes are fixed and reported** with `on_invalid`, instead of being written as they are. See [Invalid documents](README.md#invalid-documents).
|
|
74
|
+
- **HTML5:** strike is `<s>`, not `<strike>`. A code block's language is a `language-` class on `<code>`, and an ordered list starting at 1 has no `start`.
|
|
75
|
+
- **Marks are nested in schema order**, as Tiptap nests them, rather than in the order of the JSON: `<strong><em>`.
|
|
76
|
+
- **More types:** highlight, task lists, details, mentions, and YouTube and Twitch embeds render, where prosemirror_to_html rendered only their text, or nothing.
|
|
77
|
+
|
|
78
|
+
## HTML import, from html_to_prosemirror
|
|
79
|
+
|
|
80
|
+
```ruby
|
|
81
|
+
# Before
|
|
82
|
+
HtmlToProsemirror::Renderer.new.render(html) # snake_case
|
|
83
|
+
HtmlToProsemirror::Renderer.new(case_type: :lower_camel).render(html) # camelCase
|
|
84
|
+
|
|
85
|
+
# After
|
|
86
|
+
Prosemirror.from_html(html).to_h
|
|
87
|
+
Prosemirror.from_html(html, schema: Prosemirror::Schema.tiptap(case: :camel)).to_h
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
`to_h` returns a Hash with String keys, where html_to_prosemirror returned Symbol keys. HTML import needs the [herb](https://herb-tools.dev) gem.
|
|
91
|
+
|
|
92
|
+
### Differences in the JSON
|
|
93
|
+
|
|
94
|
+
The JSON follows the schema, as Tiptap's `generateJSON` does:
|
|
95
|
+
|
|
96
|
+
- **Documents are valid.** Text in a list item is wrapped in a paragraph, text at the top level too, and attributes have their types: a heading's `level` is `1`, not `"1"`.
|
|
97
|
+
- **Nested marks keep their text.** html_to_prosemirror dropped text in nested marks, like `<a><b>bold link</b></a>`.
|
|
98
|
+
- **Styles become marks:** `font-weight`, `font-style`, `text-decoration` and `vertical-align`, as in pasted Word or Google Docs content.
|
|
99
|
+
- **Whitespace is kept** as HTML defines it: newlines in `<pre>` stay, and character references like `©` are decoded.
|
|
100
|
+
- **More types:** strike, underline, subscript, superscript, highlight, tables, task lists, details, mentions and embeds.
|
|
101
|
+
|
|
102
|
+
### The `user` node
|
|
103
|
+
|
|
104
|
+
html_to_prosemirror turned `<user-mention data-id="7">` into a `user` node. `prosemirror` has Tiptap's `mention` instead. To keep the `user` node, declare it:
|
|
105
|
+
|
|
106
|
+
```ruby
|
|
107
|
+
class User < Prosemirror::NodeType
|
|
108
|
+
group :inline
|
|
109
|
+
inline
|
|
110
|
+
prop :id, _String?, parse_html: ->(element) { element["data-id"] }
|
|
111
|
+
parse_html tag: "user-mention"
|
|
112
|
+
|
|
113
|
+
def view_template = span(class: "user-mention", data_id: @id)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
schema = Prosemirror::Schema.tiptap.with(User)
|
|
117
|
+
Prosemirror.from_html(html, schema:).to_h # {"type" => "user", "attrs" => {"id" => "7"}}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## Going further
|
|
121
|
+
|
|
122
|
+
Beyond what the legacy gems did, `prosemirror` can:
|
|
123
|
+
|
|
124
|
+
- write a document as [Markdown](README.md#markdown-export), useful for LLMs, or as [plain text](README.md#usage);
|
|
125
|
+
- import [Markdown](README.md#markdown-import) and [plain text](README.md#text-import), with the same schema as HTML import;
|
|
126
|
+
- [query and transform documents](README.md#transforming-documents): nodes are immutable values with pattern matching, and `transform` builds a new document, for example to [remove empty paragraphs before saving](COOKBOOK.md#remove-empty-paragraphs-before-saving);
|
|
127
|
+
- [check documents](README.md#transforming-documents) against the schema, and fix and report [invalid documents](README.md#invalid-documents);
|
|
128
|
+
- render inside Rails views and [Phlex components](README.md#usage), and [your own components](COOKBOOK.md#render-your-own-components) for custom nodes;
|
|
129
|
+
- resolve link URLs, for example [links to records with GlobalID](COOKBOOK.md#links-to-records-with-globalid), with [allowed URL schemes](README.md#urls);
|
|
130
|
+
- use Tiptap's [task lists, details, mentions, YouTube and Twitch embeds, and text alignment](README.md#schemas), or ProseMirror's own schema;
|
|
131
|
+
- declare custom types with [content expressions, attributes and parse rules](TYPES.md).
|
data/README.md
CHANGED
|
@@ -7,7 +7,7 @@ Render [ProseMirror](https://prosemirror.net) and [Tiptap](https://tiptap.dev) d
|
|
|
7
7
|
- Link and image URLs must use an allowed scheme.
|
|
8
8
|
- No Rails dependency.
|
|
9
9
|
|
|
10
|
-
This gem replaces [prosemirror_to_html](https://github.com/etaminstudio/prosemirror_to_html) and [html_to_prosemirror](https://github.com/etaminstudio/html_to_prosemirror). See the [roadmap](ROADMAP.md) for what comes next.
|
|
10
|
+
This gem replaces [prosemirror_to_html](https://github.com/etaminstudio/prosemirror_to_html) and [html_to_prosemirror](https://github.com/etaminstudio/html_to_prosemirror): see [Migrating](MIGRATING.md). See the [roadmap](ROADMAP.md) for what comes next.
|
|
11
11
|
|
|
12
12
|
## Installation
|
|
13
13
|
|
|
@@ -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
|
|
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
|
-
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
152
|
-
|
|
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`).
|
|
@@ -232,6 +259,8 @@ Prosemirror::Schema.tiptap(
|
|
|
232
259
|
)
|
|
233
260
|
```
|
|
234
261
|
|
|
262
|
+
`resolve_urls:` gets all the URLs of a document at once, before rendering it, and returns a Hash from each URL to its rewritten URL, to look up records in one query (see the [cookbook](COOKBOOK.md#links-to-records-with-globalid)). A URL missing from the Hash, or mapped to nil, is removed and reported, as with `resolve_url`. The URLs are those of the attributes declared with `url: true`, like links' `href` and images' `src`, which `doc.urls` lists. URLs that types build while rendering, like YouTube's embed URLs, are resolved one at a time.
|
|
263
|
+
|
|
235
264
|
## Development
|
|
236
265
|
|
|
237
266
|
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests, and `rake` to also run [Standard](https://github.com/standardrb/standard). You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
data/ROADMAP.md
CHANGED
|
@@ -199,9 +199,16 @@ 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
|
-
-
|
|
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.
|
|
207
|
+
|
|
208
|
+
### 0.7: Resolving URLs at once (released)
|
|
209
|
+
|
|
210
|
+
- `resolve_urls:` resolves all the URLs of a document before rendering it, to look up records in one query. Types declare their URL attributes with `prop … url: true`, and `Document#urls` lists them.
|
|
211
|
+
- A [migration guide](MIGRATING.md) from `prosemirror_to_html` and `html_to_prosemirror`.
|
|
205
212
|
|
|
206
213
|
### 1.0
|
|
207
214
|
|
data/TYPES.md
CHANGED
|
@@ -95,12 +95,13 @@ prop :alt, _String?
|
|
|
95
95
|
- **Default:** used when the attribute is missing or invalid. Literal needs a frozen value or a Proc: `default: "left".freeze`, or a file with `# frozen_string_literal: true`. An attribute that accepts nil defaults to nil. An attribute without a default is required: without a valid value, the node or mark is removed.
|
|
96
96
|
- **Coercion:** the block converts values before they are checked. `Prosemirror::Coerce::INTEGER` turns `"2"` into `2`.
|
|
97
97
|
|
|
98
|
-
`prop` takes
|
|
98
|
+
`prop` takes four more options:
|
|
99
99
|
|
|
100
100
|
| Option | Meaning |
|
|
101
101
|
|---|---|
|
|
102
102
|
| `json: "textAlign"` | The name in JSON, when it differs from the property name. |
|
|
103
103
|
| `aliases: ["order"]` | Other JSON names accepted on input. |
|
|
104
|
+
| `url: true` | The attribute holds a URL: `doc.urls` lists it, and the schema's `resolve_urls` resolves it with the document's other URLs before rendering. Render it with `safe_url`. |
|
|
104
105
|
| `parse_html: ->(element) { … }` | How to read the attribute from an HTML element, when importing HTML. By default, the HTML attribute with the JSON name is read. |
|
|
105
106
|
|
|
106
107
|
Attributes that a type doesn't declare are kept in `unknown_attrs`. They are never rendered, and `to_h` gives them back.
|
|
@@ -259,3 +260,15 @@ Prosemirror.schema = Prosemirror::Schema.tiptap.with(Paragraph)
|
|
|
259
260
|
```
|
|
260
261
|
|
|
261
262
|
A subclass that declares parse rules replaces the rules of its parent. See the [cookbook](COOKBOOK.md) for more examples.
|
|
263
|
+
|
|
264
|
+
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`:
|
|
265
|
+
|
|
266
|
+
```ruby
|
|
267
|
+
class Callout < Prosemirror::NodeType
|
|
268
|
+
include Prosemirror::TextAlign
|
|
269
|
+
group :block
|
|
270
|
+
content "inline*"
|
|
271
|
+
|
|
272
|
+
def view_template(&) = aside(style: text_align_style, &)
|
|
273
|
+
end
|
|
274
|
+
```
|
data/lib/prosemirror/document.rb
CHANGED
|
@@ -24,6 +24,15 @@ module Prosemirror
|
|
|
24
24
|
root.descendants(&)
|
|
25
25
|
end
|
|
26
26
|
|
|
27
|
+
# The URLs in the attributes of its nodes and marks declared with `url: true`, like links' `href` and
|
|
28
|
+
# images' `src`, without duplicates.
|
|
29
|
+
def urls
|
|
30
|
+
each_with_object(Set.new) do |node, urls|
|
|
31
|
+
add_urls(urls, schema.node_type(node.type), node.attrs)
|
|
32
|
+
node.marks.each { |mark| add_urls(urls, schema.mark_type(mark.type), mark.attrs) }
|
|
33
|
+
end.to_a
|
|
34
|
+
end
|
|
35
|
+
|
|
27
36
|
# The document as HTML. The string is `html_safe` when ActiveSupport is loaded.
|
|
28
37
|
def to_html
|
|
29
38
|
html = to_phlex.call
|
|
@@ -110,5 +119,14 @@ module Prosemirror
|
|
|
110
119
|
def hash
|
|
111
120
|
root.hash
|
|
112
121
|
end
|
|
122
|
+
|
|
123
|
+
private
|
|
124
|
+
|
|
125
|
+
def add_urls(urls, type, attrs)
|
|
126
|
+
type&.url_attributes&.each do |name|
|
|
127
|
+
value = attrs[name]
|
|
128
|
+
urls << value if value.is_a?(String)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
113
131
|
end
|
|
114
132
|
end
|
|
@@ -13,7 +13,9 @@ module Prosemirror
|
|
|
13
13
|
@_prosemirror_schema = document.schema
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
+
# The document's URLs are resolved at once, with the schema's `resolve_urls`, for each rendering.
|
|
16
17
|
def view_template
|
|
18
|
+
@_prosemirror_urls = prosemirror_schema.resolve_urls(@document.urls) if prosemirror_schema.resolve_urls?
|
|
17
19
|
render_content([@document.root])
|
|
18
20
|
end
|
|
19
21
|
end
|
|
@@ -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
|
|
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:
|
|
19
|
+
strikethrough: true, table: true, autolink: true, tasklist: true, shortcodes: false, header_ids: nil,
|
|
19
20
|
front_matter_delimiter: "---"
|
|
20
21
|
}.freeze
|
|
21
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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]
|
|
221
|
+
run += 1 while index - run - 1 >= 0 && same.call(parent.content[index - run - 1])
|
|
220
222
|
run.odd?
|
|
221
223
|
end
|
|
222
224
|
|
|
@@ -5,7 +5,7 @@ module Prosemirror
|
|
|
5
5
|
module Basic
|
|
6
6
|
# ProseMirror's link. Without an allowed `href`, its text is rendered without a link.
|
|
7
7
|
class Link < MarkType
|
|
8
|
-
prop :href, _String(length: 1..)
|
|
8
|
+
prop :href, _String(length: 1..), url: true
|
|
9
9
|
prop :title, _String?
|
|
10
10
|
parse_html(tag: "a[href]") { |element, schema| schema.allowed_url?(element["href"]) && {} }
|
|
11
11
|
|
|
@@ -4,7 +4,7 @@ module Prosemirror
|
|
|
4
4
|
module Marks
|
|
5
5
|
# Tiptap's link. Without an allowed `href`, its text is rendered without a link.
|
|
6
6
|
class Link < MarkType
|
|
7
|
-
prop :href, _String(length: 1..)
|
|
7
|
+
prop :href, _String(length: 1..), url: true
|
|
8
8
|
prop :target, _String?
|
|
9
9
|
prop :rel, _String?
|
|
10
10
|
prop :css_class, _String?, json: "class"
|
|
@@ -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 =
|
|
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,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
|
|
@@ -14,9 +14,9 @@ module Prosemirror
|
|
|
14
14
|
@_prosemirror_schema
|
|
15
15
|
end
|
|
16
16
|
|
|
17
|
-
# The URL, when its scheme is allowed, after the schema's `resolve_url`. Otherwise nil.
|
|
17
|
+
# The URL, when its scheme is allowed, after the schema's `resolve_url` or `resolve_urls`. Otherwise nil.
|
|
18
18
|
def safe_url(url)
|
|
19
|
-
prosemirror_schema.safe_url(url)
|
|
19
|
+
prosemirror_schema.safe_url(url, @_prosemirror_urls)
|
|
20
20
|
end
|
|
21
21
|
|
|
22
22
|
private
|
|
@@ -64,6 +64,7 @@ module Prosemirror
|
|
|
64
64
|
|
|
65
65
|
def prepare(component)
|
|
66
66
|
component.instance_variable_set(:@_prosemirror_schema, prosemirror_schema)
|
|
67
|
+
component.instance_variable_set(:@_prosemirror_urls, @_prosemirror_urls)
|
|
67
68
|
component
|
|
68
69
|
end
|
|
69
70
|
end
|
data/lib/prosemirror/schema.rb
CHANGED
|
@@ -19,18 +19,25 @@ module Prosemirror
|
|
|
19
19
|
end
|
|
20
20
|
# Called with each URL before it is checked, to rewrite it.
|
|
21
21
|
prop :resolve_url, _Callable?, reader: false
|
|
22
|
+
# Called with all the URLs of a document at once, before rendering it: returns a Hash from each URL to
|
|
23
|
+
# its rewritten URL, or nil. Instead of `resolve_url`, to look up records in one query.
|
|
24
|
+
prop :resolve_urls, _Callable?, reader: false, predicate: :public
|
|
22
25
|
# Raise `InvalidDocument` instead of fixing invalid documents.
|
|
23
26
|
prop :strict, _Boolean, default: false, reader: false, predicate: :public
|
|
24
27
|
# Called with an `InvalidDocument` error for each problem fixed.
|
|
25
28
|
prop :on_invalid, _Callable?, reader: false
|
|
26
29
|
|
|
27
|
-
# Tiptap's StarterKit and common extensions.
|
|
28
|
-
|
|
30
|
+
# Tiptap's StarterKit and common extensions. `text_align: true` adds the TextAlign extension, configured
|
|
31
|
+
# for headings and paragraphs.
|
|
32
|
+
def self.tiptap(text_align: false, **)
|
|
29
33
|
new(
|
|
30
34
|
nodes: [
|
|
31
|
-
Nodes::Doc,
|
|
35
|
+
Nodes::Doc, text_align ? TextAlign::Paragraph : Nodes::Paragraph, Nodes::Text,
|
|
36
|
+
text_align ? TextAlign::Heading : Nodes::Heading, Nodes::Blockquote, Nodes::BulletList,
|
|
32
37
|
Nodes::OrderedList, Nodes::ListItem, Nodes::CodeBlock, Nodes::HardBreak, Nodes::HorizontalRule,
|
|
33
|
-
Nodes::Image, Nodes::Table, Nodes::TableRow, Nodes::TableHeader, Nodes::TableCell
|
|
38
|
+
Nodes::Image, Nodes::Table, Nodes::TableRow, Nodes::TableHeader, Nodes::TableCell, Nodes::TaskList,
|
|
39
|
+
Nodes::TaskItem, Nodes::Details, Nodes::DetailsSummary, Nodes::DetailsContent, Nodes::Youtube,
|
|
40
|
+
Nodes::Twitch, Nodes::Mention
|
|
34
41
|
],
|
|
35
42
|
# In Tiptap's order, which sorts and nests marks.
|
|
36
43
|
marks: [
|
|
@@ -62,6 +69,7 @@ module Prosemirror
|
|
|
62
69
|
@textblocks = @nodes.to_h { |type| [type, textblock_type?(type)] }
|
|
63
70
|
@content_matches = @nodes.to_h { |type| [type, ContentMatch.compile(type.content, self)] }
|
|
64
71
|
raise ArgumentError, "the schema has no #{@top_node} node type" unless node_type(@top_node)
|
|
72
|
+
raise ArgumentError, "resolve_url and resolve_urls can't be used together" if @resolve_url && @resolve_urls
|
|
65
73
|
end
|
|
66
74
|
|
|
67
75
|
# The start state of a node type's content expression.
|
|
@@ -170,6 +178,14 @@ module Prosemirror
|
|
|
170
178
|
|
|
171
179
|
# Whether a URL's scheme is allowed, or the URL is relative. Browsers ignore leading control characters
|
|
172
180
|
# and spaces, and tabs and newlines anywhere in a URL.
|
|
181
|
+
# The URLs resolved with `resolve_urls`, as a Hash, or nil without it.
|
|
182
|
+
def resolve_urls(urls)
|
|
183
|
+
return unless @resolve_urls
|
|
184
|
+
return {} if urls.empty?
|
|
185
|
+
|
|
186
|
+
@resolve_urls.call(urls).to_h
|
|
187
|
+
end
|
|
188
|
+
|
|
173
189
|
def allowed_url?(url)
|
|
174
190
|
return false unless url.is_a?(String)
|
|
175
191
|
|
|
@@ -190,11 +206,13 @@ module Prosemirror
|
|
|
190
206
|
@textblocks.fetch(type, false)
|
|
191
207
|
end
|
|
192
208
|
|
|
193
|
-
# The URL after `resolve_url`, when its scheme is allowed. Otherwise nil, and the
|
|
194
|
-
|
|
209
|
+
# The URL after `resolve_url` or `resolve_urls`, when its scheme is allowed. Otherwise nil, and the
|
|
210
|
+
# problem is reported. `resolved_urls` holds URLs already resolved with `resolve_urls`: others are
|
|
211
|
+
# resolved one at a time.
|
|
212
|
+
def safe_url(url, resolved_urls = nil)
|
|
195
213
|
return nil unless url.is_a?(String)
|
|
196
214
|
|
|
197
|
-
resolved =
|
|
215
|
+
resolved = resolve(url, resolved_urls)
|
|
198
216
|
return report("URL could not be resolved: #{url.inspect}") unless resolved.is_a?(String)
|
|
199
217
|
return report("URL scheme not allowed: #{resolved.inspect}") unless allowed_url?(resolved)
|
|
200
218
|
|
|
@@ -223,6 +241,14 @@ module Prosemirror
|
|
|
223
241
|
|
|
224
242
|
private
|
|
225
243
|
|
|
244
|
+
def resolve(url, resolved_urls)
|
|
245
|
+
return @resolve_url.call(url) if @resolve_url
|
|
246
|
+
return url unless @resolve_urls
|
|
247
|
+
|
|
248
|
+
resolved_urls = resolve_urls([url]) unless resolved_urls&.key?(url)
|
|
249
|
+
resolved_urls[url]
|
|
250
|
+
end
|
|
251
|
+
|
|
226
252
|
def lookup(types)
|
|
227
253
|
types.each_with_object({}) do |type, table|
|
|
228
254
|
[type.type_name, *type.aliases].each { |name| table[name] ||= type }
|
|
@@ -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
|
|
@@ -23,14 +23,16 @@ module Prosemirror
|
|
|
23
23
|
# - `json:` the attribute name in JSON, when it differs from the property name (`"textAlign"`);
|
|
24
24
|
# - `aliases:` other JSON names accepted on input (`["order"]`);
|
|
25
25
|
# - `parse_html:` a block reading the attribute from an HTML element, when importing HTML. By default,
|
|
26
|
-
# the HTML attribute with the JSON name is read
|
|
27
|
-
|
|
26
|
+
# the HTML attribute with the JSON name is read;
|
|
27
|
+
# - `url: true` for an attribute holding a URL, which `Document#urls` lists and `resolve_urls` resolves
|
|
28
|
+
# before rendering.
|
|
29
|
+
def prop(name, type, kind = :keyword, json: nil, aliases: [], parse_html: nil, url: false, **, &)
|
|
28
30
|
json = (json || name).to_s
|
|
29
31
|
aliases = aliases.map(&:to_s)
|
|
30
32
|
# The keys accepted in JSON: each name as written, in snake_case and in camelCase.
|
|
31
33
|
keys = [json, *aliases].flat_map { |key| [key, Naming.snake(key), Naming.camel(key)] }.uniq.map(&:freeze).freeze
|
|
32
|
-
attribute_names[name] = {json:, aliases:, parse_html:, keys:}.freeze
|
|
33
|
-
@known_keys = nil
|
|
34
|
+
attribute_names[name] = {json:, aliases:, parse_html:, keys:, url:}.freeze
|
|
35
|
+
@known_keys = @url_attributes = nil
|
|
34
36
|
super(name, type, kind, **, &)
|
|
35
37
|
end
|
|
36
38
|
|
|
@@ -108,6 +110,11 @@ module Prosemirror
|
|
|
108
110
|
@known_keys ||= attribute_names.each_value.flat_map { |names| names[:keys] }.to_set.freeze
|
|
109
111
|
end
|
|
110
112
|
|
|
113
|
+
# The names of the attributes declared with `url: true`.
|
|
114
|
+
def url_attributes
|
|
115
|
+
@url_attributes ||= attribute_names.filter_map { |name, names| name if names[:url] }.freeze
|
|
116
|
+
end
|
|
117
|
+
|
|
111
118
|
# Attributes as JSON, with their JSON names.
|
|
112
119
|
def serialize_attrs(attrs)
|
|
113
120
|
attrs.to_h { |name, value| [attribute_names.dig(name, :json) || name.to_s, value] }
|
data/lib/prosemirror/version.rb
CHANGED
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.
|
|
4
|
+
version: 0.7.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Hans Lemuet
|
|
@@ -62,6 +62,7 @@ files:
|
|
|
62
62
|
- CHANGELOG.md
|
|
63
63
|
- COOKBOOK.md
|
|
64
64
|
- LICENSE.txt
|
|
65
|
+
- MIGRATING.md
|
|
65
66
|
- README.md
|
|
66
67
|
- ROADMAP.md
|
|
67
68
|
- TYPES.md
|
|
@@ -107,22 +108,31 @@ files:
|
|
|
107
108
|
- lib/prosemirror/nodes/blockquote.rb
|
|
108
109
|
- lib/prosemirror/nodes/bullet_list.rb
|
|
109
110
|
- lib/prosemirror/nodes/code_block.rb
|
|
111
|
+
- lib/prosemirror/nodes/details.rb
|
|
112
|
+
- lib/prosemirror/nodes/details_content.rb
|
|
113
|
+
- lib/prosemirror/nodes/details_summary.rb
|
|
110
114
|
- lib/prosemirror/nodes/doc.rb
|
|
111
115
|
- lib/prosemirror/nodes/hard_break.rb
|
|
112
116
|
- lib/prosemirror/nodes/heading.rb
|
|
113
117
|
- lib/prosemirror/nodes/horizontal_rule.rb
|
|
114
118
|
- lib/prosemirror/nodes/image.rb
|
|
115
119
|
- lib/prosemirror/nodes/list_item.rb
|
|
120
|
+
- lib/prosemirror/nodes/mention.rb
|
|
116
121
|
- lib/prosemirror/nodes/ordered_list.rb
|
|
117
122
|
- lib/prosemirror/nodes/paragraph.rb
|
|
118
123
|
- lib/prosemirror/nodes/table.rb
|
|
119
124
|
- lib/prosemirror/nodes/table_cell.rb
|
|
120
125
|
- lib/prosemirror/nodes/table_header.rb
|
|
121
126
|
- lib/prosemirror/nodes/table_row.rb
|
|
127
|
+
- lib/prosemirror/nodes/task_item.rb
|
|
128
|
+
- lib/prosemirror/nodes/task_list.rb
|
|
122
129
|
- lib/prosemirror/nodes/text.rb
|
|
130
|
+
- lib/prosemirror/nodes/twitch.rb
|
|
131
|
+
- lib/prosemirror/nodes/youtube.rb
|
|
123
132
|
- lib/prosemirror/parse_rule.rb
|
|
124
133
|
- lib/prosemirror/rendering.rb
|
|
125
134
|
- lib/prosemirror/schema.rb
|
|
135
|
+
- lib/prosemirror/text_align.rb
|
|
126
136
|
- lib/prosemirror/text_serializer.rb
|
|
127
137
|
- lib/prosemirror/transformer.rb
|
|
128
138
|
- lib/prosemirror/type_definition.rb
|