prosemirror 0.0.1 → 0.1.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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +10 -0
  3. data/COOKBOOK.md +108 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +169 -12
  6. data/ROADMAP.md +211 -0
  7. data/lib/prosemirror/coerce.rb +17 -0
  8. data/lib/prosemirror/content_expression.rb +94 -0
  9. data/lib/prosemirror/document.rb +76 -0
  10. data/lib/prosemirror/html_renderer.rb +20 -0
  11. data/lib/prosemirror/json_parser.rb +125 -0
  12. data/lib/prosemirror/mark.rb +19 -0
  13. data/lib/prosemirror/mark_type.rb +60 -0
  14. data/lib/prosemirror/marks/basic/em.rb +13 -0
  15. data/lib/prosemirror/marks/basic/link.rb +20 -0
  16. data/lib/prosemirror/marks/basic/strong.rb +13 -0
  17. data/lib/prosemirror/marks/bold.rb +11 -0
  18. data/lib/prosemirror/marks/code.rb +11 -0
  19. data/lib/prosemirror/marks/highlight.rb +16 -0
  20. data/lib/prosemirror/marks/italic.rb +11 -0
  21. data/lib/prosemirror/marks/link.rb +21 -0
  22. data/lib/prosemirror/marks/strike.rb +9 -0
  23. data/lib/prosemirror/marks/subscript.rb +11 -0
  24. data/lib/prosemirror/marks/superscript.rb +11 -0
  25. data/lib/prosemirror/marks/underline.rb +9 -0
  26. data/lib/prosemirror/naming.rb +16 -0
  27. data/lib/prosemirror/node.rb +65 -0
  28. data/lib/prosemirror/node_type.rb +101 -0
  29. data/lib/prosemirror/nodes/basic/code_block.rb +19 -0
  30. data/lib/prosemirror/nodes/basic/image.rb +22 -0
  31. data/lib/prosemirror/nodes/basic/ordered_list.rb +19 -0
  32. data/lib/prosemirror/nodes/blockquote.rb +12 -0
  33. data/lib/prosemirror/nodes/bullet_list.rb +12 -0
  34. data/lib/prosemirror/nodes/code_block.rb +19 -0
  35. data/lib/prosemirror/nodes/doc.rb +10 -0
  36. data/lib/prosemirror/nodes/hard_break.rb +13 -0
  37. data/lib/prosemirror/nodes/heading.rb +14 -0
  38. data/lib/prosemirror/nodes/horizontal_rule.rb +11 -0
  39. data/lib/prosemirror/nodes/image.rb +21 -0
  40. data/lib/prosemirror/nodes/list_item.rb +11 -0
  41. data/lib/prosemirror/nodes/ordered_list.rb +18 -0
  42. data/lib/prosemirror/nodes/paragraph.rb +12 -0
  43. data/lib/prosemirror/nodes/table.rb +14 -0
  44. data/lib/prosemirror/nodes/table_cell.rb +23 -0
  45. data/lib/prosemirror/nodes/table_header.rb +13 -0
  46. data/lib/prosemirror/nodes/table_row.rb +11 -0
  47. data/lib/prosemirror/nodes/text.rb +10 -0
  48. data/lib/prosemirror/rendering.rb +70 -0
  49. data/lib/prosemirror/schema.rb +190 -0
  50. data/lib/prosemirror/text_serializer.rb +46 -0
  51. data/lib/prosemirror/type_definition.rb +119 -0
  52. data/lib/prosemirror/version.rb +1 -1
  53. data/lib/prosemirror.rb +33 -2
  54. metadata +101 -16
  55. data/.rubocop.yml +0 -13
  56. data/Gemfile +0 -12
  57. data/Rakefile +0 -16
  58. data/prosemirror.gemspec +0 -35
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prosemirror
4
+ # A parsed content expression, such as `"paragraph block*"` or `"(table_cell | table_header){1,5}"`.
5
+ # See https://prosemirror.net/docs/guide/#schema.content_expressions
6
+ class ContentExpression
7
+ TOKEN = /\s*(\w+|[()|+*?]|\{\s*\d+\s*(?:,\s*\d*\s*)?\})/
8
+
9
+ attr_reader :source, :tree
10
+
11
+ def initialize(source)
12
+ @source = source
13
+ @tokens = tokenize(source)
14
+ @tree = parse_choice
15
+ raise ArgumentError, "unexpected #{@tokens.first.inspect} in content expression #{source.inspect}" if @tokens.any?
16
+ end
17
+
18
+ # The node names and group names the expression refers to.
19
+ def names
20
+ collect_names(@tree)
21
+ end
22
+
23
+ private
24
+
25
+ def tokenize(source)
26
+ tokens = []
27
+ rest = source.strip
28
+ until rest.empty?
29
+ match = TOKEN.match(rest)
30
+ raise ArgumentError, "invalid content expression #{source.inspect}" unless match&.begin(0)&.zero?
31
+
32
+ tokens << match[1]
33
+ rest = match.post_match.strip
34
+ end
35
+ tokens
36
+ end
37
+
38
+ def parse_choice
39
+ branches = [parse_sequence]
40
+ while @tokens.first == "|"
41
+ @tokens.shift
42
+ branches << parse_sequence
43
+ end
44
+ (branches.size == 1) ? branches.first : [:choice, branches]
45
+ end
46
+
47
+ def parse_sequence
48
+ terms = []
49
+ terms << parse_repeat while @tokens.any? && !["|", ")"].include?(@tokens.first)
50
+ raise ArgumentError, "empty content expression in #{@source.inspect}" if terms.empty?
51
+
52
+ (terms.size == 1) ? terms.first : [:sequence, terms]
53
+ end
54
+
55
+ def parse_repeat
56
+ atom = parse_atom
57
+ case @tokens.first
58
+ when "*" then @tokens.shift && [:repeat, atom, 0, nil]
59
+ when "+" then @tokens.shift && [:repeat, atom, 1, nil]
60
+ when "?" then @tokens.shift && [:repeat, atom, 0, 1]
61
+ when /\A\{/ then [:repeat, atom, *parse_range(@tokens.shift)]
62
+ else atom
63
+ end
64
+ end
65
+
66
+ def parse_range(token)
67
+ min, comma, max = token.delete("{} ").partition(",")
68
+ return [min.to_i, min.to_i] if comma.empty?
69
+
70
+ [min.to_i, max.empty? ? nil : max.to_i]
71
+ end
72
+
73
+ def parse_atom
74
+ token = @tokens.shift
75
+ case token
76
+ when "("
77
+ expression = parse_choice
78
+ raise ArgumentError, "missing ) in content expression #{@source.inspect}" unless @tokens.shift == ")"
79
+
80
+ expression
81
+ when /\A\w+\z/ then [:name, token.to_sym]
82
+ else raise ArgumentError, "unexpected #{token.inspect} in content expression #{@source.inspect}"
83
+ end
84
+ end
85
+
86
+ def collect_names(tree)
87
+ case tree.first
88
+ when :name then [tree[1]]
89
+ when :repeat then collect_names(tree[1])
90
+ else tree[1].flat_map { |child| collect_names(child) }.uniq
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prosemirror
4
+ # A document and its schema. Enumerating a document yields all its nodes, depth first.
5
+ #
6
+ # doc = Prosemirror.parse(json)
7
+ # doc.to_html
8
+ # doc.select { |node| node in { type: :heading, attrs: { level: 1 } } }
9
+ class Document
10
+ include Enumerable
11
+
12
+ attr_reader :root, :schema
13
+
14
+ def initialize(root, schema: Prosemirror.schema)
15
+ @root = root
16
+ @schema = schema
17
+ end
18
+
19
+ def content
20
+ root.content
21
+ end
22
+
23
+ def each(&)
24
+ root.descendants(&)
25
+ end
26
+
27
+ # The document as HTML. The string is `html_safe` when ActiveSupport is loaded.
28
+ def to_html
29
+ html = to_phlex.call
30
+ html.respond_to?(:html_safe) ? html.html_safe : html
31
+ end
32
+
33
+ # A Phlex component rendering the document, to render it inside another Phlex component:
34
+ #
35
+ # render doc.to_phlex
36
+ def to_phlex
37
+ HTMLRenderer.new(self)
38
+ end
39
+
40
+ # Lets Rails render the document in ERB templates and ViewComponents: `<%= render doc %>`.
41
+ def render_in(_view_context, &)
42
+ to_html
43
+ end
44
+
45
+ # The document as plain text, with blocks separated by `block_separator`.
46
+ def to_text(block_separator: "\n\n")
47
+ TextSerializer.new(schema, block_separator: block_separator).call(root)
48
+ end
49
+
50
+ # The document as JSON, as a Hash.
51
+ def to_h
52
+ schema.serialize(root)
53
+ end
54
+
55
+ def as_json(*)
56
+ to_h
57
+ end
58
+
59
+ def to_json(*)
60
+ to_h.to_json(*)
61
+ end
62
+
63
+ def text_content
64
+ root.text_content
65
+ end
66
+
67
+ def ==(other)
68
+ other.is_a?(Document) && root == other.root
69
+ end
70
+ alias_method :eql?, :==
71
+
72
+ def hash
73
+ root.hash
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prosemirror
4
+ # Renders a document to HTML. Use `doc.to_phlex` to render it inside another Phlex component:
5
+ #
6
+ # render doc.to_phlex
7
+ class HTMLRenderer < Phlex::HTML
8
+ include Rendering
9
+
10
+ def initialize(document)
11
+ super()
12
+ @document = document
13
+ @_prosemirror_schema = document.schema
14
+ end
15
+
16
+ def view_template
17
+ render_content([@document.root])
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prosemirror
4
+ # Builds nodes from ProseMirror JSON, and normalizes them as ProseMirror does:
5
+ # marks sorted in schema order, adjacent text with the same marks joined, empty text removed.
6
+ # Invalid parts are fixed and reported to the schema (which raises in strict mode).
7
+ class JSONParser
8
+ MAX_DEPTH = 100
9
+
10
+ def initialize(schema)
11
+ @schema = schema
12
+ end
13
+
14
+ def parse(json)
15
+ hash = json.is_a?(String) ? JSON.parse(json, max_nesting: (MAX_DEPTH * 2) + 2) : json
16
+ raise ArgumentError, "expected a Hash or a JSON String, got #{json.class}" unless hash.is_a?(Hash)
17
+
18
+ root = build_node(hash, "doc", 0) || Node.new(type: @schema.top_node)
19
+ return root if root.type == @schema.top_node
20
+
21
+ @schema.report("the top node is #{root.type}, not #{@schema.top_node}", path: "doc")
22
+ Node.new(type: @schema.top_node, content: [root])
23
+ rescue JSON::NestingError
24
+ raise InvalidDocument, "the document is nested more than #{MAX_DEPTH} levels deep"
25
+ end
26
+
27
+ private
28
+
29
+ def build_node(hash, path, depth)
30
+ return invalid("not a node: #{hash.inspect}", path) unless hash.is_a?(Hash)
31
+ return invalid("nested more than #{MAX_DEPTH} levels deep", path) if depth > MAX_DEPTH
32
+
33
+ name = fetch(hash, "type")
34
+ return invalid("missing node type", path) unless name.is_a?(String) || name.is_a?(Symbol)
35
+ return build_text(hash, path) if name.to_s == "text"
36
+
37
+ node_type = @schema.node_type(name)
38
+ return build_unknown(hash, name, path, depth) unless node_type
39
+
40
+ given = fetch(hash, "attrs")
41
+ attrs = node_type.build_attrs(given, report: reporter(path))
42
+ return invalid("removed #{name} node", path) unless attrs
43
+
44
+ Node.new(type: node_type.type_name, attrs: attrs, unknown_attrs: node_type.unknown_attrs(given),
45
+ content: build_content(hash, path, depth), marks: build_marks(fetch(hash, "marks"), path))
46
+ end
47
+
48
+ def build_text(hash, path)
49
+ text = fetch(hash, "text")
50
+ return invalid("text node without text", path) unless text.is_a?(String)
51
+ return nil if text.empty?
52
+
53
+ Node.new(type: :text, text: text, marks: build_marks(fetch(hash, "marks"), path))
54
+ end
55
+
56
+ def build_unknown(hash, name, path, depth)
57
+ invalid("unknown node type #{name}", path)
58
+ attrs = fetch(hash, "attrs")
59
+ Node.new(type: Naming.snake(name), attrs: attrs.is_a?(Hash) ? attrs.transform_keys(&:to_sym) : {},
60
+ content: build_content(hash, path, depth), marks: build_marks(fetch(hash, "marks"), path))
61
+ end
62
+
63
+ def build_content(hash, path, depth)
64
+ content = fetch(hash, "content")
65
+ return [] if content.nil?
66
+ return invalid("content is not a list", path) || [] unless content.is_a?(Array)
67
+
68
+ nodes = content.each_with_index.filter_map do |child, index|
69
+ build_node(child, "#{path}.content[#{index}]", depth + 1)
70
+ end
71
+ join_text(nodes)
72
+ end
73
+
74
+ def join_text(nodes)
75
+ nodes.each_with_object([]) do |node, joined|
76
+ previous = joined.last
77
+ if previous&.text? && node.text? && previous.marks == node.marks
78
+ joined[-1] = previous.with(text: previous.text + node.text)
79
+ else
80
+ joined << node
81
+ end
82
+ end
83
+ end
84
+
85
+ def build_marks(marks, path)
86
+ return [] if marks.nil?
87
+ return invalid("marks is not a list", path) || [] unless marks.is_a?(Array)
88
+
89
+ built = marks.each_with_index.filter_map { |mark, index| build_mark(mark, "#{path}.marks[#{index}]") }
90
+ built.uniq.sort_by.with_index { |mark, index| [@schema.mark_rank(mark.type), index] }
91
+ end
92
+
93
+ def build_mark(hash, path)
94
+ return invalid("not a mark: #{hash.inspect}", path) unless hash.is_a?(Hash)
95
+
96
+ name = fetch(hash, "type")
97
+ return invalid("missing mark type", path) unless name.is_a?(String) || name.is_a?(Symbol)
98
+
99
+ mark_type = @schema.mark_type(name)
100
+ unless mark_type
101
+ invalid("unknown mark type #{name}", path)
102
+ attrs = fetch(hash, "attrs")
103
+ return Mark.new(type: Naming.snake(name), attrs: attrs.is_a?(Hash) ? attrs.transform_keys(&:to_sym) : {})
104
+ end
105
+
106
+ given = fetch(hash, "attrs")
107
+ attrs = mark_type.build_attrs(given, report: reporter(path))
108
+ return invalid("removed #{name} mark", path) unless attrs
109
+
110
+ Mark.new(type: mark_type.type_name, attrs: attrs, unknown_attrs: mark_type.unknown_attrs(given))
111
+ end
112
+
113
+ def fetch(hash, key)
114
+ hash.key?(key) ? hash[key] : hash[key.to_sym]
115
+ end
116
+
117
+ def reporter(path)
118
+ ->(message) { @schema.report(message, path: path) }
119
+ end
120
+
121
+ def invalid(message, path)
122
+ @schema.report(message, path: path)
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prosemirror
4
+ # A mark applied to a node, such as `bold` or `link`. Marks are immutable values.
5
+ #
6
+ # mark in { type: :link, attrs: { href: } }
7
+ #
8
+ # As for nodes, `unknown_attrs` holds the attributes the mark's type doesn't declare.
9
+ class Mark < Literal::Data
10
+ prop :type, Symbol, &Coerce::SYMBOL
11
+ prop :attrs, _Hash(Symbol, _Any?), default: {}.freeze, &Coerce::FROZEN
12
+ prop :unknown_attrs, _Hash(String, _Any?), default: {}.freeze, &Coerce::FROZEN
13
+
14
+ # A copy with some properties changed.
15
+ def with(**changes)
16
+ self.class.new(**to_h, **changes)
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prosemirror
4
+ # The base class of mark types. A mark type is a Phlex component: `view_template` renders one element
5
+ # and yields to render the marked content. Attributes are Literal properties.
6
+ #
7
+ # class Bold < Prosemirror::MarkType
8
+ # def view_template(&) = strong(&)
9
+ # end
10
+ #
11
+ # The options follow ProseMirror's MarkSpec: https://prosemirror.net/docs/ref/#model.MarkSpec
12
+ class MarkType < Phlex::HTML
13
+ extend Literal::Properties
14
+ extend TypeDefinition
15
+ include Rendering
16
+
17
+ class << self
18
+ def base_type?
19
+ self == MarkType
20
+ end
21
+
22
+ # The marks this mark can't coexist with: mark or group names, `"_"` for all.
23
+ # Defaults to marks of the same type.
24
+ def excludes(spec = nil)
25
+ return @excludes = spec if spec
26
+
27
+ inherited_setting(:@excludes, nil)
28
+ end
29
+
30
+ def group(*names)
31
+ return @group = names.flat_map { |n| n.to_s.split }.map(&:to_sym) unless names.empty?
32
+
33
+ inherited_setting(:@group, [])
34
+ end
35
+
36
+ # Whether one element can wrap several adjacent nodes with this mark. Defaults to true.
37
+ def spanning(value = nil)
38
+ return @spanning = value unless value.nil?
39
+
40
+ inherited_setting(:@spanning, true)
41
+ end
42
+
43
+ def spanning?
44
+ spanning
45
+ end
46
+
47
+ def code(value = true)
48
+ @code = value
49
+ end
50
+
51
+ def code?
52
+ inherited_setting(:@code, false)
53
+ end
54
+ end
55
+
56
+ def view_template
57
+ yield if block_given?
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prosemirror
4
+ module Marks
5
+ module Basic
6
+ class Em < MarkType
7
+ aliases :italic
8
+
9
+ def view_template(&) = em(&)
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prosemirror
4
+ module Marks
5
+ module Basic
6
+ # ProseMirror's link. Without an allowed `href`, its text is rendered without a link.
7
+ class Link < MarkType
8
+ prop :href, _String(length: 1..)
9
+ prop :title, _String?
10
+
11
+ def view_template(&)
12
+ href = safe_url(@href)
13
+ return yield unless href
14
+
15
+ a(href: href, title: @title, &)
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prosemirror
4
+ module Marks
5
+ module Basic
6
+ class Strong < MarkType
7
+ aliases :bold
8
+
9
+ def view_template(&) = strong(&)
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prosemirror
4
+ module Marks
5
+ class Bold < MarkType
6
+ aliases :strong
7
+
8
+ def view_template(&) = strong(&)
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prosemirror
4
+ module Marks
5
+ class Code < MarkType
6
+ code
7
+
8
+ def view_template(&) = code(&)
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prosemirror
4
+ module Marks
5
+ # Tiptap's highlight. `color` must be a hex, named, rgb() or hsl() color, so it is safe in a style.
6
+ class Highlight < MarkType
7
+ COLOR = %r{\A(\#\h{3,8}|[a-z]+|(rgb|hsl)a?\([\d\s.,%/]+\))\z}i
8
+
9
+ prop :color, _String?(COLOR)
10
+
11
+ def view_template(&)
12
+ mark(data_color: @color, style: @color && "background-color: #{@color}; color: inherit", &)
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prosemirror
4
+ module Marks
5
+ class Italic < MarkType
6
+ aliases :em
7
+
8
+ def view_template(&) = em(&)
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prosemirror
4
+ module Marks
5
+ # Tiptap's link. Without an allowed `href`, its text is rendered without a link.
6
+ class Link < MarkType
7
+ prop :href, _String(length: 1..)
8
+ prop :target, _String?
9
+ prop :rel, _String?
10
+ prop :css_class, _String?, json: "class"
11
+ prop :title, _String?
12
+
13
+ def view_template(&)
14
+ href = safe_url(@href)
15
+ return yield unless href
16
+
17
+ a(href: href, target: @target, rel: @rel, class: @css_class, title: @title, &)
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prosemirror
4
+ module Marks
5
+ class Strike < MarkType
6
+ def view_template(&) = s(&)
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prosemirror
4
+ module Marks
5
+ class Subscript < MarkType
6
+ excludes "superscript"
7
+
8
+ def view_template(&) = sub(&)
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prosemirror
4
+ module Marks
5
+ class Superscript < MarkType
6
+ excludes "subscript"
7
+
8
+ def view_template(&) = sup(&)
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prosemirror
4
+ module Marks
5
+ class Underline < MarkType
6
+ def view_template(&) = u(&)
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prosemirror
4
+ # Converts names between snake_case (`bullet_list`) and camelCase (`bulletList`).
5
+ module Naming
6
+ module_function
7
+
8
+ def snake(name)
9
+ name.to_s.gsub(/([a-z\d])([A-Z])/, '\1_\2').tr("-", "_").downcase
10
+ end
11
+
12
+ def camel(name)
13
+ snake(name).gsub(/_([a-z\d])/) { Regexp.last_match(1).upcase }
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prosemirror
4
+ # A node of a document. Nodes are immutable values, and support pattern matching:
5
+ #
6
+ # node in { type: :heading, attrs: { level: 1 } }
7
+ #
8
+ # Text nodes have the type `:text` and their content in `text`.
9
+ #
10
+ # `attrs` holds the attributes the node's type declares. `unknown_attrs` holds the others, as written
11
+ # in JSON (for example from a Tiptap extension the schema doesn't know): they are never rendered,
12
+ # and `to_h` gives them back unchanged.
13
+ class Node < Literal::Data
14
+ prop :type, Symbol, &Coerce::SYMBOL
15
+ prop :attrs, _Hash(Symbol, _Any?), default: {}.freeze, &Coerce::FROZEN
16
+ prop :unknown_attrs, _Hash(String, _Any?), default: {}.freeze, &Coerce::FROZEN
17
+ prop :content, _Array(Node), default: [].freeze, &Coerce::FROZEN
18
+ prop :marks, _Array(Mark), default: [].freeze, &Coerce::FROZEN
19
+ prop :text, _String?, &Coerce::FROZEN
20
+
21
+ # A copy with some properties changed.
22
+ def with(**changes)
23
+ self.class.new(**to_h, **changes)
24
+ end
25
+
26
+ def text?
27
+ @type == :text
28
+ end
29
+
30
+ def first_child
31
+ @content.first
32
+ end
33
+
34
+ def last_child
35
+ @content.last
36
+ end
37
+
38
+ def child_count
39
+ @content.size
40
+ end
41
+
42
+ # All text in this node and its descendants, joined without separators.
43
+ def text_content
44
+ return @text if text?
45
+
46
+ descendants.select(&:text?).map(&:text).join
47
+ end
48
+
49
+ # Iterate over all descendants, depth first, without the node itself.
50
+ def descendants
51
+ return enum_for(:descendants) unless block_given?
52
+
53
+ stack = @content.reverse
54
+ until stack.empty?
55
+ node = stack.pop
56
+ yield node
57
+ stack.concat(node.content.reverse)
58
+ end
59
+ end
60
+
61
+ def same_markup?(other)
62
+ @type == other.type && @attrs == other.attrs && @unknown_attrs == other.unknown_attrs && @marks == other.marks
63
+ end
64
+ end
65
+ end