yrby 0.8.1-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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f2a8adb2628da0f03d1f51aaaa8e9d4fff8b312ec2721e553a5948644bc2c316
4
- data.tar.gz: b3f8faa69b75d3d4ec8bc005f968e221da4039d7193401337c14a9e0582d87c4
3
+ metadata.gz: 8c1d9b86b8778b8120f728232c70de38d9a0dfc09ff99973f7ac7ad772c6bcd8
4
+ data.tar.gz: 32e33a4f2cab0777d3e15953aca69085fcedd4189de11a57756fadda5d760553
5
5
  SHA512:
6
- metadata.gz: a4a916bd3d7534bf7c39c6fcfb9fc3ca1b90931bb3887cb4e38faab64d7e8264dd0bbc7902f3c479da32037e64dea3fbfbb4ccdbd5b4d287dd04c05fc0859f3d
7
- data.tar.gz: 9725fd1c74ddaa31b4248f3b318eff62c754970468700e4e5a59db3179923be516628ccd1d2a24219abaff0aca15be26aac701efcfff76bf1fcaa9302b7f7cb2
6
+ metadata.gz: 6bb73b454506e9e766fc7ff22074363a517ac5624075185b79f2c19d0c0cd754997c1b48d6a1341c4a7a0ccfa442f8f6d501d23d38a94100183083417da94107
7
+ data.tar.gz: 45fcbb30ba7e37a59803b6f7ba2d4ac051bf3c59d342eb1dbdef8724e4461661ca3ce64c0fd5a9218d5d44173dc10b0e78baa7b430355976c8ee2e0a084c1c79
data/CHANGELOG.md CHANGED
@@ -6,6 +6,96 @@ 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
+
9
99
  ## [0.8.1] - 2026-10-03
10
100
 
11
101
  ### Added
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
 
@@ -680,7 +683,7 @@ gets blocked at the next reconnect. To avoid that, pair `expires_in:` with
680
683
  # app/controllers/posts_controller.rb
681
684
  def grant
682
685
  @post = current_user.posts.find(params[:id]) # your own authorization, again
683
- render json: { grant: @post.collaborative_sgid(:body, expires_in: 10.minutes) }
686
+ render json: { grant: @post.collaborative_document(:body).grant(expires_in: 10.minutes) }
684
687
  end
685
688
  ```
686
689
 
@@ -744,7 +747,7 @@ up the record from it, so the page decides which document the client gets.
744
747
 
745
748
  ```erb
746
749
  <%# the view picks the document and signs it %>
747
- <%= tag.div data: { grant: post.collaborative_sgid(:body) } %>
750
+ <%= tag.div data: { grant: post.collaborative_document(:body).grant } %>
748
751
  ```
749
752
 
750
753
  ```ruby
@@ -1074,6 +1077,18 @@ The browser clients manage presence (cursors, selections). The server doesn't
1074
1077
  set or hold presence state, and it relays awareness frames without reading
1075
1078
  them. See `yrby-client` for the client-side awareness API.
1076
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
+
1077
1092
  ## Thread Safety
1078
1093
 
1079
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 sgid>" name="body"></yrby-document>
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
- # Y::DocumentChannel by itself and exposes the synced Y.Doc through its
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 is a signed GlobalID scoped to this record and attribute
20
- # (record.collaborative_sgid(name)). Only render the tag on pages where
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.collaborative_sgid(name) }:
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
- grant = record.collaborative_sgid(name, **{ expires_in: expires_in }.compact)
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
- # The helper's attributes go last because a later key wins in a keyword
43
- # splat, so a template can't override the grant or the name.
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
@@ -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.collaborative_sgid(:body) }
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 token from `collaborative_sgid(name)`.
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, and key.
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
- # A signed token that a channel can pass to Y::Collaborative.locate to get
85
- # this record back, for this attribute only. Pass expires_in: to limit how
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
- options = { for: Y::Collaborative.sgid_purpose(name) }
91
- options[:expires_in] = expires_in if expires_in
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>"
@@ -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
@@ -274,7 +274,7 @@ module Y
274
274
 
275
275
  def to_html(root = nil)
276
276
  result = root.nil? ? @native.to_html : @native.to_html(root)
277
- return result unless result.is_a?(Array)
277
+ return result unless result.is_a?(::Array)
278
278
 
279
279
  RenderRules.splice(result, @render_callbacks)
280
280
  end
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Y
4
- VERSION = "0.8.1"
4
+ VERSION = "0.9.0"
5
5
  end
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.8.1
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-10-04 00:00:00.000000000 Z
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: