prosemirror 0.0.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +20 -0
- data/COOKBOOK.md +108 -0
- data/LICENSE.txt +21 -0
- data/README.md +155 -12
- data/ROADMAP.md +215 -0
- data/TYPES.md +201 -0
- data/lib/prosemirror/coerce.rb +17 -0
- data/lib/prosemirror/content_expression.rb +94 -0
- data/lib/prosemirror/content_match.rb +241 -0
- data/lib/prosemirror/document.rb +76 -0
- data/lib/prosemirror/html/builder.rb +146 -0
- data/lib/prosemirror/html/element.rb +84 -0
- data/lib/prosemirror/html/entities.rb +87 -0
- data/lib/prosemirror/html/entities.txt +2231 -0
- data/lib/prosemirror/html/selector.rb +95 -0
- data/lib/prosemirror/html/splitter.rb +68 -0
- data/lib/prosemirror/html/text.rb +23 -0
- data/lib/prosemirror/html_parser.rb +498 -0
- data/lib/prosemirror/html_renderer.rb +20 -0
- data/lib/prosemirror/json_parser.rb +114 -0
- data/lib/prosemirror/mark.rb +19 -0
- data/lib/prosemirror/mark_type.rb +71 -0
- data/lib/prosemirror/marks/basic/code.rb +16 -0
- data/lib/prosemirror/marks/basic/em.rb +17 -0
- data/lib/prosemirror/marks/basic/link.rb +21 -0
- data/lib/prosemirror/marks/basic/strong.rb +17 -0
- data/lib/prosemirror/marks/bold.rb +19 -0
- data/lib/prosemirror/marks/code.rb +16 -0
- data/lib/prosemirror/marks/highlight.rb +18 -0
- data/lib/prosemirror/marks/italic.rb +15 -0
- data/lib/prosemirror/marks/link.rb +22 -0
- data/lib/prosemirror/marks/strike.rb +14 -0
- data/lib/prosemirror/marks/subscript.rb +13 -0
- data/lib/prosemirror/marks/superscript.rb +13 -0
- data/lib/prosemirror/marks/underline.rb +12 -0
- data/lib/prosemirror/naming.rb +16 -0
- data/lib/prosemirror/node.rb +77 -0
- data/lib/prosemirror/node_type.rb +123 -0
- data/lib/prosemirror/nodes/basic/code_block.rb +20 -0
- data/lib/prosemirror/nodes/basic/image.rb +23 -0
- data/lib/prosemirror/nodes/basic/ordered_list.rb +22 -0
- data/lib/prosemirror/nodes/blockquote.rb +13 -0
- data/lib/prosemirror/nodes/bullet_list.rb +13 -0
- data/lib/prosemirror/nodes/code_block.rb +24 -0
- data/lib/prosemirror/nodes/doc.rb +10 -0
- data/lib/prosemirror/nodes/hard_break.rb +15 -0
- data/lib/prosemirror/nodes/heading.rb +15 -0
- data/lib/prosemirror/nodes/horizontal_rule.rb +12 -0
- data/lib/prosemirror/nodes/image.rb +22 -0
- data/lib/prosemirror/nodes/list_item.rb +12 -0
- data/lib/prosemirror/nodes/ordered_list.rb +36 -0
- data/lib/prosemirror/nodes/paragraph.rb +13 -0
- data/lib/prosemirror/nodes/table.rb +15 -0
- data/lib/prosemirror/nodes/table_cell.rb +33 -0
- data/lib/prosemirror/nodes/table_header.rb +15 -0
- data/lib/prosemirror/nodes/table_row.rb +12 -0
- data/lib/prosemirror/nodes/text.rb +10 -0
- data/lib/prosemirror/parse_rule.rb +50 -0
- data/lib/prosemirror/rendering.rb +70 -0
- data/lib/prosemirror/schema.rb +241 -0
- data/lib/prosemirror/text_serializer.rb +46 -0
- data/lib/prosemirror/type_definition.rb +149 -0
- data/lib/prosemirror/version.rb +1 -1
- data/lib/prosemirror.rb +43 -2
- metadata +113 -16
- data/.rubocop.yml +0 -13
- data/Gemfile +0 -12
- data/Rakefile +0 -16
- data/prosemirror.gemspec +0 -35
data/TYPES.md
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
# Node and mark types
|
|
2
|
+
|
|
3
|
+
Each node and mark of a document has a type, a Ruby class. A type declares its name, its attributes, what it can contain, how it renders to HTML, and how to recognize it when importing HTML. The options follow ProseMirror's [NodeSpec](https://prosemirror.net/docs/ref/#model.NodeSpec) and [MarkSpec](https://prosemirror.net/docs/ref/#model.MarkSpec).
|
|
4
|
+
|
|
5
|
+
```ruby
|
|
6
|
+
class Callout < Prosemirror::NodeType
|
|
7
|
+
group :block
|
|
8
|
+
content "paragraph+"
|
|
9
|
+
|
|
10
|
+
prop :tone, _String?
|
|
11
|
+
|
|
12
|
+
parse_html(tag: "aside.callout") { |element| {tone: element["data-tone"]} }
|
|
13
|
+
|
|
14
|
+
def view_template(&) = aside(class: "callout", data_tone: @tone, &)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
Prosemirror.schema = Prosemirror::Schema.tiptap.with(Callout)
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
A type is a [Phlex](https://www.phlex.fun) component, and its attributes are [Literal](https://literal.fun) properties. `Schema#with` adds a type to a schema, or replaces the type with the same name.
|
|
21
|
+
|
|
22
|
+
- [Names](#names)
|
|
23
|
+
- [Node type options](#node-type-options)
|
|
24
|
+
- [Mark type options](#mark-type-options)
|
|
25
|
+
- [Content expressions](#content-expressions)
|
|
26
|
+
- [Attributes](#attributes)
|
|
27
|
+
- [Rendering](#rendering)
|
|
28
|
+
- [Parse rules](#parse-rules)
|
|
29
|
+
- [Subclassing built-in types](#subclassing-built-in-types)
|
|
30
|
+
|
|
31
|
+
## Names
|
|
32
|
+
|
|
33
|
+
| Option | Meaning |
|
|
34
|
+
|---|---|
|
|
35
|
+
| `type_name :image_block` | The type name. Defaults to the class name in snake_case (`ImageBlock` → `image_block`). |
|
|
36
|
+
| `aliases :figure, :picture` | Other names accepted on input. `Bold` accepts `strong`. |
|
|
37
|
+
|
|
38
|
+
Type names are snake_case Symbols in Ruby. On input, camelCase works too (`imageBlock`). The schema's `case:` option chooses how they are written in JSON.
|
|
39
|
+
|
|
40
|
+
## Node type options
|
|
41
|
+
|
|
42
|
+
| Option | Default | Meaning |
|
|
43
|
+
|---|---|---|
|
|
44
|
+
| `group :block, :list` | none | The groups of the type, used in content expressions and in `marks`. |
|
|
45
|
+
| `content "inline*"` | none: the node is a leaf | The children allowed, as a [content expression](#content-expressions). |
|
|
46
|
+
| `inline` | false | The node is inline, like text. Also implied by `group :inline`. |
|
|
47
|
+
| `marks "bold italic"` | all, in nodes with inline content | The marks allowed inside: names or groups separated by spaces, `"_"` for all, `""` for none. |
|
|
48
|
+
| `code` | false | The node holds code. Implies `whitespace :pre`. |
|
|
49
|
+
| `whitespace :pre` | `:normal` | Keep spaces and newlines when importing HTML. |
|
|
50
|
+
| `leaf_text "\n"` | `""` | The text of a leaf node in `to_text`. Also takes a block, called with the node. |
|
|
51
|
+
| `linebreak_replacement` | false | When importing HTML that keeps whitespace, newlines become nodes of this inline type, like `hard_break`. |
|
|
52
|
+
|
|
53
|
+
## Mark type options
|
|
54
|
+
|
|
55
|
+
| Option | Default | Meaning |
|
|
56
|
+
|---|---|---|
|
|
57
|
+
| `group :font` | none | The groups of the type, used in `excludes` and in a node's `marks`. |
|
|
58
|
+
| `excludes "_"` | marks of the same type | The marks this one can't be combined with: names or groups, `"_"` for all. A link excludes other links, and Tiptap's `code` excludes all marks. |
|
|
59
|
+
| `spanning false` | true | Render one element per text node, instead of one for adjacent nodes with the same mark. |
|
|
60
|
+
| `code` | false | The mark holds code, as ProseMirror's `code`. Not used by this gem yet. |
|
|
61
|
+
|
|
62
|
+
The order of marks in the schema sorts the marks of a node, and nests their elements: the first mark is the outermost element.
|
|
63
|
+
|
|
64
|
+
## Content expressions
|
|
65
|
+
|
|
66
|
+
`content` says which children a node can have. It works like a regular expression, with node names or group names instead of characters:
|
|
67
|
+
|
|
68
|
+
| Expression | Meaning |
|
|
69
|
+
|---|---|
|
|
70
|
+
| `"text*"` | Zero or more text nodes. |
|
|
71
|
+
| `"inline*"` | Zero or more nodes of the `inline` group: text, hard breaks… |
|
|
72
|
+
| `"block+"` | One or more nodes of the `block` group. |
|
|
73
|
+
| `"paragraph block*"` | A paragraph, then zero or more blocks. |
|
|
74
|
+
| `"(table_cell \| table_header)*"` | Zero or more cells or header cells. |
|
|
75
|
+
| `"caption?"` | Zero or one caption. |
|
|
76
|
+
| `"cell{2}"`, `"cell{1,5}"`, `"cell{2,}"` | Exactly two, one to five, two or more cells. |
|
|
77
|
+
|
|
78
|
+
The built-in types use these groups: `block` for paragraphs, headings, lists, tables…, `inline` for text and hard breaks, and `list` for lists.
|
|
79
|
+
|
|
80
|
+
HTML import follows content expressions, to wrap content that doesn't fit where it is, or move it up. `to_text` uses them to find the nodes that contain text. Rendering doesn't check them.
|
|
81
|
+
|
|
82
|
+
## Attributes
|
|
83
|
+
|
|
84
|
+
Attributes are declared with Literal's `prop`, as keyword properties, and are available as instance variables in `view_template`:
|
|
85
|
+
|
|
86
|
+
```ruby
|
|
87
|
+
prop :level, _Integer(1..6), default: 1, &Prosemirror::Coerce::INTEGER
|
|
88
|
+
prop :src, _String(length: 1..)
|
|
89
|
+
prop :alt, _String?
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
- **Type:** a Literal type. A value that doesn't match it is invalid (see [Invalid documents](README.md#invalid-documents)).
|
|
93
|
+
- **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.
|
|
94
|
+
- **Coercion:** the block converts values before they are checked. `Prosemirror::Coerce::INTEGER` turns `"2"` into `2`.
|
|
95
|
+
|
|
96
|
+
`prop` takes three more options:
|
|
97
|
+
|
|
98
|
+
| Option | Meaning |
|
|
99
|
+
|---|---|
|
|
100
|
+
| `json: "textAlign"` | The name in JSON, when it differs from the property name. |
|
|
101
|
+
| `aliases: ["order"]` | Other JSON names accepted on input. |
|
|
102
|
+
| `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. |
|
|
103
|
+
|
|
104
|
+
Attributes that a type doesn't declare are kept in `unknown_attrs`. They are never rendered, and `to_h` gives them back.
|
|
105
|
+
|
|
106
|
+
## Rendering
|
|
107
|
+
|
|
108
|
+
A type renders with `view_template`, which yields to render its content. For a mark, the content is the marked text.
|
|
109
|
+
|
|
110
|
+
```ruby
|
|
111
|
+
def view_template(&) = blockquote(class: "quote", &)
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Without `view_template`, the content is rendered without a wrapping element. These methods are also available:
|
|
115
|
+
|
|
116
|
+
| Method | Returns |
|
|
117
|
+
|---|---|
|
|
118
|
+
| `node` | The node being rendered, a `Prosemirror::Node`. Not available in mark types. |
|
|
119
|
+
| `render_content(nodes)` | Renders any list of nodes, with their marks, for example only some of the node's children. |
|
|
120
|
+
| `safe_url(url)` | The URL after the schema's `resolve_url`, when its scheme is allowed, or nil. Use it for every URL from the document. |
|
|
121
|
+
| `prosemirror_schema` | The schema of the document being rendered. |
|
|
122
|
+
|
|
123
|
+
Phlex escapes text and attribute values, but doesn't check URLs: pass them through `safe_url`.
|
|
124
|
+
|
|
125
|
+
## Parse rules
|
|
126
|
+
|
|
127
|
+
`parse_html` says which elements become this type when importing HTML, like ProseMirror's [parse rules](https://prosemirror.net/docs/ref/#model.ParseRule). A type can have several rules. A type without rules is never created by HTML import.
|
|
128
|
+
|
|
129
|
+
```ruby
|
|
130
|
+
parse_html tag: "h1", attrs: {level: 1}
|
|
131
|
+
parse_html(tag: "b") { |element| element.style["font-weight"] != "normal" && {} }
|
|
132
|
+
parse_html(style: "font-weight") { |value| value.match?(/bold|[5-9]00/) && {} }
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
The block is called with the element, or with the value of the style for a style rule. It returns the attributes, `nil` for the defaults, or `false` when the rule doesn't match. A block with two parameters also gets the schema: `{ |element, schema| … }`.
|
|
136
|
+
|
|
137
|
+
The attributes are built from the rule's `attrs` or block, then from the values each `prop` reads from the element. Values read from the element take precedence, as in Tiptap.
|
|
138
|
+
|
|
139
|
+
| Option | Meaning |
|
|
140
|
+
|---|---|
|
|
141
|
+
| `tag: "img[src]"` | A CSS selector the element must match. |
|
|
142
|
+
| `style: "font-style=italic"` | For mark types: a CSS property, or a property and value. |
|
|
143
|
+
| `attrs: {level: 1}` | Attributes to give the node or mark. |
|
|
144
|
+
| `priority: 60` | Rules with a higher priority are tried first. Defaults to 50. Rules with the same priority are tried in schema order, mark types first. |
|
|
145
|
+
| `consuming: false` | Let the following rules match the element too, for example to apply several marks. |
|
|
146
|
+
| `context: "list_item/"` | The parents the rule needs. See below. |
|
|
147
|
+
| `ignore: true` | Skip the element and its content. |
|
|
148
|
+
| `skip: true` | Skip the element, but parse its content. |
|
|
149
|
+
| `close_parent: true` | Close the current node: the content that follows goes after it. |
|
|
150
|
+
| `content_element: "figcaption"` | A selector, or a block called with the element, giving the descendant that holds the content. |
|
|
151
|
+
| `get_content: ->(element, schema) { … }` | Returns the nodes to use as content, instead of parsing the element's children. |
|
|
152
|
+
| `preserve_whitespace: true` | Keep spaces. Newlines become `hard_break` nodes where they fit, and spaces otherwise. With `:full`, newlines stay in the text. |
|
|
153
|
+
| `clear_mark: true` | For style rules: remove the marks of this type, as `font-weight: normal` does for bold. Also takes a block called with each mark. |
|
|
154
|
+
|
|
155
|
+
### Selectors
|
|
156
|
+
|
|
157
|
+
`tag` takes a list of compound selectors, separated by commas: a type (`p`, `*`), `#id`, `.class`, attribute selectors (`[href]`, `[a=b]`, `[a^=b]`, `[a$=b]`, `[a*=b]`, `[a~=b]`, `[a|=b]`), and `:not(…)`. Combinators like `div > p` aren't supported: use `context`, or check the element in the block.
|
|
158
|
+
|
|
159
|
+
### Context
|
|
160
|
+
|
|
161
|
+
`context` matches the nodes the content is being added to, from the innermost one:
|
|
162
|
+
|
|
163
|
+
| Context | Matches |
|
|
164
|
+
|---|---|
|
|
165
|
+
| `"list_item/"` | Directly in a list item. |
|
|
166
|
+
| `"blockquote//"` | Anywhere inside a blockquote. |
|
|
167
|
+
| `"list_item/paragraph/"` | In a paragraph directly in a list item. |
|
|
168
|
+
| `"blockquote/ \| list_item/"` | One of several contexts. |
|
|
169
|
+
|
|
170
|
+
Group names work too.
|
|
171
|
+
|
|
172
|
+
### Elements
|
|
173
|
+
|
|
174
|
+
The element passed to rules is a `Prosemirror::HTML::Element`, with lowercase names, and attribute values with their character references decoded:
|
|
175
|
+
|
|
176
|
+
| Method | Returns |
|
|
177
|
+
|---|---|
|
|
178
|
+
| `name` | The tag name, like `"img"`. |
|
|
179
|
+
| `element["src"]` | An attribute value, or nil. |
|
|
180
|
+
| `key?("data-id")` | Whether the attribute is present. |
|
|
181
|
+
| `classes` | The classes, as an Array. |
|
|
182
|
+
| `style` | The declarations of the `style` attribute, as a Hash with lowercase properties: `{"font-weight" => "bold"}`. |
|
|
183
|
+
| `text_content` | The text of the element and its descendants. |
|
|
184
|
+
| `children`, `element_children`, `first_element_child` | The children: elements and texts, elements only, the first element. |
|
|
185
|
+
| `query("img")` | The first descendant matching a selector, or nil. |
|
|
186
|
+
| `matches?("p.lead")` | Whether the element matches a selector. |
|
|
187
|
+
| `parent` | The parent element. |
|
|
188
|
+
|
|
189
|
+
## Subclassing built-in types
|
|
190
|
+
|
|
191
|
+
A subclass keeps the name, options, attributes and parse rules of its parent, and can change any of them. Register it with `Schema#with`, which replaces the built-in type with the same name:
|
|
192
|
+
|
|
193
|
+
```ruby
|
|
194
|
+
class Paragraph < Prosemirror::Nodes::Paragraph
|
|
195
|
+
def view_template(&) = p(class: "text-lg", &)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
Prosemirror.schema = Prosemirror::Schema.tiptap.with(Paragraph)
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
A subclass that declares parse rules replaces the rules of its parent. See the [cookbook](COOKBOOK.md) for more examples.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Prosemirror
|
|
4
|
+
# Conversions for property values, used as Literal coercion blocks:
|
|
5
|
+
#
|
|
6
|
+
# prop :level, _Integer(1..6), default: 1, &Coerce::INTEGER
|
|
7
|
+
module Coerce
|
|
8
|
+
# "2" becomes 2.
|
|
9
|
+
INTEGER = proc { |value| (value.is_a?(String) && value.match?(/\A\s*-?\d+\s*\z/)) ? value.to_i : value }
|
|
10
|
+
|
|
11
|
+
# "bullet_list" becomes :bullet_list.
|
|
12
|
+
SYMBOL = proc { |value| value.is_a?(String) ? value.to_sym : value }
|
|
13
|
+
|
|
14
|
+
# A frozen copy, unless the value is already frozen.
|
|
15
|
+
FROZEN = proc { |value| value.frozen? ? value : value.dup.freeze }
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -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,241 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Prosemirror
|
|
4
|
+
# A state of a node type's content expression, compiled into a deterministic automaton, as in
|
|
5
|
+
# ProseMirror's ContentMatch: https://prosemirror.net/docs/ref/#model.ContentMatch
|
|
6
|
+
#
|
|
7
|
+
# Edges go from one state to the next for a node type. Their order matters: the first generatable
|
|
8
|
+
# type is used to fill required content.
|
|
9
|
+
class ContentMatch
|
|
10
|
+
attr_reader :edges
|
|
11
|
+
|
|
12
|
+
def initialize(valid_end)
|
|
13
|
+
@valid_end = valid_end
|
|
14
|
+
@edges = []
|
|
15
|
+
@wrappings = {}
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Compiles a content expression for a schema. Returns EMPTY for leaf types.
|
|
19
|
+
def self.compile(expression, schema)
|
|
20
|
+
return EMPTY if expression.nil?
|
|
21
|
+
|
|
22
|
+
Compiler.new(expression, schema).call
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Whether the content can end here.
|
|
26
|
+
def valid_end?
|
|
27
|
+
@valid_end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# The state after a node of this type, or nil when it can't come here.
|
|
31
|
+
def match_type(type)
|
|
32
|
+
@edges.find { |edge_type, _| edge_type == type }&.last
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# The state after these node types, or nil when they don't match.
|
|
36
|
+
def match_types(types)
|
|
37
|
+
types.reduce(self) { |match, type| match&.match_type(type) }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def inline_content?
|
|
41
|
+
!@edges.empty? && @edges.first.first.inline?
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# The first type that can come here and can be created empty, or nil.
|
|
45
|
+
def default_type(schema)
|
|
46
|
+
@edges.map(&:first).find { |type| generatable?(type, schema) }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# The node types to insert before `types` so that they match, possibly none, or nil when no
|
|
50
|
+
# insertion works. With `to_end`, the content must also be able to end after them.
|
|
51
|
+
def fill_before(types, schema, to_end: false)
|
|
52
|
+
seen = [self]
|
|
53
|
+
search = lambda do |match, filled|
|
|
54
|
+
finished = match.match_types(types)
|
|
55
|
+
return filled if finished && (!to_end || finished.valid_end?)
|
|
56
|
+
|
|
57
|
+
match.edges.each do |type, following|
|
|
58
|
+
next if !generatable?(type, schema) || seen.include?(following)
|
|
59
|
+
|
|
60
|
+
seen << following
|
|
61
|
+
found = search.call(following, filled + [type])
|
|
62
|
+
return found if found
|
|
63
|
+
end
|
|
64
|
+
nil
|
|
65
|
+
end
|
|
66
|
+
search.call(self, [])
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# The wrapper types that let a node of this type come here, possibly none, or nil when none do.
|
|
70
|
+
def find_wrapping(target, schema)
|
|
71
|
+
return @wrappings[target] if @wrappings.key?(target)
|
|
72
|
+
|
|
73
|
+
@wrappings[target] = compute_wrapping(target, schema)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def generatable?(type, schema)
|
|
77
|
+
type.type_name != :text && !type.required_attrs?
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def compute_wrapping(target, schema)
|
|
83
|
+
seen = {}
|
|
84
|
+
active = [[self, nil, nil]]
|
|
85
|
+
until active.empty?
|
|
86
|
+
current = active.shift
|
|
87
|
+
match, type, = current
|
|
88
|
+
return wrapping_path(current) if match.match_type(target)
|
|
89
|
+
|
|
90
|
+
match.edges.each do |next_type, following|
|
|
91
|
+
next if next_type.leaf? || next_type.required_attrs? || seen[next_type]
|
|
92
|
+
next if type && !following.valid_end?
|
|
93
|
+
|
|
94
|
+
active << [schema.content_match(next_type), next_type, current]
|
|
95
|
+
seen[next_type] = true
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
nil
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def wrapping_path(step)
|
|
102
|
+
path = []
|
|
103
|
+
while step && step[1]
|
|
104
|
+
path.unshift(step[1])
|
|
105
|
+
step = step[2]
|
|
106
|
+
end
|
|
107
|
+
path
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
EMPTY = new(true)
|
|
111
|
+
|
|
112
|
+
# Compiles an expression tree into a non-deterministic automaton, then a deterministic one.
|
|
113
|
+
class Compiler
|
|
114
|
+
def initialize(expression, schema)
|
|
115
|
+
@expression = expression
|
|
116
|
+
@schema = schema
|
|
117
|
+
@nfa = [[]]
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def call
|
|
121
|
+
connect(compile(@expression.tree, 0), new_state)
|
|
122
|
+
explore(null_from(0), {})
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
private
|
|
126
|
+
|
|
127
|
+
Edge = Struct.new(:term, :to)
|
|
128
|
+
|
|
129
|
+
def new_state
|
|
130
|
+
@nfa << []
|
|
131
|
+
@nfa.size - 1
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def edge(from, to = nil, term = nil)
|
|
135
|
+
Edge.new(term, to).tap { |created| @nfa[from] << created }
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def connect(edges, to)
|
|
139
|
+
edges.each { |created| created.to = to }
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def compile(tree, from)
|
|
143
|
+
case tree.first
|
|
144
|
+
when :choice then tree[1].flat_map { |branch| compile(branch, from) }
|
|
145
|
+
when :sequence then compile_sequence(tree[1], from)
|
|
146
|
+
when :repeat then compile_repeat(*tree.drop(1), from)
|
|
147
|
+
when :name then resolve(tree[1]).map { |type| edge(from, nil, type) }
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def compile_sequence(parts, from)
|
|
152
|
+
parts.each_with_index do |part, index|
|
|
153
|
+
edges = compile(part, from)
|
|
154
|
+
return edges if index == parts.size - 1
|
|
155
|
+
|
|
156
|
+
connect(edges, from = new_state)
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def compile_repeat(tree, min, max, from)
|
|
161
|
+
if min.zero? && max.nil? # *
|
|
162
|
+
loop_state = new_state
|
|
163
|
+
edge(from, loop_state)
|
|
164
|
+
connect(compile(tree, loop_state), loop_state)
|
|
165
|
+
[edge(loop_state)]
|
|
166
|
+
elsif min == 1 && max.nil? # +
|
|
167
|
+
loop_state = new_state
|
|
168
|
+
connect(compile(tree, from), loop_state)
|
|
169
|
+
connect(compile(tree, loop_state), loop_state)
|
|
170
|
+
[edge(loop_state)]
|
|
171
|
+
elsif min.zero? && max == 1 # ?
|
|
172
|
+
[edge(from)] + compile(tree, from)
|
|
173
|
+
else
|
|
174
|
+
compile_range(tree, min, max, from)
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def compile_range(tree, min, max, from)
|
|
179
|
+
current = from
|
|
180
|
+
min.times do
|
|
181
|
+
following = new_state
|
|
182
|
+
connect(compile(tree, current), following)
|
|
183
|
+
current = following
|
|
184
|
+
end
|
|
185
|
+
if max.nil?
|
|
186
|
+
connect(compile(tree, current), current)
|
|
187
|
+
else
|
|
188
|
+
(max - min).times do
|
|
189
|
+
following = new_state
|
|
190
|
+
edge(current, following)
|
|
191
|
+
connect(compile(tree, current), following)
|
|
192
|
+
current = following
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
[edge(current)]
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def resolve(name)
|
|
199
|
+
types = @schema.node_types_named(name)
|
|
200
|
+
raise ArgumentError, "no node type or group #{name} in content expression #{@expression.source.inspect}" if types.empty?
|
|
201
|
+
|
|
202
|
+
types
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# The states reachable from `state` without consuming a node, sorted.
|
|
206
|
+
def null_from(state)
|
|
207
|
+
result = []
|
|
208
|
+
scan = lambda do |current|
|
|
209
|
+
edges = @nfa[current]
|
|
210
|
+
return scan.call(edges.first.to) if edges.size == 1 && edges.first.term.nil?
|
|
211
|
+
|
|
212
|
+
result << current
|
|
213
|
+
edges.each { |created| scan.call(created.to) if created.term.nil? && !result.include?(created.to) }
|
|
214
|
+
end
|
|
215
|
+
scan.call(state)
|
|
216
|
+
result.sort.reverse
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def explore(states, labeled)
|
|
220
|
+
out = []
|
|
221
|
+
states.each do |state|
|
|
222
|
+
@nfa[state].each do |created|
|
|
223
|
+
next unless created.term
|
|
224
|
+
|
|
225
|
+
set = out.find { |type, _| type == created.term }&.last
|
|
226
|
+
null_from(created.to).each do |reached|
|
|
227
|
+
out << [created.term, set = []] unless set
|
|
228
|
+
set << reached unless set.include?(reached)
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
match = labeled[states.join(",")] = ContentMatch.new(states.include?(@nfa.size - 1))
|
|
233
|
+
out.each do |type, set|
|
|
234
|
+
set = set.sort.reverse
|
|
235
|
+
match.edges << [type, labeled[set.join(",")] || explore(set, labeled)]
|
|
236
|
+
end
|
|
237
|
+
match
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
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
|