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 +4 -4
- data/CHANGELOG.md +90 -0
- data/Cargo.lock +33 -11
- data/README.md +18 -3
- data/ext/yrby/Cargo.toml +3 -1
- data/ext/yrby/crates/html-core/Cargo.toml +2 -2
- data/ext/yrby/crates/html-core/src/lib.rs +34 -3
- data/ext/yrby/crates/lexical-html/Cargo.toml +3 -3
- data/ext/yrby/crates/lexical-html/src/lib.rs +46 -16
- data/ext/yrby/crates/prosemirror-html/Cargo.toml +3 -3
- data/ext/yrby/crates/prosemirror-html/src/lib.rs +30 -5
- data/ext/yrby/src/array.rs +396 -0
- data/ext/yrby/src/lib.rs +945 -36
- data/ext/yrby/src/map.rs +374 -0
- data/ext/yrby/src/read.rs +3 -3
- data/ext/yrby/src/shared.rs +561 -0
- data/ext/yrby/src/text.rs +294 -0
- data/ext/yrby/src/xml_text.rs +749 -0
- data/lib/y/anchor.rb +34 -0
- data/lib/y/collaborative/attribute.rb +36 -0
- data/lib/y/collaborative/helper.rb +13 -10
- data/lib/y/collaborative.rb +14 -11
- data/lib/y/doc.rb +36 -0
- data/lib/y/lexical_markdown.rb +175 -0
- data/lib/y/lexical_writer.rb +142 -0
- data/lib/y/lexxy.rb +25 -0
- data/lib/y/rendering.rb +3 -3
- data/lib/y/text.rb +13 -0
- data/lib/y/version.rb +1 -1
- data/lib/y/xml_text.rb +36 -0
- data/lib/y.rb +6 -0
- metadata +12 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: cc870d86089bc5373cbac29455b1b7818c4a710b4ca1c8c12eeee2939faa8758
|
|
4
|
+
data.tar.gz: 66c41fad450c8e71a23a166bc1f0e6a83def6bdcc94c79724bb63d7691a28f16
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
16
|
+
version = "1.9.3"
|
|
17
17
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
18
|
-
checksum = "
|
|
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
|
|
120
|
+
version = "6.2.1"
|
|
121
121
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
122
|
-
checksum = "
|
|
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
|
|
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.
|
|
311
|
+
version = "1.21.4"
|
|
312
312
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
313
|
-
checksum = "
|
|
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
|
|
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
|
|
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.
|
|
675
|
+
version = "0.28.0"
|
|
654
676
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
655
|
-
checksum = "
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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.
|
|
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(
|
|
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
|
|
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.
|
|
15
|
+
yrs = { version = "0.28", features = ["sync"] }
|
|
16
16
|
serde_json = "1.0"
|
|
17
|
-
yjs-html-core = { path = "../html-core", version = "0.1
|
|
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,
|
|
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 =
|
|
395
|
-
|
|
396
|
-
Some(Out::Any(Any::Number(n)))
|
|
397
|
-
|
|
398
|
-
|
|
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
|
|
566
|
-
Some(Out::Any(Any::
|
|
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::
|
|
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!(
|
|
1171
|
-
|
|
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
|
|
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.
|
|
15
|
+
yrs = { version = "0.28", features = ["sync"] }
|
|
16
16
|
serde_json = "1.0"
|
|
17
|
-
yjs-html-core = { path = "../html-core", version = "0.1
|
|
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,
|
|
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
|
|
878
|
-
Some(Out::Any(Any::
|
|
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;
|