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 +4 -4
- data/CHANGELOG.md +121 -0
- data/Cargo.lock +33 -11
- data/README.md +31 -9
- 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 +82 -20
- data/ext/yrby/crates/prosemirror-html/Cargo.toml +3 -3
- data/ext/yrby/crates/prosemirror-html/src/lib.rs +37 -8
- 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 +27 -2
- data/lib/y/rendering.rb +27 -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,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.
|
|
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
|
|
|
@@ -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
|
|
342
|
-
format and their combinations, links, the
|
|
343
|
-
blockquotes, code blocks, tabs and soft breaks,
|
|
344
|
-
header cells, image galleries, and ActionText
|
|
345
|
-
both render as `<action-text-attachment>`
|
|
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.
|
|
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.
|
|
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.
|
|
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") {
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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('&', "&")
|
|
962
980
|
.replace('<', "<")
|
|
963
981
|
.replace('>', ">")
|
|
964
982
|
}
|
|
965
983
|
|
|
966
|
-
/// Attribute-value escaping: text escaping plus `"`.
|
|
967
|
-
|
|
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('"', """)
|
|
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
|
|
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" }
|