yrby 0.2.3 → 0.3.1

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.
data/ext/yrby/src/read.rs CHANGED
@@ -62,6 +62,17 @@ fn lexical_type<T: ReadTxn>(txn: &T, t: &XmlTextRef) -> String {
62
62
  }
63
63
  }
64
64
 
65
+ /// The `__type` of an embedded Lexical `Y.Map`. Two kinds appear inside a
66
+ /// block: text-node metadata (`"text"`) and node maps like the LineBreakNode
67
+ /// (`"linebreak"`). Structure confirmed from live-editor bytes (see the
68
+ /// captured-fixture test).
69
+ fn lexical_map_type<T: ReadTxn>(txn: &T, m: &MapRef) -> String {
70
+ match m.get(txn, "__type") {
71
+ Some(Out::Any(Any::String(s))) => s.to_string(),
72
+ _ => String::new(),
73
+ }
74
+ }
75
+
65
76
  /// Gather the text of an inline Lexical element (its text runs and any nested
66
77
  /// inline elements) without introducing block breaks.
67
78
  fn inline_lexical_text<T: ReadTxn>(txn: &T, t: &XmlTextRef, buf: &mut String) {
@@ -69,7 +80,12 @@ fn inline_lexical_text<T: ReadTxn>(txn: &T, t: &XmlTextRef, buf: &mut String) {
69
80
  match d.insert {
70
81
  Out::Any(Any::String(s)) => buf.push_str(&s),
71
82
  Out::YXmlText(child) => inline_lexical_text(txn, &child, buf),
72
- _ => {} // per-text-node metadata map, decorator embeds: no text
83
+ Out::YMap(m) => match lexical_map_type(txn, &m).as_str() {
84
+ "linebreak" => buf.push('\n'),
85
+ "tab" => buf.push('\t'),
86
+ _ => {} // per-text-node metadata: no text of its own
87
+ },
88
+ _ => {} // decorator embeds: no text
73
89
  }
74
90
  }
75
91
  }
