yrby 0.8.1 → 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: ea4b274bb5d8928c48900e3fe08b1e2b5d93c7e9970fe8ee6d453366e2c2c9be
4
- data.tar.gz: 6df2e03ac56e522e1ba854266dd193124514d0fe5f756ad79f1038e4704103b9
3
+ metadata.gz: cc870d86089bc5373cbac29455b1b7818c4a710b4ca1c8c12eeee2939faa8758
4
+ data.tar.gz: 66c41fad450c8e71a23a166bc1f0e6a83def6bdcc94c79724bb63d7691a28f16
5
5
  SHA512:
6
- metadata.gz: e45d38cf54bf43b63a72e0127c89aedd3f5c713a7677afddbdd5c2b4c702af1d755bc1c15fdf22d0f118d9611dc7dd06843efb7ec94cb89d6a898cf6f6d44b30
7
- data.tar.gz: 651268d3b107633e104e346840083bcf5b1ecda00ae3d4586ed89cdb235b8bda34df34c6d60fcde23fdd47b18d0439ff9fca5e36683322799fc66d86157923bb
6
+ metadata.gz: 0725a1a0966f516316041b3d46edcd946783802691d568ed139549fbc120515b809ca368784f23f522bad7e0a4f24abb46951985ff0935627b1733d2e3b47cd3
7
+ data.tar.gz: 6fcf5914510b36d10fe3bf344725cb151f945c57df95b692cdd5a4344ceeba2e81ed6455a0ec3b4071ae5a9c569de4bc0ae3bea9aa6b40b399594c296fb71a0b
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/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.4"
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.5"
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
 
@@ -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/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.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", "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") {
@@ -796,13 +794,24 @@ fn lexxy_style(style: &str) -> Option<String> {
796
794
  if css.is_empty() { None } else { Some(css) }
797
795
  }
798
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
+
799
808
  /// `link` / `autolink`: Lexxy's sanitize keeps only `href` and `title`
800
809
  /// (`target`/`rel` are stored in the doc but stripped from exported HTML).
801
810
  fn render_link<T: ReadTxn>(txn: &T, t: &XmlTextRef, depth: usize, em: &mut Emitter, rules: &Rules) {
802
811
  em.push_str("<a");
803
812
  if let Some(url) = str_attr(txn, t, "__url") {
804
813
  em.push_str(" href=\"");
805
- em.push_str(&escape_attr(&url));
814
+ em.push_str(&escape_attr(&safe_href(url)));
806
815
  em.push('"');
807
816
  }
808
817
  if let Some(title) = str_attr(txn, t, "__title").filter(|s| !s.is_empty()) {
@@ -981,6 +990,21 @@ pub fn escape_attr(s: &str) -> String {
981
990
  #[cfg(test)]
982
991
  mod tests {
983
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
+ }
984
1008
  use yrs::updates::decoder::Decode;
985
1009
  use yrs::{Doc, Transact, Update};
986
1010
 
@@ -1167,8 +1191,14 @@ mod tests {
1167
1191
  let frag = txn.get_xml_fragment("root").unwrap();
1168
1192
  let html = render(&txn, &frag).unwrap();
1169
1193
 
1170
- assert!(html.contains("rocks"), "marked text survives without a rule: {html}");
1171
- assert!(!html.contains("<mark"), "no wrapper is invented for it: {html}");
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
+ );
1172
1202
  assert!(html.contains("#ruby"), "hashtag text survives: {html}");
1173
1203
  }
1174
1204
 
@@ -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.5"
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" }
@@ -52,8 +52,8 @@ pub use yjs_html_core::*;
52
52
  use yrs::types::Attrs;
53
53
  use yrs::types::text::YChange;
54
54
  use yrs::{
55
- Any, GetString, Out, ReadTxn, Text, Xml, XmlElementRef, XmlFragment, XmlFragmentRef, XmlOut,
56
- XmlTextRef,
55
+ Any, GetString, Number, Out, ReadTxn, Text, Xml, XmlElementRef, XmlFragment, XmlFragmentRef,
56
+ XmlOut, XmlTextRef,
57
57
  };
58
58
 
59
59
  // The block tree is walked on a heap stack, so depth can't overflow the
@@ -807,16 +807,25 @@ fn hex_to_rgb(value: &str) -> Option<String> {
807
807
  Some(format!("rgb({r}, {g}, {b})"))
808
808
  }
809
809
 
810
+ /// The schemes a link may use, as Tiptap's link mark allows them. It
811
+ /// renders a link with any other scheme with an empty `href`, since a
812
+ /// `javascript:` link would run in the page.
813
+ const LINK_SCHEMES: [&str; 10] = [
814
+ "http", "https", "ftp", "ftps", "mailto", "tel", "callto", "sms", "cid", "xmpp",
815
+ ];
816
+
810
817
  /// `<a>` with Tiptap's attribute order (target, rel, class, href, title),
811
818
  /// skipping any that are absent or null.
812
819
  fn link_open(link: &std::collections::HashMap<String, Any>) -> String {
813
820
  let mut out = String::from("<a");
814
821
  for key in ["target", "rel", "class", "href", "title"] {
815
822
  if let Some(Any::String(v)) = link.get(key) {
823
+ let unsafe_href = key == "href"
824
+ && url_scheme(v).is_some_and(|scheme| !LINK_SCHEMES.contains(&scheme.as_str()));
816
825
  out.push(' ');
817
826
  out.push_str(key);
818
827
  out.push_str("=\"");
819
- out.push_str(&escape_attr(v));
828
+ out.push_str(&escape_attr(if unsafe_href { "" } else { v }));
820
829
  out.push('"');
821
830
  }
822
831
  }
@@ -874,8 +883,8 @@ fn str_attr<T: ReadTxn>(txn: &T, e: &XmlElementRef, name: &str) -> Option<String
874
883
 
875
884
  fn num_attr<T: ReadTxn>(txn: &T, e: &XmlElementRef, name: &str) -> Option<i64> {
876
885
  match e.get_attribute(txn, name) {
877
- Some(Out::Any(Any::Number(n))) => Some(n as i64),
878
- Some(Out::Any(Any::BigInt(n))) => Some(n),
886
+ Some(Out::Any(Any::Number(Number::Int(n)))) => Some(n),
887
+ Some(Out::Any(Any::Number(Number::Float(n)))) => Some(n as i64),
879
888
  _ => None,
880
889
  }
881
890
  }
@@ -899,6 +908,22 @@ pub fn escape_attr(s: &str) -> String {
899
908
  #[cfg(test)]
900
909
  mod tests {
901
910
  use super::*;
911
+
912
+ #[test]
913
+ fn a_link_to_an_unsafe_scheme_has_an_empty_href() {
914
+ let link = |href: &str| {
915
+ link_open(&std::collections::HashMap::from([(
916
+ "href".to_string(),
917
+ Any::from(href),
918
+ )]))
919
+ };
920
+ assert_eq!(
921
+ link("https://example.com"),
922
+ r#"<a href="https://example.com">"#
923
+ );
924
+ assert_eq!(link("ftp://example.com"), r#"<a href="ftp://example.com">"#);
925
+ assert_eq!(link("javascript:alert(1)"), r#"<a href="">"#);
926
+ }
902
927
  use std::collections::HashMap;
903
928
  use std::sync::Arc;
904
929
  use yrs::updates::decoder::Decode;