yrby 0.8.0-aarch64-linux → 0.9.0-aarch64-linux
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 +121 -0
- data/README.md +31 -9
- data/lib/y/3.4/yrby.so +0 -0
- data/lib/y/4.0/yrby.so +0 -0
- data/lib/y/anchor.rb +34 -0
- data/lib/y/collaborative/attribute.rb +36 -0
- data/lib/y/collaborative/helper.rb +13 -10
- data/lib/y/collaborative.rb +14 -11
- data/lib/y/doc.rb +36 -0
- data/lib/y/lexical_markdown.rb +175 -0
- data/lib/y/lexical_writer.rb +142 -0
- data/lib/y/lexxy.rb +27 -2
- data/lib/y/rendering.rb +27 -3
- data/lib/y/text.rb +13 -0
- data/lib/y/version.rb +1 -1
- data/lib/y/xml_text.rb +36 -0
- data/lib/y.rb +6 -0
- metadata +8 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8c1d9b86b8778b8120f728232c70de38d9a0dfc09ff99973f7ac7ad772c6bcd8
|
|
4
|
+
data.tar.gz: 32e33a4f2cab0777d3e15953aca69085fcedd4189de11a57756fadda5d760553
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6bb73b454506e9e766fc7ff22074363a517ac5624075185b79f2c19d0c0cd754997c1b48d6a1341c4a7a0ccfa442f8f6d501d23d38a94100183083417da94107
|
|
7
|
+
data.tar.gz: 45fcbb30ba7e37a59803b6f7ba2d4ac051bf3c59d342eb1dbdef8724e4461661ca3ce64c0fd5a9218d5d44173dc10b0e78baa7b430355976c8ee2e0a084c1c79
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,127 @@ to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## yrby-client 0.7.1 - 2026-11-03
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- yrby-client: `connect()` restores the presence that `disconnect()` removed,
|
|
14
|
+
unless the app set or cleared presence in between. Before this, a `renew()`
|
|
15
|
+
(which document sessions call with a renewed grant) or a manual reconnect
|
|
16
|
+
left the local awareness state `null`, and y-protocols ignores
|
|
17
|
+
`setLocalStateField` while it is `null`, so the user stayed invisible to
|
|
18
|
+
peers until reload.
|
|
19
|
+
|
|
20
|
+
## [0.9.0] - 2026-11-03
|
|
21
|
+
|
|
22
|
+
### Experimental
|
|
23
|
+
|
|
24
|
+
These are new, and they may change in a minor release. See
|
|
25
|
+
[docs/experimental.md](docs/experimental.md).
|
|
26
|
+
|
|
27
|
+
- Live handles that write to a document from Ruby: `Doc#get_map`,
|
|
28
|
+
`get_array`, `get_text`, and `get_xml_text` return `Y::Map`, `Y::Array`,
|
|
29
|
+
`Y::Text`, and `Y::XmlText`. A write is a CRDT edit, so a person editing the
|
|
30
|
+
same document keeps their change. A handle stays on the shared type it was
|
|
31
|
+
made for while others edit around it, and releases the GVL like `Doc`.
|
|
32
|
+
Indexes are Ruby characters, and negative indexes count from the end as in
|
|
33
|
+
Ruby. `push`, `<<`, and `insert` return the receiver, and `each`
|
|
34
|
+
without a block an Enumerator, as Ruby's own do.
|
|
35
|
+
- `Doc#edit { }` runs a block that writes to the document and returns the
|
|
36
|
+
update its writes produced, or nil when it wrote nothing. It is the unit a
|
|
37
|
+
process saves and sends as it writes.
|
|
38
|
+
- `Y::Lexical` and `Y::Lexxy` write Lexical's node shape: `append_paragraph`,
|
|
39
|
+
`append_heading`, `append_list`, `append_quote`, `append_code`,
|
|
40
|
+
`append_markdown`, `insert_paragraph`, `append_text`, `replace_runs`, and
|
|
41
|
+
`delete_block`.
|
|
42
|
+
Text takes formatted runs (`bold:`, `italic:`, `code:`, `link:`, and so on).
|
|
43
|
+
What Ruby writes renders the same as what a person typed. Each helper
|
|
44
|
+
writes its block in one transaction. `XmlText#push_xml_text` and
|
|
45
|
+
`insert_xml_text` take the new block's content, and `append` and
|
|
46
|
+
`replace` write content into a block.
|
|
47
|
+
- `append_markdown` writes a link to a script as plain text.
|
|
48
|
+
- `Y::Anchor`, from `XmlText#anchor`, is a block's identity as a value to
|
|
49
|
+
store or send. `Doc#block_at` and `Doc#find` resolve it.
|
|
50
|
+
`relative_position(index)` builds the Yjs caret shape editors put in
|
|
51
|
+
awareness, and `Text#index_at(position)` resolves one.
|
|
52
|
+
- `Doc#apply_update_changes(update, root)` applies an update and returns the
|
|
53
|
+
ordinals of the top-level blocks it touched. It reads the blocks and
|
|
54
|
+
applies the update in one transaction, so another thread's write doesn't
|
|
55
|
+
show up in the result.
|
|
56
|
+
- `Y::Awareness` builds and reads presence frames, and `Doc#client_id` returns
|
|
57
|
+
the id a document writes under.
|
|
58
|
+
|
|
59
|
+
### Changed
|
|
60
|
+
|
|
61
|
+
- yrby and the renderer crates are on yrs 0.28. yrs 0.28 replaced the number
|
|
62
|
+
variants of `Any` with `Any::Number(Number::Int | Number::Float)`. Because
|
|
63
|
+
the crates take yrs types, this is a breaking release for them:
|
|
64
|
+
`lexical-yjs-html` 0.2.0, `prosemirror-yjs-html` 0.2.0, and
|
|
65
|
+
`yjs-html-core` 0.2.0 need yrs 0.28.
|
|
66
|
+
|
|
67
|
+
### Fixed
|
|
68
|
+
|
|
69
|
+
- The Lexical and ProseMirror renderers (and the `lexical-yjs-html` and
|
|
70
|
+
`prosemirror-yjs-html` 0.2.1 crates) refuse a link to a script: Lexical
|
|
71
|
+
sends it to `about:blank` and ProseMirror empties its `href`, as each
|
|
72
|
+
editor does.
|
|
73
|
+
- A `Timeout`, `Thread#raise`, or `Thread#kill` that arrives during a
|
|
74
|
+
native call raises once the call returns. Before, Ruby could jump over
|
|
75
|
+
the extension's Rust frames without running their cleanup.
|
|
76
|
+
|
|
77
|
+
## yrby-client 0.7.0 - 2026-10-08
|
|
78
|
+
|
|
79
|
+
This release updates yrby-client only. The gems are unchanged.
|
|
80
|
+
|
|
81
|
+
### Added
|
|
82
|
+
|
|
83
|
+
- yrby-client: `<yrby-document>` has a read-only `current` property that
|
|
84
|
+
returns the `yrby:synced` detail of the bound session, or undefined when
|
|
85
|
+
nothing is bound. A binding that loads after the event fired can bind from
|
|
86
|
+
it.
|
|
87
|
+
- yrby-client: `<yrby-document>` has a `retry()` method that acquires the
|
|
88
|
+
document again after its session blocked or was discarded, without changing
|
|
89
|
+
whether the page is live. It works from a lease abort handler or right after
|
|
90
|
+
`session.discard()`, so a binding can replace a broken document with a fresh
|
|
91
|
+
session. It replaces calling `activate()`, which is internal to the Turbo
|
|
92
|
+
adapter.
|
|
93
|
+
- yrby-client: `YrbyDocumentElement.consumer` accepts a function that returns
|
|
94
|
+
a consumer or a promise of one. The element calls it when it first needs a
|
|
95
|
+
consumer and reuses the result. A function that throws or rejects is called
|
|
96
|
+
again on the next attempt, and assigning a different value replaces the
|
|
97
|
+
result.
|
|
98
|
+
|
|
99
|
+
## [0.8.1] - 2026-10-03
|
|
100
|
+
|
|
101
|
+
### Added
|
|
102
|
+
|
|
103
|
+
- `unknown_types` on `Y::Lexical` and `Y::ProseMirror`, and so on `Y::Lexxy`
|
|
104
|
+
and `Y::Tiptap`. It lists the node types in a document that neither the
|
|
105
|
+
built-in schema nor a rule handles, which are the ones `to_html` can't fully
|
|
106
|
+
render. Render a real document from your editor in a test and assert the list
|
|
107
|
+
is empty.
|
|
108
|
+
- The renderer crates export `escape_text` and `escape_attr` for callers that
|
|
109
|
+
build their own markup (`lexical-yjs-html` 0.1.4, `prosemirror-yjs-html`
|
|
110
|
+
0.1.5).
|
|
111
|
+
|
|
112
|
+
### Changed
|
|
113
|
+
|
|
114
|
+
- The demo app and the render-parity test run on Lexxy 1.0. `Y::Lexxy`
|
|
115
|
+
output matches Lexxy 1.0's own `value`, including image alt text edited
|
|
116
|
+
after the document syncs.
|
|
117
|
+
|
|
118
|
+
### Fixed
|
|
119
|
+
|
|
120
|
+
- The Lexical renderers keep the text of an unknown inline wrapper, such as a
|
|
121
|
+
mark with no rule. The text renders without the wrapper. Before, the whole
|
|
122
|
+
node rendered as nothing, so its text was missing from the HTML.
|
|
123
|
+
- `Y::Lexxy`'s list-item rule escapes the stored `__checked` and `__value`
|
|
124
|
+
attributes. Collaborators write those attributes, and a crafted value could
|
|
125
|
+
break out of the HTML attribute. Normal values (booleans and integers) render
|
|
126
|
+
the same as before.
|
|
127
|
+
- The crate READMEs' callback examples escape the values they insert, and both
|
|
128
|
+
READMEs describe how unknown node types render.
|
|
129
|
+
|
|
9
130
|
## [0.8.0] - 2026-10-01
|
|
10
131
|
|
|
11
132
|
This release ships alongside yrby-client 0.6.0, and the client entries below
|
data/README.md
CHANGED
|
@@ -101,6 +101,7 @@ bin/rails generate yrby:install && bin/rails db:migrate
|
|
|
101
101
|
- [Rendering to HTML](#rendering-to-html)
|
|
102
102
|
- [Protocol codec (module functions)](#protocol-codec-module-functions)
|
|
103
103
|
- [ActionCable Integration](#actioncable-integration)
|
|
104
|
+
- [Experimental: writing from Ruby](#experimental-writing-from-ruby)
|
|
104
105
|
- [Thread Safety](#thread-safety)
|
|
105
106
|
- [Parallelism (GVL release)](#parallelism-gvl-release)
|
|
106
107
|
- [Message Type Constants](#message-type-constants)
|
|
@@ -117,7 +118,9 @@ primitives. By default the Ruby side treats a document as opaque CRDT state. It
|
|
|
117
118
|
applies updates, answers sync handshakes, and records deltas without reading
|
|
118
119
|
the contents, and the browser editor decides what shape the document has. When
|
|
119
120
|
you do need to look inside, `Doc#read_text` and `Doc#read_map` rebuild it in
|
|
120
|
-
Ruby
|
|
121
|
+
Ruby, and live handles (`Doc#get_map`, `get_array`, `get_text`,
|
|
122
|
+
`get_xml_text`) write to it. The handles are
|
|
123
|
+
[experimental](docs/experimental.md).
|
|
121
124
|
|
|
122
125
|
## Durability and delivery
|
|
123
126
|
|
|
@@ -338,12 +341,12 @@ every editor configures its own. `Y::Lexical` is the core Lexical base
|
|
|
338
341
|
(paragraphs, headings, quotes, code, lists, tables, links, and the full
|
|
339
342
|
text-format model), and other Lexical editors extend it with rules.
|
|
340
343
|
|
|
341
|
-
It handles every node in the
|
|
342
|
-
format and their combinations, links, the
|
|
343
|
-
blockquotes, code blocks, tabs and soft breaks,
|
|
344
|
-
header cells, image galleries, and ActionText
|
|
345
|
-
both render as `<action-text-attachment>`
|
|
346
|
-
re-render.
|
|
344
|
+
It handles every node in Lexxy 1.0, which has the same set as 0.9.x:
|
|
345
|
+
paragraphs, headings, every text format and their combinations, links, the
|
|
346
|
+
four list types with nesting, blockquotes, code blocks, tabs and soft breaks,
|
|
347
|
+
horizontal rules, tables with header cells, image galleries, and ActionText
|
|
348
|
+
attachments. Uploads and mentions both render as `<action-text-attachment>`
|
|
349
|
+
elements, which ActionText can re-render.
|
|
347
350
|
|
|
348
351
|
The Lexical support has the same two layers. `Y::Lexical` handles core Lexical
|
|
349
352
|
structure natively, and everything Lexxy adds is in `Y::Lexxy`'s rule set,
|
|
@@ -401,6 +404,13 @@ attribute names your templates and blocks will read, and `children` and `text`
|
|
|
401
404
|
tell you which `contains:` to pick. Child block types mean `:blocks`, and text
|
|
402
405
|
means `:inline`.
|
|
403
406
|
|
|
407
|
+
`unknown_types` lists the types whose `handled` is nil. `to_html` still
|
|
408
|
+
renders them as well as it can. An unknown container or inline wrapper renders
|
|
409
|
+
its children without its own markup, so its text stays. A node that keeps its
|
|
410
|
+
content only in its attributes renders nothing. Either way, the node's markup is
|
|
411
|
+
missing from the HTML, and nothing raises. When you add editor nodes, add a test
|
|
412
|
+
that renders a real document and asserts `unknown_types` is empty.
|
|
413
|
+
|
|
404
414
|
When a declarative rule can't express the markup, give the node a block:
|
|
405
415
|
|
|
406
416
|
```ruby
|
|
@@ -673,7 +683,7 @@ gets blocked at the next reconnect. To avoid that, pair `expires_in:` with
|
|
|
673
683
|
# app/controllers/posts_controller.rb
|
|
674
684
|
def grant
|
|
675
685
|
@post = current_user.posts.find(params[:id]) # your own authorization, again
|
|
676
|
-
render json: { grant: @post.
|
|
686
|
+
render json: { grant: @post.collaborative_document(:body).grant(expires_in: 10.minutes) }
|
|
677
687
|
end
|
|
678
688
|
```
|
|
679
689
|
|
|
@@ -737,7 +747,7 @@ up the record from it, so the page decides which document the client gets.
|
|
|
737
747
|
|
|
738
748
|
```erb
|
|
739
749
|
<%# the view picks the document and signs it %>
|
|
740
|
-
<%= tag.div data: { grant: post.
|
|
750
|
+
<%= tag.div data: { grant: post.collaborative_document(:body).grant } %>
|
|
741
751
|
```
|
|
742
752
|
|
|
743
753
|
```ruby
|
|
@@ -1067,6 +1077,18 @@ The browser clients manage presence (cursors, selections). The server doesn't
|
|
|
1067
1077
|
set or hold presence state, and it relays awareness frames without reading
|
|
1068
1078
|
them. See `yrby-client` for the client-side awareness API.
|
|
1069
1079
|
|
|
1080
|
+
## Experimental: writing from Ruby
|
|
1081
|
+
|
|
1082
|
+
These APIs are new. They may change in any minor release, and they aren't
|
|
1083
|
+
covered by yrby's usual compatibility promise.
|
|
1084
|
+
[docs/experimental.md](docs/experimental.md) documents them:
|
|
1085
|
+
|
|
1086
|
+
- Live `Y::Map`, `Y::Array`, and `Y::Text` handles that write to a document
|
|
1087
|
+
- Writing Lexical rich text from Ruby, with `Y::Lexical` and `Y::Lexxy`
|
|
1088
|
+
- Anchors: a block's identity as a value
|
|
1089
|
+
- Streaming into a document with `Doc#edit`
|
|
1090
|
+
- Editing a record's document from Ruby
|
|
1091
|
+
|
|
1070
1092
|
## Thread Safety
|
|
1071
1093
|
|
|
1072
1094
|
You can share a `Doc` across Ruby threads. Puma workers, ActionCable connection
|
data/lib/y/3.4/yrby.so
CHANGED
|
Binary file
|
data/lib/y/4.0/yrby.so
CHANGED
|
Binary file
|
data/lib/y/anchor.rb
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Y
|
|
6
|
+
# A block's identity as a plain value. A live handle already stays on its
|
|
7
|
+
# block while people edit, but it lives in one process; an anchor can be
|
|
8
|
+
# stored and sent, then turned back into the block's ordinal with
|
|
9
|
+
# `Doc#block_at(anchor)` or into a handle with `Doc#find(anchor)`. It is a
|
|
10
|
+
# Yjs relative position that names the block itself, plus the root's name.
|
|
11
|
+
#
|
|
12
|
+
# anchor = block.anchor
|
|
13
|
+
# doc.block_at(anchor) # => 3 (the block's ordinal now), or nil once it is gone
|
|
14
|
+
# doc.find(anchor) # => the live handle, or nil
|
|
15
|
+
# Y::Anchor.from_json(anchor.to_json) == anchor
|
|
16
|
+
Anchor = Data.define(:position, :root) do
|
|
17
|
+
def self.from_h(hash)
|
|
18
|
+
hash = hash.transform_keys(&:to_s)
|
|
19
|
+
new(position: hash.fetch("position").transform_keys(&:to_s), root: hash.fetch("root"))
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.from_json(json)
|
|
23
|
+
from_h(JSON.parse(json))
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def to_h
|
|
27
|
+
{ "position" => position, "root" => root }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def to_json(*)
|
|
31
|
+
to_h.to_json(*)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -22,6 +22,42 @@ module Y
|
|
|
22
22
|
# this to return before it acknowledges or broadcasts the change.
|
|
23
23
|
def append(update) = document_row.append(update)
|
|
24
24
|
|
|
25
|
+
# A signed grant for this document, what a page or a Ruby client
|
|
26
|
+
# subscribes with. It's a signed GlobalID scoped to this attribute, and
|
|
27
|
+
# Y::Collaborative.locate turns it back into the record. Pass
|
|
28
|
+
# expires_in: to limit how long it lasts. Without it GlobalID's own
|
|
29
|
+
# default applies, which is one month under Rails. The option goes
|
|
30
|
+
# through only when it's given, because an explicit nil would mean
|
|
31
|
+
# "never expire".
|
|
32
|
+
def grant(expires_in: nil)
|
|
33
|
+
options = { for: Y::Collaborative.sgid_purpose(name) }
|
|
34
|
+
options[:expires_in] = expires_in if expires_in
|
|
35
|
+
record.to_sgid(**options).to_s
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# The channel that accepts this document's grant. A gem with its own
|
|
39
|
+
# grants and channel, such as lexxy-realtime, returns its own document
|
|
40
|
+
# with both.
|
|
41
|
+
def channel = "Y::DocumentChannel"
|
|
42
|
+
|
|
43
|
+
# Edits the document from Ruby the way a browser does: yields the
|
|
44
|
+
# current document, records what the block changed, and broadcasts it so
|
|
45
|
+
# open editors apply it. Returns the update, or nil when the block
|
|
46
|
+
# changed nothing.
|
|
47
|
+
#
|
|
48
|
+
# post.collaborative_document(:body).edit do |doc|
|
|
49
|
+
# Y::Lexxy.append_paragraph(doc, "Reviewed by ops.")
|
|
50
|
+
# end
|
|
51
|
+
def edit
|
|
52
|
+
doc = y_doc
|
|
53
|
+
update = doc.edit { yield doc }
|
|
54
|
+
return unless update
|
|
55
|
+
|
|
56
|
+
append(update)
|
|
57
|
+
Y::ActionCable.broadcast(key, update)
|
|
58
|
+
update
|
|
59
|
+
end
|
|
60
|
+
|
|
25
61
|
# Builds a new Y::Doc from storage on every call.
|
|
26
62
|
def y_doc
|
|
27
63
|
Y::Doc.new.tap do |doc|
|
|
@@ -9,15 +9,18 @@ module Y
|
|
|
9
9
|
#
|
|
10
10
|
# renders the auto-connecting element:
|
|
11
11
|
#
|
|
12
|
-
# <yrby-document grant="<signed
|
|
12
|
+
# <yrby-document channel="Y::DocumentChannel" grant="<signed grant>" name="body"></yrby-document>
|
|
13
13
|
#
|
|
14
14
|
# Importing "yrby-client/element" registers the element. It subscribes to
|
|
15
|
-
#
|
|
15
|
+
# the document's channel by itself and exposes the synced Y.Doc through its
|
|
16
16
|
# `doc` property and `yrby:synced` event, much like the element behind
|
|
17
17
|
# turbo_stream_from.
|
|
18
18
|
#
|
|
19
|
-
# The grant
|
|
20
|
-
#
|
|
19
|
+
# The grant and channel come from record.collaborative_document(name), so
|
|
20
|
+
# the tag works for any gem that returns its own document, such as
|
|
21
|
+
# lexxy-realtime. Pass channel: for an app's own Y::DocumentChannel
|
|
22
|
+
# subclass. The grant is scoped to this record and attribute. Only render
|
|
23
|
+
# the tag on pages where
|
|
21
24
|
# the user may edit the record, because by default anyone holding the
|
|
22
25
|
# grant can open the document. To also check the user's current
|
|
23
26
|
# permissions when they subscribe, use Y::DocumentChannel.authorize_document.
|
|
@@ -30,18 +33,18 @@ module Y
|
|
|
30
33
|
# expires_in: limits how long the grant lasts. refresh: is a URL the
|
|
31
34
|
# element fetches a new grant from when a subscription is rejected, so it
|
|
32
35
|
# can resubscribe without a page load. The app's action there re-runs its
|
|
33
|
-
# own authorization and renders { grant: record.
|
|
36
|
+
# own authorization and renders { grant: record.collaborative_document(name).grant }:
|
|
34
37
|
#
|
|
35
38
|
# <%= collaborative_document_tag @post, :body, expires_in: 10.minutes,
|
|
36
39
|
# refresh: grant_post_path(@post) %>
|
|
37
40
|
module Helper
|
|
38
41
|
def collaborative_document_tag(record, name, expires_in: nil, refresh: nil, **, &)
|
|
39
|
-
|
|
40
|
-
attributes = { grant: grant, name: name }
|
|
42
|
+
document = record.collaborative_document(name)
|
|
43
|
+
attributes = { grant: document.grant(expires_in:), name: name }
|
|
41
44
|
attributes[:refresh] = refresh if refresh
|
|
42
|
-
#
|
|
43
|
-
#
|
|
44
|
-
tag.yrby_document(**, **attributes, &)
|
|
45
|
+
# A later key wins in a keyword splat. A template can pass its own
|
|
46
|
+
# channel, but it can't override the grant or the name.
|
|
47
|
+
tag.yrby_document(channel: document.channel, **, **attributes, &)
|
|
45
48
|
end
|
|
46
49
|
end
|
|
47
50
|
end
|
data/lib/y/collaborative.rb
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "active_support/concern"
|
|
4
|
+
require "active_support/deprecation"
|
|
4
5
|
require "global_id"
|
|
6
|
+
require "y/action_cable"
|
|
5
7
|
|
|
6
8
|
module Y
|
|
7
9
|
# The signed token that connects a page to a channel for record-backed
|
|
@@ -12,7 +14,7 @@ module Y
|
|
|
12
14
|
# record up from it. The token is a signed GlobalID scoped to one attribute.
|
|
13
15
|
#
|
|
14
16
|
# # the view
|
|
15
|
-
# tag.div data: { grant: post.
|
|
17
|
+
# tag.div data: { grant: post.collaborative_document(:body).grant }
|
|
16
18
|
#
|
|
17
19
|
# # the channel
|
|
18
20
|
# def authorized?(_key)
|
|
@@ -28,6 +30,10 @@ module Y
|
|
|
28
30
|
#
|
|
29
31
|
# The engine includes this into ActiveRecord::Base so any channel's
|
|
30
32
|
# authorized? can use it. lexxy-realtime uses the same token flow.
|
|
33
|
+
# Warns about yrby-rails methods that are going away. The engine adds it to
|
|
34
|
+
# Rails.application.deprecators, so the app's deprecation settings apply.
|
|
35
|
+
def self.deprecator = @deprecator ||= ActiveSupport::Deprecation.new("0.9", "yrby-rails")
|
|
36
|
+
|
|
31
37
|
module Collaborative
|
|
32
38
|
extend ActiveSupport::Concern
|
|
33
39
|
|
|
@@ -39,7 +45,7 @@ module Y
|
|
|
39
45
|
# shipped one can both resolve the same tokens for an attribute.
|
|
40
46
|
def sgid_purpose(name) = "yrby/#{name}"
|
|
41
47
|
|
|
42
|
-
# Looks up the record for a
|
|
48
|
+
# Looks up the record for a grant from `collaborative_document(name).grant`.
|
|
43
49
|
# Returns nil for an invalid, tampered, expired, or wrong-attribute
|
|
44
50
|
# token, and for a record that no longer exists.
|
|
45
51
|
def locate(sgid, name)
|
|
@@ -76,20 +82,17 @@ module Y
|
|
|
76
82
|
end
|
|
77
83
|
|
|
78
84
|
# Returns the document for one attribute, stored the way the model
|
|
79
|
-
# declared. It responds to load_state, append, y_doc,
|
|
85
|
+
# declared. It responds to load_state, append, edit, y_doc, key, grant,
|
|
86
|
+
# and channel.
|
|
80
87
|
def collaborative_document(name)
|
|
81
88
|
Attribute.new(self, name)
|
|
82
89
|
end
|
|
83
90
|
|
|
84
|
-
#
|
|
85
|
-
#
|
|
86
|
-
# long the grant lasts. Without it GlobalID's own default applies, which is
|
|
87
|
-
# one month under Rails. We pass the option through only when it's given,
|
|
88
|
-
# because an explicit nil would mean "never expire".
|
|
91
|
+
# Deprecated. Use collaborative_document(name).grant. This one always
|
|
92
|
+
# signs yrby's own grant, as it did before.
|
|
89
93
|
def collaborative_sgid(name, expires_in: nil)
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
to_sgid(**options).to_s
|
|
94
|
+
Y.deprecator.warn("collaborative_sgid(name) is deprecated. Use collaborative_document(name).grant instead.")
|
|
95
|
+
Attribute.new(self, name).grant(expires_in:)
|
|
93
96
|
end
|
|
94
97
|
end
|
|
95
98
|
end
|
data/lib/y/doc.rb
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Y
|
|
4
|
+
# Ruby-side conveniences over the native document.
|
|
5
|
+
class Doc
|
|
6
|
+
# Runs the block, which edits this document through handles, and returns
|
|
7
|
+
# the update its writes produced, or nil when the document didn't change.
|
|
8
|
+
# The document has the edit either way. The update is what a process
|
|
9
|
+
# saves and broadcasts, one call per chunk as it streams into a document.
|
|
10
|
+
#
|
|
11
|
+
# The update holds everything the document gained while the block ran,
|
|
12
|
+
# so a write another thread or peer made meanwhile is in it too. Applying
|
|
13
|
+
# an update twice changes nothing, so that only adds bytes.
|
|
14
|
+
def edit
|
|
15
|
+
before = native_snapshot
|
|
16
|
+
yield self
|
|
17
|
+
native_update_since(before)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# The ordinal of the top-level block a position falls in, or nil at the
|
|
21
|
+
# root or once the block is gone. Takes an `Anchor`, or a raw relative
|
|
22
|
+
# position hash (`{type, tname, item, assoc}`, the shape editors put in
|
|
23
|
+
# awareness as `anchorPos`/`focusPos`) plus the root's name.
|
|
24
|
+
def block_at(position, root = nil)
|
|
25
|
+
return native_block_at(position.position, position.root) if position.is_a?(Anchor)
|
|
26
|
+
raise ArgumentError, "block_at needs the root name with a raw position" unless root
|
|
27
|
+
|
|
28
|
+
native_block_at(position, root)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# The block an anchor names, as a live handle, or nil once it is gone.
|
|
32
|
+
def find(anchor)
|
|
33
|
+
native_find(anchor.position, anchor.root)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Y
|
|
4
|
+
class Lexical
|
|
5
|
+
# The subset of Markdown a model writes: headings, paragraphs, bulleted
|
|
6
|
+
# and numbered lists, quotes, fenced code, and inline bold, italic, code,
|
|
7
|
+
# and links. Appended as Lexical blocks, so model output arrives looking
|
|
8
|
+
# like what a person would have typed.
|
|
9
|
+
module Markdown
|
|
10
|
+
HEADING = /\A(\#{1,6})\s+(.*)\z/
|
|
11
|
+
BULLET = /\A[-*]\s+(.*)\z/
|
|
12
|
+
NUMBERED = /\A\d+[.)]\s+(.*)\z/
|
|
13
|
+
QUOTE = /\A>\s?(.*)\z/
|
|
14
|
+
FENCE = /\A```\s*([^\s`]*)[^`]*\z/ # the language, then any other info
|
|
15
|
+
INLINE = /
|
|
16
|
+
( \*\*(.+?)\*\* # bold
|
|
17
|
+
| `([^`]+)` # code
|
|
18
|
+
| \[([^\]]+)\]\(((?:[^()\s]|\([^()\s]*\))+)\) # link: its URL may hold balanced parentheses
|
|
19
|
+
| (?<![*\w])\*([^*]+)\*(?!\w) # italic
|
|
20
|
+
| (?<!\w)_([^_]+)_(?!\w) # italic
|
|
21
|
+
)
|
|
22
|
+
/x
|
|
23
|
+
|
|
24
|
+
module_function
|
|
25
|
+
|
|
26
|
+
# Append `markdown` to `doc` through `flavor` (Y::Lexical or Y::Lexxy).
|
|
27
|
+
def append(flavor, doc, markdown, root: "root")
|
|
28
|
+
blocks(markdown.to_s).each do |kind, payload, extra|
|
|
29
|
+
case kind
|
|
30
|
+
when :heading then flavor.append_heading(doc, runs(payload), tag: "h#{extra}", root: root)
|
|
31
|
+
when :paragraph then flavor.append_paragraph(doc, runs(payload), root: root)
|
|
32
|
+
when :quote then flavor.append_quote(doc, runs(payload), root: root)
|
|
33
|
+
when :code then flavor.append_code(doc, payload, language: extra, root: root)
|
|
34
|
+
when :list then flavor.append_list(doc, payload.map { |line| runs(line) }, ordered: extra, root: root)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Lines into [kind, payload, extra] blocks.
|
|
40
|
+
def blocks(text)
|
|
41
|
+
parser = Blocks.new
|
|
42
|
+
text.each_line(chomp: true) { |line| parser.feed(line) }
|
|
43
|
+
parser.finish
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# A line-by-line parser. Prose lines gather into a paragraph, quote
|
|
47
|
+
# lines into a quote, and list lines into a list until something else
|
|
48
|
+
# ends them; a fence gathers lines verbatim until it closes.
|
|
49
|
+
class Blocks
|
|
50
|
+
def initialize
|
|
51
|
+
@out = []
|
|
52
|
+
@para = []
|
|
53
|
+
@quote = nil
|
|
54
|
+
@list = nil
|
|
55
|
+
@fence = nil
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def feed(line)
|
|
59
|
+
return fenced(line) if @fence
|
|
60
|
+
|
|
61
|
+
if (m = FENCE.match(line)) then open_fence(m[1])
|
|
62
|
+
elsif (m = HEADING.match(line)) then block(:heading, m[2], m[1].length)
|
|
63
|
+
elsif (m = BULLET.match(line)) then item(m[1], ordered: false)
|
|
64
|
+
elsif (m = NUMBERED.match(line)) then item(m[1], ordered: true)
|
|
65
|
+
elsif (m = QUOTE.match(line)) then quote(m[1])
|
|
66
|
+
elsif line.strip.empty? then close_all
|
|
67
|
+
else
|
|
68
|
+
close_list
|
|
69
|
+
close_quote
|
|
70
|
+
@para << line.strip
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def finish
|
|
75
|
+
close_fence if @fence
|
|
76
|
+
close_all
|
|
77
|
+
@out
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def fenced(line)
|
|
83
|
+
return close_fence if line.start_with?("```")
|
|
84
|
+
|
|
85
|
+
@fence[:lines] << line
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def open_fence(lang)
|
|
89
|
+
close_all
|
|
90
|
+
@fence = { lang: lang.empty? ? "plain" : lang, lines: [] }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def close_fence
|
|
94
|
+
@out << [:code, @fence[:lines].join("\n"), @fence[:lang]]
|
|
95
|
+
@fence = nil
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def block(kind, payload, extra = nil)
|
|
99
|
+
close_all
|
|
100
|
+
@out << [kind, payload, extra]
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def item(payload, ordered:)
|
|
104
|
+
close_para
|
|
105
|
+
close_quote
|
|
106
|
+
close_list if @list && @list[:ordered] != ordered
|
|
107
|
+
(@list ||= { ordered: ordered, items: [] })[:items] << payload
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def quote(line)
|
|
111
|
+
close_para
|
|
112
|
+
close_list
|
|
113
|
+
@quote ||= []
|
|
114
|
+
@quote << line.strip unless line.strip.empty?
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def close_quote
|
|
118
|
+
@out << [:quote, @quote.join(" ")] if @quote&.any?
|
|
119
|
+
@quote = nil
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def close_para
|
|
123
|
+
@out << [:paragraph, @para.join(" ")] unless @para.empty?
|
|
124
|
+
@para = []
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def close_list
|
|
128
|
+
@out << [:list, @list[:items], @list[:ordered]] if @list
|
|
129
|
+
@list = nil
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def close_all
|
|
133
|
+
close_para
|
|
134
|
+
close_quote
|
|
135
|
+
close_list
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Inline markdown into runs: strings and { text:, bold:/italic:/code:/link: }.
|
|
140
|
+
def runs(line)
|
|
141
|
+
out = []
|
|
142
|
+
rest = line
|
|
143
|
+
while (m = INLINE.match(rest))
|
|
144
|
+
out << m.pre_match unless m.pre_match.empty?
|
|
145
|
+
out << if m[2] then { text: m[2], bold: true }
|
|
146
|
+
elsif m[3] then { text: m[3], code: true }
|
|
147
|
+
elsif m[4] then safe_url?(m[5]) ? { text: m[4], link: m[5] } : m[4]
|
|
148
|
+
else { text: m[6] || m[7], italic: true }
|
|
149
|
+
end
|
|
150
|
+
rest = m.post_match
|
|
151
|
+
end
|
|
152
|
+
out << rest unless rest.empty?
|
|
153
|
+
out
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# The URL schemes a Markdown link may use, as Lexical's own link node
|
|
157
|
+
# allows them. A URL without a scheme is a path and stays.
|
|
158
|
+
SAFE_SCHEMES = %w[http https mailto sms tel].freeze
|
|
159
|
+
|
|
160
|
+
# Whether a model's link is safe to write: a `javascript:` URL becomes
|
|
161
|
+
# plain text. Browsers ignore whitespace and control characters inside
|
|
162
|
+
# a scheme, so those don't hide one.
|
|
163
|
+
def safe_url?(url)
|
|
164
|
+
scheme = url.gsub(/[\x00-\x20\x7f]/, "")[/\A([a-z][a-z0-9+.-]*):/i, 1]
|
|
165
|
+
scheme.nil? || SAFE_SCHEMES.include?(scheme.downcase)
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Append a subset of Markdown as Lexical blocks. Call it on the flavor that
|
|
170
|
+
# matches the editor (`Y::Lexxy.append_markdown` for a Lexxy editor).
|
|
171
|
+
def self.append_markdown(doc, markdown, root: "root")
|
|
172
|
+
Markdown.append(self, doc, markdown, root: root)
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Y
|
|
4
|
+
# Writing to a Lexical document from Ruby.
|
|
5
|
+
#
|
|
6
|
+
# Lexical keeps its document in a root `XmlText`. Each block is an `XmlText`
|
|
7
|
+
# embedded in it, carrying the node's properties as attributes. Inside a
|
|
8
|
+
# block, a text node is an embedded marker (a map of its properties)
|
|
9
|
+
# followed by the characters; a run of formatted text is its own marker and
|
|
10
|
+
# characters. These helpers build exactly that shape, so what Ruby writes
|
|
11
|
+
# renders the same as what a person typed, and an open editor applies it as
|
|
12
|
+
# an ordinary remote edit. Each helper writes its block in one transaction,
|
|
13
|
+
# so another thread or a peer never sees a block without its text, or text
|
|
14
|
+
# under the wrong marker.
|
|
15
|
+
class Lexical
|
|
16
|
+
ELEMENT_ATTRIBUTES = {
|
|
17
|
+
"__format" => 0, "__style" => "", "__indent" => 0, "__dir" => nil,
|
|
18
|
+
"__textFormat" => 0, "__textStyle" => ""
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
PARAGRAPH_ATTRIBUTES = { "__type" => "paragraph", **ELEMENT_ATTRIBUTES }.freeze
|
|
22
|
+
|
|
23
|
+
TEXT_ATTRIBUTES = {
|
|
24
|
+
"__type" => "text", "__format" => 0, "__style" => "", "__mode" => 0, "__detail" => 0
|
|
25
|
+
}.freeze
|
|
26
|
+
|
|
27
|
+
# Lexical's text format bits.
|
|
28
|
+
FORMAT = { bold: 1, italic: 2, strikethrough: 4, underline: 8, code: 16 }.freeze
|
|
29
|
+
|
|
30
|
+
# A heading block: `tag` is "h1".."h6".
|
|
31
|
+
def self.heading_attributes(tag)
|
|
32
|
+
{ "__type" => "heading", "__tag" => tag.to_s, **ELEMENT_ATTRIBUTES }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.quote_attributes = { "__type" => "quote", **ELEMENT_ATTRIBUTES }
|
|
36
|
+
|
|
37
|
+
# A code block. Its text nodes are `code-highlight` nodes.
|
|
38
|
+
def self.code_attributes(language: "plain")
|
|
39
|
+
{ "__type" => "code", "__language" => language.to_s, **ELEMENT_ATTRIBUTES }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
CODE_TEXT_ATTRIBUTES = { **TEXT_ATTRIBUTES, "__type" => "code-highlight" }.freeze
|
|
43
|
+
|
|
44
|
+
# A text node's marker with a format from FORMAT keys, e.g. bold: true.
|
|
45
|
+
def self.text_attributes(**format)
|
|
46
|
+
bits = format.sum { |name, on| on ? FORMAT.fetch(name) : 0 }
|
|
47
|
+
{ **TEXT_ATTRIBUTES, "__format" => bits }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# A list block: `ordered:` false is a bulleted list ("ul"), true a numbered
|
|
51
|
+
# one ("ol").
|
|
52
|
+
def self.list_attributes(ordered: false)
|
|
53
|
+
{ "__type" => "list", "__tag" => ordered ? "ol" : "ul",
|
|
54
|
+
"__listType" => ordered ? "number" : "bullet", "__start" => 1, **ELEMENT_ATTRIBUTES }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# A list item; `value` is its 1-based position. The item node's type is
|
|
58
|
+
# `list_item_type`, which an editor flavor can override (Lexxy uses its own).
|
|
59
|
+
def self.list_item_attributes(value)
|
|
60
|
+
{ "__type" => list_item_type, "__value" => value, **ELEMENT_ATTRIBUTES }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def self.list_item_type = "listitem"
|
|
64
|
+
|
|
65
|
+
# A link inside a block: an element holding its own text nodes.
|
|
66
|
+
def self.link_attributes(url, title: nil)
|
|
67
|
+
{ "__type" => "link", "__url" => url.to_s, "__target" => nil, "__rel" => nil,
|
|
68
|
+
"__title" => title, **ELEMENT_ATTRIBUTES }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# --- appending blocks ---
|
|
72
|
+
|
|
73
|
+
# Append a paragraph and return the live block. `runs` is a string, or an
|
|
74
|
+
# array of strings and hashes like `{ text: "bold", bold: true }` or
|
|
75
|
+
# `{ text: "docs", link: "https://…" }`.
|
|
76
|
+
def self.append_paragraph(doc, runs, root: "root")
|
|
77
|
+
append_block(doc, PARAGRAPH_ATTRIBUTES, runs, root: root)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Append a heading (`tag:` "h1".."h6") and return the live block.
|
|
81
|
+
def self.append_heading(doc, runs, tag: "h2", root: "root")
|
|
82
|
+
append_block(doc, heading_attributes(tag), runs, root: root)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def self.append_quote(doc, runs, root: "root")
|
|
86
|
+
append_block(doc, quote_attributes, runs, root: root)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Append a code block. Lines stay as they are; `language:` names the syntax.
|
|
90
|
+
def self.append_code(doc, code, language: "plain", root: "root")
|
|
91
|
+
doc.get_xml_text(root).push_xml_text(code_attributes(language: language),
|
|
92
|
+
[CODE_TEXT_ATTRIBUTES, code.to_s])
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Append a list of `items` (each a string or runs) and return the live list.
|
|
96
|
+
def self.append_list(doc, items, ordered: false, root: "root")
|
|
97
|
+
entries = items.each_with_index.map { |runs, i| [list_item_attributes(i + 1), content(runs)] }
|
|
98
|
+
doc.get_xml_text(root).push_xml_text(list_attributes(ordered: ordered), entries)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Append a block with `attributes` holding `runs`.
|
|
102
|
+
def self.append_block(doc, attributes, runs, root: "root")
|
|
103
|
+
doc.get_xml_text(root).push_xml_text(attributes, content(runs))
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# --- editing in place ---
|
|
107
|
+
|
|
108
|
+
# Insert a paragraph before the `at`-th block (at the end when past the
|
|
109
|
+
# last) and return it.
|
|
110
|
+
def self.insert_paragraph(doc, at, runs, root: "root")
|
|
111
|
+
doc.get_xml_text(root).insert_xml_text(at, PARAGRAPH_ATTRIBUTES, content(runs))
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Replace everything in a block with `runs`.
|
|
115
|
+
def self.replace_runs(block, runs) = block.replace(content(runs))
|
|
116
|
+
|
|
117
|
+
# Remove the `at`-th block. Returns whether there was one.
|
|
118
|
+
def self.delete_block(doc, at, root: "root") = doc.get_xml_text(root).delete_xml_text(at)
|
|
119
|
+
|
|
120
|
+
# Append plain `text` to the end of `block` and return the block. This is
|
|
121
|
+
# the call for streaming into a block. Text must follow a text node's
|
|
122
|
+
# marker or Lexical drops it, so after a link, or in an empty block, it
|
|
123
|
+
# starts a text node of its own; otherwise it joins the last one.
|
|
124
|
+
def self.append_text(block, text) = block.append_text(text.to_s, TEXT_ATTRIBUTES)
|
|
125
|
+
|
|
126
|
+
# Write `runs` at the end of `block`.
|
|
127
|
+
def self.write_runs(block, runs) = block.append(content(runs))
|
|
128
|
+
|
|
129
|
+
# `runs` as block content (see `XmlText#insert_xml_text`): a text node,
|
|
130
|
+
# its marker then its text, per run, and a link element holding one for a
|
|
131
|
+
# run with `link:`. Runs may use string keys, as parsed JSON does.
|
|
132
|
+
def self.content(runs)
|
|
133
|
+
Array(runs).flat_map do |run|
|
|
134
|
+
run = run.is_a?(String) ? { text: run } : run.transform_keys(&:to_sym)
|
|
135
|
+
raise ArgumentError, "a run needs text: #{run.inspect}" unless run.key?(:text)
|
|
136
|
+
|
|
137
|
+
text = [text_attributes(**run.slice(*FORMAT.keys)), run[:text].to_s]
|
|
138
|
+
run[:link] ? [[link_attributes(run[:link], title: run[:title]), text]] : text
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
data/lib/y/lexxy.rb
CHANGED
|
@@ -11,6 +11,27 @@ module Y
|
|
|
11
11
|
# nodes are declarative hashes, nodes with logic are plain methods mapped
|
|
12
12
|
# in NODES.
|
|
13
13
|
class Lexxy < Lexical
|
|
14
|
+
# Lexxy's list items are its own node type (it leaves a list when you press
|
|
15
|
+
# Enter on an empty item), so lists written for a Lexxy editor use it.
|
|
16
|
+
def self.list_item_type
|
|
17
|
+
"early_escape_listitem"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Lexxy's code block is its own node type, with a syntax-highlight flag.
|
|
21
|
+
def self.code_attributes(language: "plain")
|
|
22
|
+
{ **super, "__type" => "early_escape_code", "__isSyntaxHighlightSupported" => true }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Lexxy links carry rel="noreferrer".
|
|
26
|
+
def self.link_attributes(url, title: nil)
|
|
27
|
+
{ **super, "__rel" => "noreferrer" }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Lexxy puts a quote's text inside a paragraph within the quote block.
|
|
31
|
+
def self.append_quote(doc, runs, root: "root")
|
|
32
|
+
doc.get_xml_text(root).push_xml_text(quote_attributes, [[PARAGRAPH_ATTRIBUTES, content(runs)]])
|
|
33
|
+
end
|
|
34
|
+
|
|
14
35
|
# A cursor-placement placeholder; empty ones export to nothing.
|
|
15
36
|
def self.provisional_paragraph(node)
|
|
16
37
|
node.content.empty? ? "" : "<p>#{node.content}</p>"
|
|
@@ -42,8 +63,8 @@ module Y
|
|
|
42
63
|
def self.list_item(node)
|
|
43
64
|
out = +"<li"
|
|
44
65
|
checked = node.attrs["__checked"]
|
|
45
|
-
out << %( aria-checked="#{checked}") unless checked.nil?
|
|
46
|
-
out << %( value="#{node.attrs["__value"] || 1}")
|
|
66
|
+
out << %( aria-checked="#{RenderRules.escape_attr(checked)}") unless checked.nil?
|
|
67
|
+
out << %( value="#{RenderRules.escape_attr(node.attrs["__value"] || 1)}")
|
|
47
68
|
out << %( class="lexxy-nested-listitem") if node.child_types.include?("list")
|
|
48
69
|
"#{out}>#{node.content}</li>"
|
|
49
70
|
end
|
|
@@ -101,6 +122,8 @@ module Y
|
|
|
101
122
|
|
|
102
123
|
NODES = {
|
|
103
124
|
# Lexxy's replacement for Lexical's CodeNode.
|
|
125
|
+
# Lexxy wraps a quote's content in a paragraph.
|
|
126
|
+
"quote" => { tag: "blockquote", contains: :blocks },
|
|
104
127
|
"early_escape_code" => { tag: "pre", attrs: { "data-language" => :language } },
|
|
105
128
|
"horizontal_divider" => { tag: "hr", void: true },
|
|
106
129
|
"provisonal_paragraph" => method(:provisional_paragraph), # (sic: Lexxy's spelling)
|
|
@@ -109,6 +132,8 @@ module Y
|
|
|
109
132
|
"wrapped_table_node" => { contains: :blocks, render: method(:table) },
|
|
110
133
|
"tablecell" => { contains: :blocks, render: method(:table_cell) },
|
|
111
134
|
"listitem" => { contains: :blocks, render: method(:list_item) },
|
|
135
|
+
# Lexxy's own list item (it leaves the list on Enter in an empty item).
|
|
136
|
+
"early_escape_listitem" => { contains: :blocks, render: method(:list_item) },
|
|
112
137
|
"action_text_attachment" => method(:upload),
|
|
113
138
|
"action_text_attachment_upload" => method(:pending_upload),
|
|
114
139
|
"custom_action_text_attachment" => method(:mention)
|
data/lib/y/rendering.rb
CHANGED
|
@@ -125,7 +125,7 @@ module Y
|
|
|
125
125
|
# A template: String literal, Symbol attribute reference, or an Array of
|
|
126
126
|
# both.
|
|
127
127
|
def compile_parts(template)
|
|
128
|
-
Array(template).map do |part|
|
|
128
|
+
Kernel.Array(template).map do |part|
|
|
129
129
|
case part
|
|
130
130
|
when Symbol then { "ref" => part.to_s }
|
|
131
131
|
else { "lit" => part.to_s }
|
|
@@ -227,7 +227,7 @@ module Y
|
|
|
227
227
|
|
|
228
228
|
def to_html(root = nil)
|
|
229
229
|
result = root.nil? ? @native.to_html : @native.to_html(root)
|
|
230
|
-
return result unless result.is_a?(Array)
|
|
230
|
+
return result unless result.is_a?(::Array)
|
|
231
231
|
|
|
232
232
|
RenderRules.splice(result, @render_callbacks)
|
|
233
233
|
end
|
|
@@ -241,6 +241,18 @@ module Y
|
|
|
241
241
|
json = root.nil? ? @native.node_types : @native.node_types(root)
|
|
242
242
|
json && JSON.parse(json)
|
|
243
243
|
end
|
|
244
|
+
|
|
245
|
+
# The node types to_html can't fully render: types in the document that
|
|
246
|
+
# neither the built-in schema nor a registered rule handles. An unknown
|
|
247
|
+
# container or inline wrapper renders its children without its own
|
|
248
|
+
# markup, so its text stays. An unknown decorator renders nothing, so
|
|
249
|
+
# content kept only in its attributes is missing from the HTML. Returns
|
|
250
|
+
# [] when every type is handled, and when the root is missing or isn't
|
|
251
|
+
# Lexical-shaped, because to_html returns nil there.
|
|
252
|
+
def unknown_types(root = nil)
|
|
253
|
+
types = node_types(root)
|
|
254
|
+
types ? types.filter_map { |type, info| type if info["handled"].nil? } : []
|
|
255
|
+
end
|
|
244
256
|
end
|
|
245
257
|
|
|
246
258
|
class ProseMirror
|
|
@@ -262,7 +274,7 @@ module Y
|
|
|
262
274
|
|
|
263
275
|
def to_html(root = nil)
|
|
264
276
|
result = root.nil? ? @native.to_html : @native.to_html(root)
|
|
265
|
-
return result unless result.is_a?(Array)
|
|
277
|
+
return result unless result.is_a?(::Array)
|
|
266
278
|
|
|
267
279
|
RenderRules.splice(result, @render_callbacks)
|
|
268
280
|
end
|
|
@@ -276,6 +288,18 @@ module Y
|
|
|
276
288
|
json = root.nil? ? @native.node_types : @native.node_types(root)
|
|
277
289
|
json && JSON.parse(json)
|
|
278
290
|
end
|
|
291
|
+
|
|
292
|
+
# The node types to_html can't fully render: types in the document that
|
|
293
|
+
# neither the built-in schema nor a registered rule handles. An unknown
|
|
294
|
+
# node renders its text and children without its own markup. A node that
|
|
295
|
+
# keeps its content only in its attributes renders nothing. Marks aren't
|
|
296
|
+
# listed, and an unknown mark renders its text unformatted. Returns []
|
|
297
|
+
# when every type is handled, and when the root is missing or isn't
|
|
298
|
+
# ProseMirror-shaped, because to_html returns nil there.
|
|
299
|
+
def unknown_types(root = nil)
|
|
300
|
+
types = node_types(root)
|
|
301
|
+
types ? types.filter_map { |type, info| type if info["handled"].nil? } : []
|
|
302
|
+
end
|
|
279
303
|
end
|
|
280
304
|
|
|
281
305
|
private_constant :NativeLexical, :NativeProseMirror
|
data/lib/y/text.rb
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Y
|
|
4
|
+
class Text
|
|
5
|
+
# The Yjs relative position of character `index`, the shape editors put
|
|
6
|
+
# in awareness as a caret. It keeps pointing at the same character as
|
|
7
|
+
# people edit around it; `index_at` says where that is now. `assoc:` is
|
|
8
|
+
# :after (default) or :before.
|
|
9
|
+
def relative_position(index, assoc: :after)
|
|
10
|
+
native_relative_position(index, assoc.to_s)
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
data/lib/y/version.rb
CHANGED
data/lib/y/xml_text.rb
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Y
|
|
4
|
+
# Ruby-side conveniences over the native live handle.
|
|
5
|
+
class XmlText
|
|
6
|
+
# Insert a block with `attributes` before the `ordinal`-th embedded
|
|
7
|
+
# block and return a live handle to it. A negative `ordinal` counts from
|
|
8
|
+
# the end, so -1 adds it after the last. `content` is written into the
|
|
9
|
+
# block in the same transaction, so no one sees the block before what it
|
|
10
|
+
# holds. It is an Array of Strings (text), Hashes (embeds, such as the
|
|
11
|
+
# marker Lexical puts before a text node's characters), and
|
|
12
|
+
# `[attributes, content]` pairs (blocks of their own).
|
|
13
|
+
def insert_xml_text(ordinal, attributes, content = [])
|
|
14
|
+
native_insert_xml_text(ordinal, attributes, content)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Append a block with `attributes` holding `content` (see
|
|
18
|
+
# `insert_xml_text`) and return a live handle to it.
|
|
19
|
+
def push_xml_text(attributes, content = [])
|
|
20
|
+
insert_xml_text(-1, attributes, content)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# The Yjs relative position of character `index`: the `{type, tname,
|
|
24
|
+
# item, assoc}` hash editors carry in awareness as `anchorPos`/`focusPos`.
|
|
25
|
+
# `assoc:` is `:after` (the default) or `:before`.
|
|
26
|
+
def relative_position(index, assoc: :after)
|
|
27
|
+
native_relative_position(index, assoc.to_s)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# This block's `Anchor`: a value that names the block itself, with the
|
|
31
|
+
# root's name, so the document can say where the block is later.
|
|
32
|
+
def anchor
|
|
33
|
+
Anchor.new(position: native_block_position, root: root_name)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
data/lib/y.rb
CHANGED
|
@@ -14,6 +14,12 @@ end
|
|
|
14
14
|
|
|
15
15
|
require_relative "y/rendering"
|
|
16
16
|
require_relative "y/lexxy"
|
|
17
|
+
require_relative "y/lexical_writer"
|
|
18
|
+
require_relative "y/lexical_markdown"
|
|
19
|
+
require_relative "y/anchor"
|
|
20
|
+
require_relative "y/xml_text"
|
|
21
|
+
require_relative "y/text"
|
|
22
|
+
require_relative "y/doc"
|
|
17
23
|
require_relative "y/tiptap"
|
|
18
24
|
require_relative "y/decoder"
|
|
19
25
|
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: yrby
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.9.0
|
|
5
5
|
platform: aarch64-linux
|
|
6
6
|
authors:
|
|
7
7
|
- JP Camara
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-11-03 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: minitest
|
|
@@ -72,14 +72,20 @@ files:
|
|
|
72
72
|
- lib/y.rb
|
|
73
73
|
- lib/y/3.4/yrby.so
|
|
74
74
|
- lib/y/4.0/yrby.so
|
|
75
|
+
- lib/y/anchor.rb
|
|
75
76
|
- lib/y/collaborative.rb
|
|
76
77
|
- lib/y/collaborative/attribute.rb
|
|
77
78
|
- lib/y/collaborative/helper.rb
|
|
78
79
|
- lib/y/decoder.rb
|
|
80
|
+
- lib/y/doc.rb
|
|
81
|
+
- lib/y/lexical_markdown.rb
|
|
82
|
+
- lib/y/lexical_writer.rb
|
|
79
83
|
- lib/y/lexxy.rb
|
|
80
84
|
- lib/y/rendering.rb
|
|
85
|
+
- lib/y/text.rb
|
|
81
86
|
- lib/y/tiptap.rb
|
|
82
87
|
- lib/y/version.rb
|
|
88
|
+
- lib/y/xml_text.rb
|
|
83
89
|
- lib/yrby.rb
|
|
84
90
|
homepage: https://github.com/jpcamara/yrby
|
|
85
91
|
licenses:
|