@@ -81,17 +97,30 @@ fn walk_lexical_block<T: ReadTxn>(txn: &T, t: &XmlTextRef, out: &mut Vec<String>
81
97
  for d in t.diff(txn, YChange::identity) {
82
98
  match d.insert {
83
99
  Out::Any(Any::String(s)) => line.push_str(&s),
100
+ // Node maps: linebreak/tab carry no text, so emit the character
101
+ // they represent ("foo⏎bar" must not become "foobar"). Metadata
102
+ // maps ("text") stay silent.
103
+ Out::YMap(m) => match lexical_map_type(txn, &m).as_str() {
104
+ "linebreak" => line.push('\n'),
105
+ "tab" => line.push('\t'),
106
+ _ => {}
107
+ },
84
108
  Out::YXmlText(child) => {
85
- if is_inline_lexical_type(&lexical_type(txn, &child)) {
86
- inline_lexical_text(txn, &child, &mut line);
87
- } else {
88
- if !line.is_empty() {
89
- out.push(std::mem::take(&mut line));
109
+ let ty = lexical_type(txn, &child);
110
+ match ty.as_str() {
111
+ // Defensive only: real Lexical stores these as Y.Map embeds.
112
+ "linebreak" => line.push('\n'),
113
+ "tab" => line.push('\t'),
114
+ _ if is_inline_lexical_type(&ty) => inline_lexical_text(txn, &child, &mut line),
115
+ _ => {
116
+ if !line.is_empty() {
117
+ out.push(std::mem::take(&mut line));
118
+ }
119
+ walk_lexical_block(txn, &child, out);
90
120
  }
91
- walk_lexical_block(txn, &child, out);
92
121
  }
93
122
  }
94
- _ => {} // per-text-node metadata map; embeds we don't read for text
123
+ _ => {} // decorator embeds we don't read for text
95
124
  }
96
125
  }
97
126
  if !line.is_empty() {
@@ -256,6 +285,53 @@ mod tests {
256
285
  assert_eq!(map_json(&txn, &map), "{}");
257
286
  }
258
287
 
288
+ #[test]
289
+ fn lexical_soft_line_break_and_tab_emit_their_characters() {
290
+ // A paragraph "foo⏎bar" (shift-enter): Lexical stores the LineBreakNode
291
+ // as an embedded Y.Map with __type=linebreak (the same shape as the
292
+ // per-text-node metadata maps, which must stay silent). It must come
293
+ // through as '\n', not vanish and glue the words. Same for tab.
294
+ use yrs::{Text, XmlTextPrelim};
295
+ let doc = Doc::new();
296
+ let frag = doc.get_or_insert_xml_fragment("lex");
297
+ {
298
+ let mut txn = doc.transact_mut();
299
+ let block = frag.push_back(&mut txn, XmlTextPrelim::new(""));
300
+ let meta: MapPrelim = [("__type", yrs::In::from("text"))].into_iter().collect();
301
+ block.insert_embed(&mut txn, 0, meta); // metadata map: no text
302
+ block.push(&mut txn, "foo");
303
+ let br: MapPrelim = [("__type", yrs::In::from("linebreak"))]
304
+ .into_iter()
305
+ .collect();
306
+ block.insert_embed(&mut txn, 4, br);
307
+ block.push(&mut txn, "bar");
308
+ let tab: MapPrelim = [("__type", yrs::In::from("tab"))].into_iter().collect();
309
+ block.insert_embed(&mut txn, 8, tab);
310
+ block.push(&mut txn, "baz");
311
+ }
312
+ let txn = doc.transact();
313
+ assert_eq!(xml_blocks_text(&txn, &frag), "foo\nbar\tbaz");
314
+ }
315
+
316
+ #[test]
317
+ fn lexical_real_captured_linebreak_extracts_as_newline() {
318
+ // Ground truth: bytes captured from a LIVE Lexxy editor (agent-browser
319
+ // typing "foo", pressing Shift+Enter, typing "barbaz"), served by the
320
+ // yrby test server's durable store. The hand-built test above models
321
+ // this structure; this one IS the structure. Regenerate by driving
322
+ // lexxy-realtime's test server and saving GET /content/:room.
323
+ use yrs::updates::decoder::Decode;
324
+ use yrs::Update;
325
+ let bytes = include_bytes!("fixtures/lexical_linebreak.bin");
326
+ let doc = Doc::new();
327
+ doc.transact_mut()
328
+ .apply_update(Update::decode_v1(bytes).unwrap())
329
+ .unwrap();
330
+ let txn = doc.transact();
331
+ let frag = txn.get_xml_fragment("root").unwrap();
332
+ assert_eq!(xml_blocks_text(&txn, &frag), "foo\nbarbaz");
333
+ }
334
+
259
335
  #[test]
260
336
  fn lexical_complex_doc_extracts_all_nested_text() {
261
337
  // A real Lexxy/Lexical doc with every block type: headings, formatted
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.2.3"
4
+ VERSION = "0.3.1"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: yrby
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.3
4
+ version: 0.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - JP Camara
@@ -77,6 +77,7 @@ extensions:
77
77
  extra_rdoc_files: []
78
78
  files:
79
79
  - CHANGELOG.md
80
+ - Cargo.lock
80
81
  - Cargo.toml
81
82
  - LICENSE
82
83
  - README.md
@@ -86,10 +87,7 @@ files:
86
87
  - ext/yrby/src/protocol.rs
87
88
  - ext/yrby/src/read.rs
88
89
  - lib/y.rb
89
- - lib/y/decoder.rb
90
- - lib/y/decoder/version.rb
91
90
  - lib/y/version.rb
92
- - lib/yrby-decoder.rb
93
91
  - lib/yrby.rb
94
92
  homepage: https://github.com/jpcamara/yrby
95
93
  licenses:
@@ -1,7 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Y
4
- module Decoder
5
- VERSION = "0.1.0.alpha1"
6
- end
7
- end
data/lib/y/decoder.rb DELETED
@@ -1,66 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "y"
4
- require "y/decoder/version"
5
-
6
- module Y
7
- # Plain-text reconstruction of a stored Yjs document, in pure Ruby — for search
8
- # indexing and previews. The core `yrby` gem moves and stores opaque CRDT
9
- # updates without reading them; this reads the text out of the shared type the
10
- # editor uses (Lexical's `Y.XmlText`, plain `Y.Text`, or ProseMirror's
11
- # `Y.XmlFragment`), in-process, on the native extension core already ships — no
12
- # Node, no subprocess, no binary.
13
- #
14
- # state = doc.encode_state_as_update # opaque CRDT bytes from the store
15
- # Y::Decoder.text(state) # => "hello world"
16
- # Y::Decoder.preview(state, 280) # => "hello world…"
17
- #
18
- # Full-fidelity reconstruction (the exact Lexical EditorState / HTML, which
19
- # needs @lexical/yjs) is a separate, opt-in concern — see the `yrby-decode`
20
- # package's Bun binary. This gem stays pure Ruby on purpose.
21
- module Decoder
22
- class Error < Y::Error; end
23
-
24
- module_function
25
-
26
- # Plain text of the document. `field` pins the root key (Lexical: the editor
27
- # id; ProseMirror: "default"); omit it to use the document's sole root.
28
- def text(state, field: nil)
29
- field ||= Y::Doc.new.tap { |d| d.apply_update(state) }.root_names.first
30
- return "" unless field
31
-
32
- # A plain `Y.Text` root (a simple shared-text editor) reads straight out.
33
- # (A yrs root's type is fixed by its first typed access, so each reader
34
- # gets a fresh doc to try a different shared type against the same state.)
35
- direct = load(state).read_text(field)
36
- return normalize(direct) if direct && !direct.strip.empty?
37
-
38
- # Lexical (each block a sibling `Y.XmlText`) and ProseMirror (blocks are
39
- # `Y.XmlElement`s) both come back from read_xml as block-per-line markup;
40
- # strip any element tags to plain text.
41
- markup = load(state).read_xml(field)
42
- markup ? normalize(strip_tags(markup)) : ""
43
- end
44
-
45
- # A compact, single-line preview for list UIs.
46
- def preview(state, limit: 280, field: nil)
47
- body = text(state, field: field).gsub(/\s+/, " ").strip
48
- body.length > limit ? "#{body[0, limit].rstrip}…" : body
49
- end
50
-
51
- def load(state)
52
- Y::Doc.new.tap { |doc| doc.apply_update(state) }
53
- end
54
-
55
- def strip_tags(markup)
56
- markup.gsub(/<[^>]*>/, " ")
57
- end
58
-
59
- def normalize(text)
60
- text.gsub(/[ \t]+/, " ") # collapse runs of spaces/tabs
61
- .gsub(/ *\n */, "\n") # trim spaces left around block separators
62
- .gsub(/\n{3,}/, "\n\n") # cap blank-line runs
63
- .strip
64
- end
65
- end
66
- end
data/lib/yrby-decoder.rb DELETED
@@ -1,4 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # Entry point matching the gem name, so `Bundler.require` loads it automatically.
4
- require "y/decoder"