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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3717422ef93a6f35012d264a8d82b3fe9c2982631d5b2f39a499be9ad2da79a7
4
- data.tar.gz: 05af2f5ff65a7a07e0f1134627fd6e4110d089264b9df1ad78f66000c4bda30e
3
+ metadata.gz: 76385bda3d9b696b80e976154bf40c5d33ad2b9ac79b48e5f3fa8a6f7bf224e8
4
+ data.tar.gz: 7acda4288b7bbb2d731a111143dc26b715177551476829ffa6416a7a298180c6
5
5
  SHA512:
6
- metadata.gz: 950422c7a48833915def7682efdb216a93c416acbfcb57ea712da29d220280a269d524013cc5bff57c0cc706fa243b7e0cd7080985bc8bd129224f182b1e4c62
7
- data.tar.gz: 3914858a0eadcabd1d17dc253259a2c4786cb22332aff14b66c3d8fe89f9ea99de7c0ea4c6e70255fb23b9b992b543bd22bde54fdb2f55dbb6232185ba306168
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 `&copy` 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 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`).
@@ -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
- - 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.
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 three more options:
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
+ ```
@@ -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 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
 
@@ -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"
@@ -8,7 +8,7 @@ module Prosemirror
8
8
  group :inline
9
9
  inline
10
10
 
11
- prop :src, _String(length: 1..)
11
+ prop :src, _String(length: 1..), url: true
12
12
  prop :alt, _String?
13
13
  prop :title, _String?
14
14
  parse_html tag: "img[src]"
@@ -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
@@ -6,7 +6,7 @@ module Prosemirror
6
6
  class Image < NodeType
7
7
  group :block
8
8
 
9
- prop :src, _String(length: 1..)
9
+ prop :src, _String(length: 1..), url: true
10
10
  prop :alt, _String?
11
11
  prop :title, _String?
12
12
  prop :width, _Integer?(1..), &Coerce::INTEGER
@@ -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
@@ -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
- def self.tiptap(**)
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, Nodes::Paragraph, Nodes::Text, Nodes::Heading, Nodes::Blockquote, Nodes::BulletList,
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 problem is reported.
194
- def safe_url(url)
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 = @resolve_url ? @resolve_url.call(url) : url
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
- def prop(name, type, kind = :keyword, json: nil, aliases: [], parse_html: nil, **, &)
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] }
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Prosemirror
4
- VERSION = "0.5.0"
4
+ VERSION = "0.7.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.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