prosemirror 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +14 -0
  3. data/README.md +49 -4
  4. data/ROADMAP.md +9 -5
  5. data/TYPES.md +55 -0
  6. data/lib/prosemirror/checker.rb +2 -2
  7. data/lib/prosemirror/coerce.rb +7 -0
  8. data/lib/prosemirror/document.rb +6 -0
  9. data/lib/prosemirror/html/element.rb +1 -1
  10. data/lib/prosemirror/html/selector.rb +12 -4
  11. data/lib/prosemirror/html_parser.rb +9 -2
  12. data/lib/prosemirror/json_parser.rb +71 -40
  13. data/lib/prosemirror/mark.rb +8 -3
  14. data/lib/prosemirror/mark_type.rb +15 -0
  15. data/lib/prosemirror/markdown/builder.rb +65 -13
  16. data/lib/prosemirror/markdown_serializer.rb +518 -0
  17. data/lib/prosemirror/marks/basic/code.rb +2 -0
  18. data/lib/prosemirror/marks/basic/em.rb +2 -0
  19. data/lib/prosemirror/marks/basic/link.rb +2 -0
  20. data/lib/prosemirror/marks/basic/strong.rb +2 -0
  21. data/lib/prosemirror/marks/bold.rb +2 -0
  22. data/lib/prosemirror/marks/code.rb +2 -0
  23. data/lib/prosemirror/marks/italic.rb +2 -0
  24. data/lib/prosemirror/marks/link.rb +2 -0
  25. data/lib/prosemirror/marks/strike.rb +2 -0
  26. data/lib/prosemirror/node.rb +21 -6
  27. data/lib/prosemirror/node_type.rb +13 -0
  28. data/lib/prosemirror/nodes/basic/code_block.rb +4 -0
  29. data/lib/prosemirror/nodes/basic/image.rb +4 -0
  30. data/lib/prosemirror/nodes/basic/ordered_list.rb +4 -0
  31. data/lib/prosemirror/nodes/blockquote.rb +4 -0
  32. data/lib/prosemirror/nodes/bullet_list.rb +15 -0
  33. data/lib/prosemirror/nodes/code_block.rb +15 -0
  34. data/lib/prosemirror/nodes/details.rb +27 -0
  35. data/lib/prosemirror/nodes/details_content.rb +12 -0
  36. data/lib/prosemirror/nodes/details_summary.rb +12 -0
  37. data/lib/prosemirror/nodes/hard_break.rb +5 -0
  38. data/lib/prosemirror/nodes/heading.rb +6 -0
  39. data/lib/prosemirror/nodes/horizontal_rule.rb +5 -0
  40. data/lib/prosemirror/nodes/image.rb +16 -0
  41. data/lib/prosemirror/nodes/list_item.rb +4 -0
  42. data/lib/prosemirror/nodes/mention.rb +36 -0
  43. data/lib/prosemirror/nodes/ordered_list.rb +11 -0
  44. data/lib/prosemirror/nodes/paragraph.rb +8 -0
  45. data/lib/prosemirror/nodes/table.rb +36 -0
  46. data/lib/prosemirror/nodes/task_item.rb +40 -0
  47. data/lib/prosemirror/nodes/task_list.rb +24 -0
  48. data/lib/prosemirror/nodes/text.rb +4 -0
  49. data/lib/prosemirror/nodes/twitch.rb +94 -0
  50. data/lib/prosemirror/nodes/youtube.rb +71 -0
  51. data/lib/prosemirror/schema.rb +22 -6
  52. data/lib/prosemirror/text_align.rb +31 -0
  53. data/lib/prosemirror/type_definition.rb +33 -13
  54. data/lib/prosemirror/version.rb +1 -1
  55. metadata +11 -1
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Prosemirror
6
+ module Nodes
7
+ # Tiptap's YouTube embed. `src` is the video's URL, like `https://www.youtube.com/watch?v=…`, rendered as
8
+ # an iframe of its embed URL. Without a YouTube URL, it renders nothing. Override `embed_host` for
9
+ # `https://www.youtube-nocookie.com`, as Tiptap's `nocookie` option.
10
+ class Youtube < NodeType
11
+ # The URLs Tiptap accepts: on youtube.com, youtu.be and youtube-nocookie.com.
12
+ URL = %r{\A(?:(?:https?:)?//)?(?:(?:www|m|music)\.)?(?:youtube\.com|youtu\.be|youtube-nocookie\.com)(?:/(?:[\w-]+\?v=|embed/|v/)?)[\w-]+\S*\z}
13
+ EMBED_HOSTS = %w[youtube.com youtube-nocookie.com].freeze
14
+
15
+ group :block
16
+
17
+ prop :src, _String(length: 1..), parse_html: ->(element) { Youtube.from_embed_url(element["src"])&.fetch(:src) || element["src"] }
18
+ prop :start, _Integer(0..), default: 0,
19
+ parse_html: ->(element) { Youtube.from_embed_url(element["src"])&.fetch(:start) }, &Coerce::INTEGER
20
+ prop :width, _Integer?(1..), default: 640, &Coerce::INTEGER
21
+ prop :height, _Integer?(1..), default: 480, &Coerce::INTEGER
22
+
23
+ # Tiptap's markup, `<div data-youtube-video><iframe>`, or an iframe of a YouTube embed URL.
24
+ parse_html(tag: "iframe[src]") do |element|
25
+ (element.parent&.key?("data-youtube-video") || Youtube.from_embed_url(element["src"])) ? {} : false
26
+ end
27
+
28
+ def view_template
29
+ src = safe_url(self.class.embed_url(@src, start: @start, host: embed_host))
30
+ return unless src
31
+
32
+ div(data_youtube_video: "") { iframe(src:, width: @width, height: @height, allowfullscreen: true) }
33
+ end
34
+
35
+ def embed_host = "https://www.youtube.com"
36
+
37
+ # The URL, as text that Markdown links. With `html: true`, the iframe.
38
+ def to_markdown(markdown, _parent, _index)
39
+ markdown.write(markdown.html? ? markdown.node_html(node) : "<#{markdown.url(@src)}>")
40
+ markdown.close_block(node)
41
+ end
42
+
43
+ # The embed URL of a video or playlist, as Tiptap builds it, or nil.
44
+ def self.embed_url(src, start: 0, host: "https://www.youtube.com")
45
+ return unless src.match?(URL)
46
+
47
+ if (playlist = src[/[?&]list=([\w-]+)/, 1])
48
+ return "#{host}/embed/videoseries?list=#{playlist}"
49
+ end
50
+
51
+ id = src[%r{(?:youtu\.be/|/embed/|/v/|/shorts/|/live/|[?&]v=)([\w-]+)}, 1]
52
+ id && "#{host}/embed/#{id}#{"?start=#{start}" if start.positive?}"
53
+ end
54
+
55
+ # The video URL and start time of an embed URL, as Tiptap reads them, or nil.
56
+ def self.from_embed_url(url)
57
+ uri = URI.parse(url.to_s)
58
+ return unless uri.is_a?(URI::HTTP) && EMBED_HOSTS.include?(uri.host.to_s.delete_prefix("www."))
59
+
60
+ query = URI.decode_www_form(uri.query.to_s).to_h
61
+ src = case uri.path
62
+ when "/embed/videoseries" then query["list"]&.then { |list| "https://www.youtube.com/playlist?list=#{list}" }
63
+ when %r{\A/embed/([\w-]+)\z} then "https://www.youtube.com/watch?v=#{Regexp.last_match(1)}"
64
+ end
65
+ {src:, start: query["start"].to_i} if src&.match?(URL)
66
+ rescue URI::Error, ArgumentError
67
+ nil
68
+ end
69
+ end
70
+ end
71
+ end
@@ -24,13 +24,17 @@ module Prosemirror
24
24
  # Called with an `InvalidDocument` error for each problem fixed.
25
25
  prop :on_invalid, _Callable?, reader: false
26
26
 
27
- # Tiptap's StarterKit and common extensions.
28
- def self.tiptap(**)
27
+ # Tiptap's StarterKit and common extensions. `text_align: true` adds the TextAlign extension, configured
28
+ # for headings and paragraphs.
29
+ def self.tiptap(text_align: false, **)
29
30
  new(
30
31
  nodes: [
31
- Nodes::Doc, Nodes::Paragraph, Nodes::Text, Nodes::Heading, Nodes::Blockquote, Nodes::BulletList,
32
+ Nodes::Doc, text_align ? TextAlign::Paragraph : Nodes::Paragraph, Nodes::Text,
33
+ text_align ? TextAlign::Heading : Nodes::Heading, Nodes::Blockquote, Nodes::BulletList,
32
34
  Nodes::OrderedList, Nodes::ListItem, Nodes::CodeBlock, Nodes::HardBreak, Nodes::HorizontalRule,
33
- Nodes::Image, Nodes::Table, Nodes::TableRow, Nodes::TableHeader, Nodes::TableCell
35
+ Nodes::Image, Nodes::Table, Nodes::TableRow, Nodes::TableHeader, Nodes::TableCell, Nodes::TaskList,
36
+ Nodes::TaskItem, Nodes::Details, Nodes::DetailsSummary, Nodes::DetailsContent, Nodes::Youtube,
37
+ Nodes::Twitch, Nodes::Mention
34
38
  ],
35
39
  # In Tiptap's order, which sorts and nests marks.
36
40
  marks: [
@@ -57,6 +61,8 @@ module Prosemirror
57
61
  def after_initialize
58
62
  @node_lookup = lookup(@nodes)
59
63
  @mark_lookup = lookup(@marks)
64
+ @node_names = spellings(@node_lookup)
65
+ @mark_names = spellings(@mark_lookup)
60
66
  @textblocks = @nodes.to_h { |type| [type, textblock_type?(type)] }
61
67
  @content_matches = @nodes.to_h { |type| [type, ContentMatch.compile(type.content, self)] }
62
68
  raise ArgumentError, "the schema has no #{@top_node} node type" unless node_type(@top_node)
@@ -100,11 +106,11 @@ module Prosemirror
100
106
 
101
107
  # The node type for a name, alias, or camelCase name.
102
108
  def node_type(name)
103
- @node_lookup[Naming.snake(name).to_sym]
109
+ @node_names[name] || @node_lookup[Naming.snake(name).to_sym]
104
110
  end
105
111
 
106
112
  def mark_type(name)
107
- @mark_lookup[Naming.snake(name).to_sym]
113
+ @mark_names[name] || @mark_lookup[Naming.snake(name).to_sym]
108
114
  end
109
115
 
110
116
  # Build a document from JSON, given as a Hash or a String.
@@ -228,6 +234,16 @@ module Prosemirror
228
234
  end
229
235
  end
230
236
 
237
+ # The names of a lookup table, as Symbols and Strings, in snake_case and camelCase, so that most names
238
+ # are found without converting them.
239
+ def spellings(lookup)
240
+ table = {}
241
+ lookup.each { |name, type| table[name] = table[name.name] = type }
242
+ lookup.each { |name, type| table[Naming.camel(name).freeze] ||= type }
243
+ table.each_key.select { |name| name.is_a?(String) }.each { |name| table[name.to_sym] ||= table[name] }
244
+ table.freeze
245
+ end
246
+
231
247
  def merge_types(types, additions)
232
248
  additions.reduce(types) do |list, addition|
233
249
  index = list.index { |type| type.type_name == addition.type_name }
@@ -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
@@ -3,6 +3,7 @@
3
3
  module Prosemirror
4
4
  # Class-level declarations shared by node types and mark types.
5
5
  module TypeDefinition
6
+ EMPTY_HASH = {}.freeze
6
7
  # The name of the type, as a snake_case Symbol. It defaults to the class name (`BulletList` → `:bullet_list`),
7
8
  # and subclasses of a concrete type keep its name.
8
9
  def type_name(name = nil)
@@ -24,7 +25,12 @@ module Prosemirror
24
25
  # - `parse_html:` a block reading the attribute from an HTML element, when importing HTML. By default,
25
26
  # the HTML attribute with the JSON name is read.
26
27
  def prop(name, type, kind = :keyword, json: nil, aliases: [], parse_html: nil, **, &)
27
- attribute_names[name] = {json: (json || name).to_s, aliases: aliases.map(&:to_s), parse_html:}.freeze
28
+ json = (json || name).to_s
29
+ aliases = aliases.map(&:to_s)
30
+ # The keys accepted in JSON: each name as written, in snake_case and in camelCase.
31
+ 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
28
34
  super(name, type, kind, **, &)
29
35
  end
30
36
 
@@ -54,7 +60,10 @@ module Prosemirror
54
60
  # Invalid values are converted when the meaning is clear, or replaced by their default.
55
61
  # Returns nil when a required attribute is missing or invalid.
56
62
  def build_attrs(given, report:)
57
- given = given.is_a?(Hash) ? given.transform_keys(&:to_s) : {}
63
+ given = if !given.is_a?(Hash) then EMPTY_HASH
64
+ elsif keys?(given) { |key| key.is_a?(String) } then given
65
+ else given.transform_keys(&:to_s)
66
+ end
58
67
 
59
68
  literal_properties.each_with_object({}) do |property, attrs|
60
69
  json_name = attribute_names.dig(property.name, :json)
@@ -86,14 +95,19 @@ module Prosemirror
86
95
 
87
96
  # The JSON attributes this type doesn't declare, with String keys, so that `to_h` gives them back.
88
97
  def unknown_attrs(given)
89
- return {} unless given.is_a?(Hash)
98
+ return EMPTY_HASH unless given.is_a?(Hash)
99
+
100
+ known = known_keys
101
+ return EMPTY_HASH if keys?(given) { |key| known.include?(key.is_a?(Symbol) ? key.name : key) }
90
102
 
91
- known = attribute_names.values.flat_map do |names|
92
- [names[:json], *names[:aliases]].flat_map { |name| [name, Naming.snake(name), Naming.camel(name)] }
93
- end
94
103
  given.transform_keys(&:to_s).except(*known)
95
104
  end
96
105
 
106
+ # The JSON keys of the declared attributes, in all their spellings.
107
+ def known_keys
108
+ @known_keys ||= attribute_names.each_value.flat_map { |names| names[:keys] }.to_set.freeze
109
+ end
110
+
97
111
  # Attributes as JSON, with their JSON names.
98
112
  def serialize_attrs(attrs)
99
113
  attrs.to_h { |name, value| [attribute_names.dig(name, :json) || name.to_s, value] }
@@ -122,17 +136,23 @@ module Prosemirror
122
136
  superclass.send(:inherited_setting, ivar, default)
123
137
  end
124
138
 
139
+ # Whether the block is true for all keys of the hash, without the Enumerator `each_key.all?` allocates.
140
+ def keys?(hash)
141
+ hash.each_key { |key| return false unless yield(key) }
142
+ true
143
+ end
144
+
125
145
  def attribute_key(given, names)
126
- [names[:json], *names[:aliases]].each do |name|
127
- [name, Naming.snake(name), Naming.camel(name)].each do |candidate|
128
- return candidate if given.key?(candidate)
129
- end
130
- end
131
- nil
146
+ names[:keys].find { |key| given.key?(key) }
132
147
  end
133
148
 
149
+ # The gem's coercions don't use `self`: they are called directly, without Literal's `instance_exec`.
134
150
  def coerce_attribute(property, value)
135
- property.coercion ? property.coerce(value, context: self) : value
151
+ coercion = property.coercion
152
+ if coercion.nil? then value
153
+ elsif Coerce::PLAIN.include?(coercion) then coercion.call(value)
154
+ else property.coerce(value, context: self)
155
+ end
136
156
  rescue
137
157
  value
138
158
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Prosemirror
4
- VERSION = "0.4.0"
4
+ VERSION = "0.6.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: prosemirror
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Hans Lemuet
@@ -84,6 +84,7 @@ files:
84
84
  - lib/prosemirror/mark.rb
85
85
  - lib/prosemirror/mark_type.rb
86
86
  - lib/prosemirror/markdown/builder.rb
87
+ - lib/prosemirror/markdown_serializer.rb
87
88
  - lib/prosemirror/marks/basic/code.rb
88
89
  - lib/prosemirror/marks/basic/em.rb
89
90
  - lib/prosemirror/marks/basic/link.rb
@@ -106,22 +107,31 @@ files:
106
107
  - lib/prosemirror/nodes/blockquote.rb
107
108
  - lib/prosemirror/nodes/bullet_list.rb
108
109
  - lib/prosemirror/nodes/code_block.rb
110
+ - lib/prosemirror/nodes/details.rb
111
+ - lib/prosemirror/nodes/details_content.rb
112
+ - lib/prosemirror/nodes/details_summary.rb
109
113
  - lib/prosemirror/nodes/doc.rb
110
114
  - lib/prosemirror/nodes/hard_break.rb
111
115
  - lib/prosemirror/nodes/heading.rb
112
116
  - lib/prosemirror/nodes/horizontal_rule.rb
113
117
  - lib/prosemirror/nodes/image.rb
114
118
  - lib/prosemirror/nodes/list_item.rb
119
+ - lib/prosemirror/nodes/mention.rb
115
120
  - lib/prosemirror/nodes/ordered_list.rb
116
121
  - lib/prosemirror/nodes/paragraph.rb
117
122
  - lib/prosemirror/nodes/table.rb
118
123
  - lib/prosemirror/nodes/table_cell.rb
119
124
  - lib/prosemirror/nodes/table_header.rb
120
125
  - lib/prosemirror/nodes/table_row.rb
126
+ - lib/prosemirror/nodes/task_item.rb
127
+ - lib/prosemirror/nodes/task_list.rb
121
128
  - lib/prosemirror/nodes/text.rb
129
+ - lib/prosemirror/nodes/twitch.rb
130
+ - lib/prosemirror/nodes/youtube.rb
122
131
  - lib/prosemirror/parse_rule.rb
123
132
  - lib/prosemirror/rendering.rb
124
133
  - lib/prosemirror/schema.rb
134
+ - lib/prosemirror/text_align.rb
125
135
  - lib/prosemirror/text_serializer.rb
126
136
  - lib/prosemirror/transformer.rb
127
137
  - lib/prosemirror/type_definition.rb