yrby 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9e731133b862944bb0475e1175d59c1d365924a5da722a395624426044371413
4
- data.tar.gz: 1ec6b9298ecb11609d3d93279ecd5239f54b10ae5e7cd5f9d1b7deffe75288b6
3
+ metadata.gz: cc870d86089bc5373cbac29455b1b7818c4a710b4ca1c8c12eeee2939faa8758
4
+ data.tar.gz: 66c41fad450c8e71a23a166bc1f0e6a83def6bdcc94c79724bb63d7691a28f16
5
5
  SHA512:
6
- metadata.gz: 3b0d016da20f2bd97529fdf0fce808a771e4997d9834f5be9234ef6f0225485f72fa3854cd7a362ff72275f84f3fa8003ac0cd42efea55028c07879fa6b9da1a
7
- data.tar.gz: da77acc0583feb5e7cbb4da0076c6fd3053c11fb64a6aa4c33d708a31220a49e67f1cadf6c5ea27f67c9dd5b9c655285a004dbe70acea1133a232c606d8f7ccb
6
+ metadata.gz: 0725a1a0966f516316041b3d46edcd946783802691d568ed139549fbc120515b809ca368784f23f522bad7e0a4f24abb46951985ff0935627b1733d2e3b47cd3
7
+ data.tar.gz: 6fcf5914510b36d10fe3bf344725cb151f945c57df95b692cdd5a4344ceeba2e81ed6455a0ec3b4071ae5a9c569de4bc0ae3bea9aa6b40b399594c296fb71a0b
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/Cargo.lock CHANGED
@@ -13,9 +13,9 @@ dependencies = [
13
13
 
14
14
  [[package]]
15
15
  name = "arc-swap"
16
- version = "1.8.0"
16
+ version = "1.9.3"
17
17
  source = "registry+https://github.com/rust-lang/crates.io-index"
18
- checksum = "51d03449bb8ca2cc2ef70869af31463d1ae5ccc8fa3e334b307203fbf815207e"
18
+ checksum = "380962b3d93b99642e50b9febf72c071e97e8b32d86eae9cf4ef582bb960ae6e"
19
19
  dependencies = [
20
20
  "rustversion",
21
21
  ]
@@ -117,9 +117,9 @@ checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
117
117
 
118
118
  [[package]]
119
119
  name = "dashmap"
120
- version = "6.1.0"
120
+ version = "6.2.1"
121
121
  source = "registry+https://github.com/rust-lang/crates.io-index"
122
- checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf"
122
+ checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c"
123
123
  dependencies = [
124
124
  "cfg-if",
125
125
  "crossbeam-utils",
@@ -229,7 +229,7 @@ checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55"
229
229
 
230
230
  [[package]]
231
231
  name = "lexical-yjs-html"
232
- version = "0.1.3"
232
+ version = "0.2.1"
233
233
  dependencies = [
234
234
  "serde_json",
235
235
  "yjs-html-core",
@@ -308,9 +308,9 @@ dependencies = [
308
308
 
309
309
  [[package]]
310
310
  name = "once_cell"
311
- version = "1.21.3"
311
+ version = "1.21.4"
312
312
  source = "registry+https://github.com/rust-lang/crates.io-index"
313
- checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
313
+ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
314
314
 
315
315
  [[package]]
316
316
  name = "parking"
@@ -348,7 +348,7 @@ dependencies = [
348
348
 
349
349
  [[package]]
350
350
  name = "prosemirror-yjs-html"
351
- version = "0.1.4"
351
+ version = "0.2.1"
352
352
  dependencies = [
353
353
  "serde_json",
354
354
  "yjs-html-core",
@@ -400,6 +400,27 @@ version = "0.2.3"
400
400
  source = "registry+https://github.com/rust-lang/crates.io-index"
401
401
  checksum = "cca7ad6a7e21e72151d56fe2495a259b5670e204c3adac41ee7ef676ea08117a"
402
402
 
403
+ [[package]]
404
+ name = "rb-sys-test-helpers"
405
+ version = "0.3.0"
406
+ source = "registry+https://github.com/rust-lang/crates.io-index"
407
+ checksum = "ccc637c53cad6a6b49ad51c5857c64e36bd70c955ffafe9dc9cc5bc9dd0b3b18"
408
+ dependencies = [
409
+ "rb-sys",
410
+ "rb-sys-env 0.2.3",
411
+ "rb-sys-test-helpers-macros",
412
+ ]
413
+
414
+ [[package]]
415
+ name = "rb-sys-test-helpers-macros"
416
+ version = "0.3.0"
417
+ source = "registry+https://github.com/rust-lang/crates.io-index"
418
+ checksum = "b1c1fce35d9ac77a2745e539232fb82a75856675a5daf4ef8b1ca994d89f8b3f"
419
+ dependencies = [
420
+ "quote",
421
+ "syn",
422
+ ]
423
+
403
424
  [[package]]
404
425
  name = "redox_syscall"
405
426
  version = "0.5.18"
@@ -628,7 +649,7 @@ checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
628
649
 
629
650
  [[package]]
630
651
  name = "yjs-html-core"
631
- version = "0.1.3"
652
+ version = "0.2.1"
632
653
  dependencies = [
633
654
  "serde_json",
634
655
  "yrs",
@@ -643,6 +664,7 @@ dependencies = [
643
664
  "prosemirror-yjs-html",
644
665
  "rb-sys",
645
666
  "rb-sys-env 0.1.2",
667
+ "rb-sys-test-helpers",
646
668
  "serde_json",
647
669
  "yjs-html-core",
648
670
  "yrs",
@@ -650,9 +672,9 @@ dependencies = [
650
672
 
651
673
  [[package]]
652
674
  name = "yrs"
653
- version = "0.27.2"
675
+ version = "0.28.0"
654
676
  source = "registry+https://github.com/rust-lang/crates.io-index"
655
- checksum = "ccc6a0094c76c87b1b72ca0579bf7aa00f540957182264e6561cbde707c2ce51"
677
+ checksum = "52c70dc8beca8666c77612a96889106ca3cd65318609721f464624ff79685da9"
656
678
  dependencies = [
657
679
  "arc-swap",
658
680
  "async-lock",
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 Lexxy 0.9.x set: paragraphs, headings, every text
342
- format and their combinations, links, the four list types with nesting,
343
- blockquotes, code blocks, tabs and soft breaks, horizontal rules, tables with
344
- header cells, image galleries, and ActionText attachments. Uploads and mentions
345
- both render as `<action-text-attachment>` elements, which ActionText can
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.collaborative_sgid(:body, expires_in: 10.minutes) }
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.collaborative_sgid(:body) } %>
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/ext/yrby/Cargo.toml CHANGED
@@ -12,7 +12,7 @@ crate-type = ["cdylib"]
12
12
  [dependencies]
13
13
  magnus = "0.8"
14
14
  rb-sys = "0.9"
15
- yrs = { version = "0.27", features = ["sync"] }
15
+ yrs = { version = "0.28", features = ["sync"] }
16
16
  serde_json = "1.0"
17
17
  yjs-html-core = { path = "crates/html-core" }
18
18
  lexical-yjs-html = { path = "crates/lexical-html" }
@@ -20,3 +20,5 @@ prosemirror-yjs-html = { path = "crates/prosemirror-html" }
20
20
 
21
21
  [dev-dependencies]
22
22
  rb-sys-env = "0.1"
23
+ magnus = { version = "0.8", features = ["embed"] }
24
+ rb-sys-test-helpers = "0.3"
@@ -3,7 +3,7 @@ name = "yjs-html-core"
3
3
  description = "Internal core for the lexical-yjs-html and prosemirror-yjs-html renderer crates; depend on those instead"
4
4
  repository = "https://github.com/jpcamara/yrby"
5
5
  readme = "README.md"
6
- version = "0.1.3"
6
+ version = "0.2.1"
7
7
  edition = "2024"
8
8
  rust-version = "1.85"
9
9
  authors = ["JP Camara <johnpcamara@gmail.com>"]
@@ -12,5 +12,5 @@ keywords = ["yjs", "crdt", "yrs", "html", "rendering"]
12
12
  categories = ["text-processing", "web-programming"]
13
13
 
14
14
  [dependencies]
15
- yrs = { version = "0.27", features = ["sync"] }
15
+ yrs = { version = "0.28", features = ["sync"] }
16
16
  serde_json = "1.0"
@@ -18,7 +18,7 @@
18
18
  //! every binding and caller.
19
19
 
20
20
  use std::collections::{BTreeMap, BTreeSet, HashMap};
21
- use yrs::{Any, Out, ReadTxn, Xml};
21
+ use yrs::{Any, Number, Out, ReadTxn, Xml};
22
22
 
23
23
  /// One piece of renderer output. `Html` is finished markup; `Deferred` is a
24
24
  /// callback node whose markup the caller supplies after the render, carrying
@@ -216,12 +216,12 @@ pub fn xml_ref_attr<T: ReadTxn, N: Xml>(txn: &T, node: &N, name: &str) -> Option
216
216
  pub fn any_attr_string(any: &Any) -> Option<String> {
217
217
  match any {
218
218
  Any::String(s) => Some(s.to_string()),
219
- Any::Number(n) => Some(if n.fract() == 0.0 {
219
+ Any::Number(Number::Int(n)) => Some(format!("{n}")),
220
+ Any::Number(Number::Float(n)) => Some(if n.fract() == 0.0 {
220
221
  format!("{}", *n as i64)
221
222
  } else {
222
223
  format!("{n}")
223
224
  }),
224
- Any::BigInt(n) => Some(format!("{n}")),
225
225
  Any::Bool(b) => Some(if *b { "true" } else { "false" }.to_string()),
226
226
  _ => None,
227
227
  }
@@ -458,10 +458,41 @@ fn parse_parts(name: &str, parts: &[serde_json::Value]) -> Result<Vec<AttrPart>,
458
458
  .collect()
459
459
  }
460
460
 
461
+ /// The scheme a URL names, lowercased, or `None` for a URL without one (a
462
+ /// path). Browsers ignore whitespace and control characters in a scheme, so
463
+ /// they don't count here either: `java\tscript:` is `javascript`.
464
+ pub fn url_scheme(url: &str) -> Option<String> {
465
+ let url: String = url
466
+ .chars()
467
+ .filter(|c| !c.is_whitespace() && !c.is_control())
468
+ .collect();
469
+ let (scheme, _) = url.split_once(':')?;
470
+ let mut chars = scheme.chars();
471
+ let starts_with_letter = chars.next().is_some_and(|c| c.is_ascii_alphabetic());
472
+ let valid = chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '.' | '-'));
473
+ (starts_with_letter && valid).then(|| scheme.to_ascii_lowercase())
474
+ }
475
+
461
476
  #[cfg(test)]
462
477
  mod tests {
463
478
  use super::*;
464
479
 
480
+ #[test]
481
+ fn url_scheme_reads_a_scheme_as_a_browser_does() {
482
+ assert_eq!(url_scheme("https://example.com").as_deref(), Some("https"));
483
+ assert_eq!(
484
+ url_scheme("JavaScript:alert(1)").as_deref(),
485
+ Some("javascript")
486
+ );
487
+ assert_eq!(
488
+ url_scheme(" java\tscript:alert(1)").as_deref(),
489
+ Some("javascript")
490
+ );
491
+ assert_eq!(url_scheme("/docs/a:b"), None);
492
+ assert_eq!(url_scheme("#top"), None);
493
+ assert_eq!(url_scheme("example.com"), None);
494
+ }
495
+
465
496
  #[test]
466
497
  fn parses_the_compiled_rule_shape() {
467
498
  let rules = Rules::parse(
@@ -3,7 +3,7 @@ name = "lexical-yjs-html"
3
3
  description = "Render Lexical-shaped yrs documents to HTML, no browser or Node required"
4
4
  repository = "https://github.com/jpcamara/yrby"
5
5
  readme = "README.md"
6
- version = "0.1.3"
6
+ version = "0.2.1"
7
7
  edition = "2024"
8
8
  rust-version = "1.85"
9
9
  authors = ["JP Camara <johnpcamara@gmail.com>"]
@@ -12,6 +12,6 @@ keywords = ["yjs", "crdt", "lexical", "html", "yrs"]
12
12
  categories = ["text-processing", "web-programming"]
13
13
 
14
14
  [dependencies]
15
- yrs = { version = "0.27", features = ["sync"] }
15
+ yrs = { version = "0.28", features = ["sync"] }
16
16
  serde_json = "1.0"
17
- yjs-html-core = { path = "../html-core", version = "0.1.3" }
17
+ yjs-html-core = { path = "../html-core", version = "0.2.1" }
@@ -67,8 +67,8 @@ mod readme_examples {}
67
67
  pub use yjs_html_core::*;
68
68
  use yrs::types::text::YChange;
69
69
  use yrs::{
70
- Any, GetString, Map, Out, ReadTxn, Text, Xml, XmlElementRef, XmlFragment, XmlFragmentRef,
71
- XmlOut, XmlTextRef,
70
+ Any, GetString, Map, Number, Out, ReadTxn, Text, Xml, XmlElementRef, XmlFragment,
71
+ XmlFragmentRef, XmlOut, XmlTextRef,
72
72
  };
73
73
 
74
74
  // Lexical text format bitmask (lexical 0.44).
@@ -391,13 +391,11 @@ fn open_block<T: ReadTxn>(
391
391
  push_block_children(txn, t, depth, "</tr>", false, stack);
392
392
  }
393
393
  "tablecell" => {
394
- let header = matches!(
395
- t.get_attribute(txn, "__headerState"),
396
- Some(Out::Any(Any::Number(n))) if n > 0.0
397
- ) || matches!(
398
- t.get_attribute(txn, "__headerState"),
399
- Some(Out::Any(Any::BigInt(n))) if n > 0
400
- );
394
+ let header = match t.get_attribute(txn, "__headerState") {
395
+ Some(Out::Any(Any::Number(Number::Int(n)))) => n > 0,
396
+ Some(Out::Any(Any::Number(Number::Float(n)))) => n > 0.0,
397
+ _ => false,
398
+ };
401
399
  let close = if header {
402
400
  em.push_str("<th>");
403
401
  "</th>"
@@ -562,8 +560,8 @@ fn open_listitem<T: ReadTxn>(
562
560
  rules: &Rules,
563
561
  ) {
564
562
  let value = match t.get_attribute(txn, "__value") {
565
- Some(Out::Any(Any::Number(n))) => n as i64,
566
- Some(Out::Any(Any::BigInt(n))) => n,
563
+ Some(Out::Any(Any::Number(Number::Int(n)))) => n,
564
+ Some(Out::Any(Any::Number(Number::Float(n)))) => n as i64,
567
565
  _ => 1,
568
566
  };
569
567
  let checked = match t.get_attribute(txn, "__checked") {
@@ -669,8 +667,8 @@ fn render_inline<T: ReadTxn>(
669
667
  _ => {
670
668
  is_tab = false;
671
669
  format = match m.get(txn, "__format") {
672
- Some(Out::Any(Any::Number(n))) => n as u32,
673
- Some(Out::Any(Any::BigInt(n))) => n as u32,
670
+ Some(Out::Any(Any::Number(Number::Int(n)))) => n as u32,
671
+ Some(Out::Any(Any::Number(Number::Float(n)))) => n as u32,
674
672
  _ => 0,
675
673
  };
676
674
  style = match m.get(txn, "__style") {
@@ -697,8 +695,15 @@ fn render_inline<T: ReadTxn>(
697
695
  None => render_link(txn, &child, depth, em, rules),
698
696
  }
699
697
  } else if ruled_inline && !is_builtin(&ty) {
700
- if let Some(rule) = rules.nodes.get(ty.as_str()) {
701
- render_rule_inline(txn, &child, &ty, rule, depth, em, rules);
698
+ match rules.nodes.get(ty.as_str()) {
699
+ Some(rule) => render_rule_inline(txn, &child, &ty, rule, depth, em, rules),
700
+ // An unknown inline wrapper, such as a mark with no
701
+ // rule, renders its text without the wrapper, the same
702
+ // way unknown blocks do. Its text is kept.
703
+ None if depth < MAX_INLINE_DEPTH => {
704
+ render_inline(txn, &child, depth + 1, ruled_inline, em, rules);
705
+ }
706
+ None => {}
702
707
  }
703
708
  }
704
709
  // Nested blocks are rendered by their parent block's renderer.
@@ -789,13 +794,24 @@ fn lexxy_style(style: &str) -> Option<String> {
789
794
  if css.is_empty() { None } else { Some(css) }
790
795
  }
791
796
 
797
+ /// The schemes a link may use, and what an editor shows for any other, as
798
+ /// Lexical's `LinkNode` does: a `javascript:` link would run in the page.
799
+ const LINK_SCHEMES: [&str; 5] = ["http", "https", "mailto", "sms", "tel"];
800
+
801
+ fn safe_href(url: String) -> String {
802
+ match url_scheme(&url) {
803
+ Some(scheme) if !LINK_SCHEMES.contains(&scheme.as_str()) => "about:blank".to_string(),
804
+ _ => url,
805
+ }
806
+ }
807
+
792
808
  /// `link` / `autolink`: Lexxy's sanitize keeps only `href` and `title`
793
809
  /// (`target`/`rel` are stored in the doc but stripped from exported HTML).
794
810
  fn render_link<T: ReadTxn>(txn: &T, t: &XmlTextRef, depth: usize, em: &mut Emitter, rules: &Rules) {
795
811
  em.push_str("<a");
796
812
  if let Some(url) = str_attr(txn, t, "__url") {
797
813
  em.push_str(" href=\"");
798
- em.push_str(&escape_attr(&url));
814
+ em.push_str(&escape_attr(&safe_href(url)));
799
815
  em.push('"');
800
816
  }
801
817
  if let Some(title) = str_attr(txn, t, "__title").filter(|s| !s.is_empty()) {
@@ -956,21 +972,39 @@ fn elem_type<T: ReadTxn>(txn: &T, e: &XmlElementRef) -> String {
956
972
  }
957
973
 
958
974
  /// Text-content escaping, matching what the browser's serializer emits:
959
- /// `&`, `<`, `>` escaped; quotes left alone in text.
960
- fn escape_text(s: &str) -> String {
975
+ /// `&`, `<`, `>` escaped; quotes left alone in text. Public for splice
976
+ /// callers. Values read from a deferred segment's attributes come from
977
+ /// collaborators, so escape anything you put into markup.
978
+ pub fn escape_text(s: &str) -> String {
961
979
  s.replace('&', "&amp;")
962
980
  .replace('<', "&lt;")
963
981
  .replace('>', "&gt;")
964
982
  }
965
983
 
966
- /// Attribute-value escaping: text escaping plus `"`.
967
- fn escape_attr(s: &str) -> String {
984
+ /// Attribute-value escaping: text escaping plus `"`. Public for splice
985
+ /// callers, for document values interpolated into attribute positions.
986
+ pub fn escape_attr(s: &str) -> String {
968
987
  escape_text(s).replace('"', "&quot;")
969
988
  }
970
989
 
971
990
  #[cfg(test)]
972
991
  mod tests {
973
992
  use super::*;
993
+
994
+ #[test]
995
+ fn a_link_to_an_unsafe_scheme_goes_nowhere() {
996
+ assert_eq!(
997
+ safe_href("https://example.com".into()),
998
+ "https://example.com"
999
+ );
1000
+ assert_eq!(safe_href("/docs".into()), "/docs");
1001
+ assert_eq!(
1002
+ safe_href("mailto:a@example.com".into()),
1003
+ "mailto:a@example.com"
1004
+ );
1005
+ assert_eq!(safe_href("javascript:alert(1)".into()), "about:blank");
1006
+ assert_eq!(safe_href(" JAVA\tSCRIPT:alert(1)".into()), "about:blank");
1007
+ }
974
1008
  use yrs::updates::decoder::Decode;
975
1009
  use yrs::{Doc, Transact, Update};
976
1010
 
@@ -1140,6 +1174,34 @@ mod tests {
1140
1174
  assert!(html.contains("<p>Before gallery</p>"));
1141
1175
  }
1142
1176
 
1177
+ /// An unknown inline wrapper never drops its text. The fixture is a
1178
+ /// real capture: two collaborating editors typed "Collab #ruby rocks
1179
+ /// and #peer too", the word "rocks" wrapped in @lexical/mark's
1180
+ /// MarkNode and the hashtags as @lexical/hashtag TextNode runs. With
1181
+ /// no rules, the mark's wrapper is lost but "rocks" survives, and
1182
+ /// hashtags render as their text.
1183
+ #[test]
1184
+ fn unknown_inline_wrappers_keep_their_text() {
1185
+ let bytes = include_bytes!("fixtures/mark_hashtag.bin");
1186
+ let doc = Doc::new();
1187
+ doc.transact_mut()
1188
+ .apply_update(Update::decode_v1(bytes).unwrap())
1189
+ .unwrap();
1190
+ let txn = doc.transact();
1191
+ let frag = txn.get_xml_fragment("root").unwrap();
1192
+ let html = render(&txn, &frag).unwrap();
1193
+
1194
+ assert!(
1195
+ html.contains("rocks"),
1196
+ "marked text survives without a rule: {html}"
1197
+ );
1198
+ assert!(
1199
+ !html.contains("<mark"),
1200
+ "no wrapper is invented for it: {html}"
1201
+ );
1202
+ assert!(html.contains("#ruby"), "hashtag text survives: {html}");
1203
+ }
1204
+
1143
1205
  #[test]
1144
1206
  fn format_runs_match_lexxys_export_algorithm() {
1145
1207
  // Singles take their semantic tag; the span for plain text unwraps.
@@ -3,7 +3,7 @@ name = "prosemirror-yjs-html"
3
3
  description = "Render ProseMirror-shaped yrs documents to HTML, no browser or Node required"
4
4
  repository = "https://github.com/jpcamara/yrby"
5
5
  readme = "README.md"
6
- version = "0.1.4"
6
+ version = "0.2.1"
7
7
  edition = "2024"
8
8
  rust-version = "1.85"
9
9
  authors = ["JP Camara <johnpcamara@gmail.com>"]
@@ -12,6 +12,6 @@ keywords = ["yjs", "crdt", "prosemirror", "tiptap", "html"]
12
12
  categories = ["text-processing", "web-programming"]
13
13
 
14
14
  [dependencies]
15
- yrs = { version = "0.27", features = ["sync"] }
15
+ yrs = { version = "0.28", features = ["sync"] }
16
16
  serde_json = "1.0"
17
- yjs-html-core = { path = "../html-core", version = "0.1.3" }
17
+ yjs-html-core = { path = "../html-core", version = "0.2.1" }