1 files changed,
+220,
-376
+220,
-376
1@@ -2,207 +2,93 @@
2
3 Go bindings for the Rust [y-crdt](https://github.com/y-crdt/y-crdt) library, providing CRDT (Conflict-free Replicated Data Types) functionality for building collaborative applications.
4
5-## Overview
6+## Features
7
8-This library provides Go bindings to y-crdt's Yjs-compatible CRDT implementation via CGO. It enables real-time collaborative editing with support for:
9+- **Yjs CRDT Bindings** - Go bindings to the Rust y-crdt library for manipulating Yjs documents (Text, Array, Map, XML types)
10+- **y-sweet Integration** - Sync documents in real-time with [jamsocket/y-sweet](https://github.com/jamsocket/y-sweet) servers via WebSocket
11+- **Document Hooks** - React to changes with `OnUpdate` callbacks for document modifications
12+- **Proxy Hooks** - Intercept and act on connections when using ygo as a WebSocket proxy to y-sweet
13
14-- **Documents** - The core unit of collaborative state
15-- **Text** - Rich text with formatting and attributes
16-- **Arrays** - Ordered collections with move operations
17-- **Maps** - Key-value stores with nested types
18-- **XML** - XML fragment and element types
19-- **Transactions** - Atomic read/write operations
20-- **Updates** - State synchronization between peers
21-- **Undo/Redo** - Operation history management
22-
23-**Resource Management:** All types (`Doc`, `Text`, `Array`, `Map`, `XmlFragment`, etc.) require explicit cleanup. Always use `defer obj.Destroy()` when creating these objects to prevent memory leaks. See the Quick Start example below.
24-
25-## Installation
26-
27-```bash
28-go get github.com/BTBurke/ygo
29-```
30-
31-## Building
32-
33-### Prerequisites
34+## Prerequisites
35
36 - Go 1.22 or later
37 - C compiler (gcc or clang)
38
39-The library includes pre-built static libraries for Linux and FreeBSD on amd64 and arm64. No Rust toolchain is required for normal use.
40+The library uses Cgo and includes pre-built static libraries for Linux and FreeBSD on amd64 and arm64. No Rust toolchain is required for normal use.
41
42-### Linux / FreeBSD
43+## Creating Documents
44
45-**Simple usage (using pre-built libraries):**
46-
47-```bash
48-go get github.com/BTBurke/ygo
49-go build .
50-```
51+### New Document
52
53-**For static linking (recommended for distribution):**
54+```go
55+doc, _ := ygo.NewDoc()
56+// Cgo bindings require manually freeing memory (sorry!)
57+defer doc.Destroy()
58
59-```bash
60-CGO_ENABLED=1 go build -ldflags '-linkmode external -extldflags "-static"' .
61-```
62+txt, _ := doc.GetText("content")
63+defer txt.Destroy()
64
65-For a completely static binary (no dynamic library dependencies):
66-```bash
67-CGO_ENABLED=1 go build -ldflags '-linkmode external -extldflags "-static -ldl -lm"' .
68+_ = doc.WithWriteTransaction(func(txn *ygo.Transaction) error {
69+ txt.Insert(txn, 0, "Hello, world!")
70+ return nil
71+})
72 ```
73
74-### Building from Source (optional)
75-
76-If you need to build the Rust library from source (e.g., for a different platform or to apply patches):
77-
78-1. **Install Rust toolchain:**
79- ```bash
80- # TODO: need to install devbox or have rustup available
81- devbox shell
82- ```
83-
84-2. **Clone with submodules and build:**
85- ```bash
86- git clone --recursive https://github.com/BTBurke/ygo
87- cd ygo
88- make libyrs-local # Builds for current platform, overwrites prebuilt
89- go build .
90- ```
91-
92-3. **Cross-compile for all supported platforms:**
93- ```bash
94- make libyrs-cross # Builds for linux-amd64, freebsd-amd64
95- ```
96-
97-## Quick Start
98+### Create a document from a Yjs V1 Update
99
100 ```go
101-package main
102-
103-import (
104- "fmt"
105- "log"
106-
107- "github.com/BTBurke/ygo"
108-)
109+data, _ := os.ReadFile("document.yjs")
110
111-func main() {
112- // Create a new document
113- doc, err := ygo.NewDoc()
114- if err != nil {
115- log.Fatal(err)
116- }
117- defer doc.Destroy()
118+doc, _ := ygo.NewDoc()
119+defer doc.Destroy()
120
121- // Get or create a text field
122- txt, err := doc.GetText("content")
123- if err != nil {
124- log.Fatal(err)
125- }
126- defer txt.Destroy()
127+_ = doc.UnmarshalBinary(data)
128+```
129
130- // Insert text within a transaction
131- err = doc.WithWriteTransaction(func(txn *ygo.Transaction) error {
132- txt.Insert(txn, 0, "Hello, collaborative world!")
133- return nil
134- })
135- if err != nil {
136- log.Fatal(err)
137- }
138+### Create a document from the y-sweet format (similar to Yjs V1 format, but tracks changes)
139
140- // Read the text back
141- var content string
142- err = doc.WithReadTransaction(func(txn *ygo.Transaction) error {
143- var err error
144- content, err = txt.String(txn)
145- return err
146- })
147- if err != nil {
148- log.Fatal(err)
149- }
150+```go
151+// From file
152+doc, _ := ygo.NewDocFromYSweetFile("data.ysweet")
153+defer doc.Destroy()
154
155- fmt.Println(content) // "Hello, collaborative world!"
156-}
157+// From reader
158+doc, _ := ygo.NewDocFromYSweet(reader)
159+defer doc.Destroy()
160 ```
161
162-## Convenience Features
163+## Transactions
164
165-### Typed Map Getters
166+When you want to make changes to a Y document, we offer two APIs:
167
168-Get typed values from maps without manual type checking:
169+### Callback Pattern (Atomic)
170
171-```go
172-// Instead of verbose Output handling:
173-// out, _ := m.Get(txn, "name")
174-// defer out.Destroy()
175-// str, _ := out.String()
176-
177-// Use convenience getters:
178-name, _ := m.GetString(txn, "name")
179-age, _ := m.GetInt(txn, "age")
180-pi, _ := m.GetFloat(txn, "pi")
181-active, _ := m.GetBool(txn, "active")
182-```
183-
184-### Map/Array Iteration
185-
186-Iterate with callbacks instead of manual iterator management:
187+For simple changes, callback pattern automatically creates a transaction for you. The underlying library has no concept of rollbacks, so a failed insert, push, etc. may leave you in an indeterminate state.
188
189 ```go
190-// Map iteration
191-err = m.ForEach(txn, func(key string, value *ygo.Output) error {
192- // Process each entry
193- // Return error to stop iteration early
194- return nil
195-})
196-
197-// Array iteration
198-err = arr.ForEach(txn, func(index uint32, value *ygo.Output) error {
199- // Process each element
200+_ = doc.WithWriteTransaction(func(txn *ygo.Transaction) error {
201+ txt.Insert(txn, 0, "Hello")
202+ arr.Push(txn, ygo.String("item"))
203+ m.Insert(txn, "key", ygo.Int(42))
204 return nil
205 })
206 ```
207
208-### Map Input from Go Maps
209-
210-Create inputs from Go maps instead of parallel slices:
211-
212 ```go
213-// Old: parallel slices (error-prone)
214-// ygo.YMap([]string{"name", "age"}, []ygo.Input{ygo.String("Alice"), ygo.Int(30)})
215-
216-// New: Go map syntax
217-data := map[string]ygo.Input{
218- "name": ygo.String("Alice"),
219- "age": ygo.Int(30),
220-}
221-input, _ := ygo.MapInput(data)
222-m.Insert(txn, "user", input)
223+_ = doc.WithReadTransaction(func(txn *ygo.Transaction) error {
224+ content, _ := txt.String(txn)
225+ length := arr.Len()
226+ return nil
227+})
228 ```
229
230-### Explicit Transactions
231+### Explicit Pattern (Flat)
232
233-Use explicit Begin/Commit for flatter code with less nesting:
234+Use when you want flatter code with manual control. Rollback and Commit are basically the same thing, don't assume that rollback fixes anything. This is a limitation of the underlying Rust library. Open an issue if this is a problem; the fix is to snapshot it before an attempted transaction and roll back to it.
235
236 ```go
237-// Old: Callback-based API (nested)
238-err = doc.WithWriteTransaction(func(txn *ygo.Transaction) error {
239- txt.Insert(txn, 0, "Hello")
240- arr.Push(txn, ygo.Int(42))
241- return nil
242-})
243-
244-// New: Explicit transaction API (flatter)
245-txn, err := doc.BeginWrite()
246-if err != nil {
247- return err
248-}
249+txn, _ := doc.BeginWrite()
250 defer func() {
251- if r := recover(); r != nil {
252- txn.Rollback()
253- panic(r)
254- }
255 if err != nil {
256 txn.Rollback()
257 } else {
258@@ -210,262 +96,239 @@ defer func() {
259 }
260 }()
261
262-// Perform operations (no nesting!)
263 txt.Insert(txn, 0, "Hello")
264-arr.Push(txn, ygo.Int(42))
265+arr.Push(txn, ygo.String("item"))
266+```
267
268-// Read transactions work similarly
269-txn, err = doc.BeginRead()
270-if err != nil {
271- return err
272-}
273+```go
274+txn, _ := doc.BeginRead()
275 defer txn.Commit()
276
277 length := arr.Len()
278 content, _ := txt.String(txn)
279 ```
280
281-The explicit API gives you full control over transaction lifecycle:
282-- **BeginRead()** → **Commit()** for read-only transactions
283-- **BeginWrite()** → **Commit()** or **Rollback()** for write transactions
284-- **BeginWriteWithOrigin(origin)** to mark changes with a source identifier
285-
286-**Note on Rollback:** The y-crdt library applies changes immediately during the transaction. Calling `Rollback()` only closes the transaction without syncing to remote peers - the local document state is already modified. For true atomic operations that automatically rollback on error, use `WithWriteTransaction()` which only commits if the callback succeeds.
287-
288-### Breaking API Changes (v0.x → v1.0)
289-
290-**Len() Methods Simplified:**
291+### With Origin Markers
292
293-All `Len()` methods have been updated for consistency:
294+Track change sources:
295
296 ```go
297-// Old API - returned (uint32, error)
298-length, err := arr.Len()
299-if err != nil {
300- return err
301-}
302-
303-// New API - returns just uint32, panics on nil (like Go's len())
304-length := arr.Len() // Array.Len() - no txn needed
305-length := m.Len(txn) // Map.Len() - txn still required
306-length := txt.Len(txn) // Text.Len() - txn still required
307+_ = doc.WithWriteTransactionWithOrigin([]byte("user-edit"), func(txn *ygo.Transaction) error {
308+ txt.Insert(txn, 0, "Hello")
309+ return nil
310+})
311+
312+txn, _ := doc.BeginWriteWithOrigin([]byte("system"))
313+defer txn.Commit()
314 ```
315
316-**Error Handling - Nil Checks Now Panic:**
317+## Working with Data Types
318
319-Methods now panic on programming errors (nil pointers) instead of returning errors:
320+### Text
321
322 ```go
323-// Old API
324-length, err := arr.Len()
325-if err != nil {
326- // Handle nil array error
327-}
328-
329-// New API - panics like Go's built-in len()
330-var arr *ygo.Array
331-length := arr.Len() // panic: "ygo: Array.Len called on nil Array"
332+txt, _ := doc.GetText("content")
333+defer txt.Destroy()
334+
335+_ = doc.WithWriteTransaction(func(txn *ygo.Transaction) error {
336+ txt.Insert(txn, 0, "Hello, ")
337+ txt.Insert(txn, 7, "world!")
338+ txt.RemoveRange(txn, 0, 7)
339+ length := txt.Len(txn)
340+ content, _ := txt.String(txn)
341+ return nil
342+})
343 ```
344
345-This aligns with Go best practices where programming errors (nil dereferences) should fail fast. Only runtime errors (key not found, index out of bounds) return errors.
346+### Array
347
348-## Serialization
349+```go
350+arr, _ := doc.GetArray("items")
351+defer arr.Destroy()
352+
353+_ = doc.WithWriteTransaction(func(txn *ygo.Transaction) error {
354+ arr.Push(txn, ygo.String("first"))
355+ arr.Insert(txn, 0, ygo.Int(42))
356+ length := arr.Len()
357+ return nil
358+})
359+```
360
361-Documents support standard Go binary marshaling:
362+### Map
363
364 ```go
365-// Serialize document to bytes
366-data, err := doc.MarshalBinary()
367-if err != nil {
368- log.Fatal(err)
369-}
370+m, _ := doc.GetMap("data")
371+defer m.Destroy()
372
373-// Save to file or send over network
374-os.WriteFile("document.yjs", data, 0644)
375+_ = doc.WithWriteTransaction(func(txn *ygo.Transaction) error {
376+ m.Insert(txn, "name", ygo.String("Alice"))
377+ m.Insert(txn, "count", ygo.Int(10))
378
379-// Later, load it back
380-data, _ := os.ReadFile("document.yjs")
381-doc2, _ := ygo.NewDoc()
382-txt2, _ := doc2.GetText("content")
383-if err := doc2.UnmarshalBinary(data); err != nil {
384- log.Fatal(err)
385-}
386+ name, _ := m.GetString(txn, "name")
387+ count, _ := m.GetInt(txn, "count")
388+ return nil
389+})
390 ```
391
392-## Collaborative Updates
393-
394-Synchronize documents between clients:
395+### Iteration
396
397 ```go
398-// Get current state vector
399-var sv1 *ygo.StateVector
400-err = doc1.WithReadTransaction(func(txn *ygo.Transaction) error {
401- sv1 = txn.GetStateVector()
402+_ = m.ForEach(txn, func(key string, value *ygo.Output) error {
403+ // Process entry
404 return nil
405 })
406
407-// Get state diff (updates needed by remote)
408-var diff *ygo.Update
409-err = doc1.WithWriteTransaction(func(txn *ygo.Transaction) error {
410- diff = txn.GetStateDiff(sv2) // sv2 is remote's state vector
411+_ = arr.ForEach(txn, func(index uint32, value *ygo.Output) error {
412+ // Process element
413 return nil
414 })
415-
416-// Apply diff on remote
417-doc2.WithWriteTransaction(func(txn *ygo.Transaction) error {
418- return txn.ApplyUpdate(diff)
419-})
420 ```
421
422-## Real-Time Sync with y-sweet Server
423+## Sync to y-sweet Server
424
425-For production collaborative editing, connect to a y-sweet server with explicit lifecycle management:
426+### Simple Sync
427
428 ```go
429-// Create document
430-doc, err := ygo.NewDoc()
431-if err != nil {
432- log.Fatal(err)
433-}
434-defer doc.Destroy()
435+ctx, cancel := context.WithCancel(context.Background())
436+defer cancel()
437+
438+_ = doc.Sync(ctx,
439+ ygo.WithSyncEndpoint("wss://y-sweet.example.com/doc/my-doc"),
440+ ygo.WithSyncAuthToken("token"),
441+ ygo.WithOnUpdate(func(d *ygo.Doc) error {
442+ // React to remote changes
443+ return nil
444+ }),
445+)
446+```
447+
448+### With Additional Lifecycle Hooks
449+
450+```go
451+doc, _ = ygo.NewDoc()
452
453-// Create sync client (does not connect yet)
454-client, err := ygo.NewSyncClient(doc,
455+client, _ := ygo.NewSyncClient(doc,
456 ygo.WithSyncEndpoint("wss://y-sweet.example.com/doc/my-doc"),
457- ygo.WithSyncAuthToken("my-token"),
458+ ygo.WithSyncAuthToken("token"),
459 )
460-if err != nil {
461- log.Fatal(err)
462-}
463
464-// Set up event handlers
465 client.OnConnect(func(doc *ygo.Doc, stats ygo.SyncStats) error {
466 log.Printf("Connected with %d peers", stats.PeerCount)
467 return nil
468 })
469
470-client.OnDisconnect(func(doc *ygo.Doc, stats ygo.SyncStats) error {
471- log.Printf("Disconnected - %d peers remaining", stats.PeerCount)
472- // Note: doc is NOT destroyed here - you may want to reconnect
473+client.OnDisconnect(func(doc *ygo.Doc, stats ygo.SyncStats, reason ygo.DisconnectReason) error {
474+ log.Printf("Disconnected: %v", reason)
475 return nil
476 })
477
478-client.OnUpdateCtx(func(ctx context.Context, doc *ygo.Doc, stats ygo.SyncStats) error {
479- // React to changes from other clients
480- log.Printf("Update received, pending: %v", stats.PendingUpdate)
481- return processRemoteChanges(ctx, doc)
482+client.OnUpdate(func(doc *ygo.Doc, stats ygo.SyncStats) error {
483+ // Handle remote update
484+ return nil
485 })
486
487-// Connect in a goroutine with retry logic
488-var connectErr error
489-var wg sync.WaitGroup
490-wg.Add(1)
491-
492-go func() {
493- defer wg.Done()
494-
495- for {
496- ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
497- err := client.Connect(ctx)
498- cancel()
499-
500- if err == nil {
501- // Connected successfully - wait for disconnect
502- <-client.Done()
503-
504- // Check if we should reconnect or shut down
505- select {
506- case <-shutdownChan:
507- return // Shutting down, don't reconnect
508- default:
509- log.Println("Disconnected, will retry...")
510- time.Sleep(5 * time.Second)
511- continue
512- }
513- }
514-
515- // Connection failed
516- connectErr = err
517- log.Printf("Connection failed: %v", err)
518-
519- select {
520- case <-shutdownChan:
521- return // Shutting down
522- case <-time.After(5 * time.Second):
523- // Retry connection
524- continue
525- }
526- }
527-}()
528+_ = client.Connect(ctx)
529+```
530
531-// Monitor connection stats
532-go func() {
533- ticker := time.NewTicker(30 * time.Second)
534- defer ticker.Stop()
535-
536- for {
537- select {
538- case <-ticker.C:
539- stats := client.Stats()
540- log.Printf("Status: %v, Peers: %d, Pending: %v",
541- stats.Status, stats.PeerCount, stats.PendingUpdate)
542- case <-shutdownChan:
543- return
544- }
545- }
546-}()
547+## y-sweet API Client
548
549-// Run for some time, then shutdown
550-select {
551-case <-time.After(1 * time.Hour):
552-case <-signalChan: // Handle SIGTERM/SIGINT
553-}
554+The ysweet package contains a client for interacting with an upstream y-sweet server. This is needed when you need to create new documents and generate authentication credentials for the y-sweet Yjs sync provider.
555
556-// Graceful shutdown
557-close(shutdownChan)
558+```go
559+import "github.com/BTBurke/ygo/ysweet"
560
561-// Flush pending updates before closing
562-flushCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
563-defer cancel()
564+client, _ := ysweet.NewClient("http://localhost:8080")
565
566-if err := client.Flush(flushCtx); err != nil {
567- log.Printf("Flush failed: %v", err)
568-}
569+// Create new document
570+docID, _ := client.NewDoc("")
571
572-// Close the connection
573-if err := client.Close(); err != nil {
574- log.Printf("Close error: %v", err)
575-}
576+// Or with specific ID
577+docID, _ := client.NewDoc("my-document")
578
579-// Wait for connection goroutine to finish
580-wg.Wait()
581+// Get authentication for sync
582+auth, _ := client.AuthDoc(docID)
583+// you can return this auth struct in JSON to configure the y-sweet Yjs provider
584
585-// Finally destroy the document when completely done
586-// (This is your responsibility - never automatic)
587-doc.Destroy()
588+//use it to configure a Go client to connect to the y-sweet server
589+wsURL := auth.WebsocketURL()
590 ```
591
592-### Sync Client Events
593+## Proxy Server
594
595-- **OnConnect(doc, stats)**: Called when successfully connected to server
596-- **OnDisconnect(doc, stats)**: Called when connection closes (reconnect possible)
597-- **OnUpdate(doc, stats)**: Called when document receives changes from remote peers
598-- **OnUpdateCtx(ctx, doc, stats)**: Context-aware variant for cancellation/deadlines
599+This library provides a proxy server that you can put in front of y-sweet to proxy the websocket connections from a Yjs document. The advantage of this is that you don't need to protect server-only routes on the y-sweet server that are meant to create documents and generate authorization credentials. It also adds hooks so you can also react to connection/disconnection events when Yjs clients connect to your y-sweet server.
600
601-### Connection Statistics
602+### Default URL Pattern
603
604-The `Stats()` method returns:
605-- `PeerCount`: Number of other clients connected (via awareness protocol)
606-- `LastUpdate`: Timestamp of last received remote update
607-- `PendingUpdate`: True if local changes haven't been synced yet
608-- `Status`: Current connection state (Disconnected, Connecting, Connected, Disconnecting)
609+The default y-sweet routes returns URLs like /d/{docID}/ws/{docID}. You can optionally change this, see the custom URL scheme below.
610
611-### Important: Document Lifecycle
612+```go
613+handler := ysweet.ProxyHandler("ws://upstream:8080",
614+ ysweet.WithTargetAuthToken("secret"),
615+ ysweet.WithOnConnect(func(ctx context.Context, docID string, w http.ResponseWriter, r *http.Request) error {
616+ // Validate connection
617+ return nil
618+ }),
619+ ysweet.WithOnDisconnect(func(ctx context.Context, docID string, reason ysweet.DisconnectReason) error {
620+ // Cleanup on disconnect
621+ return nil
622+ }),
623+)
624+http.Handle("/d/", handler)
625+```
626
627-The sync client **never** destroys the document automatically, even on errors or disconnect. You must explicitly call `doc.Destroy()` when completely done. This allows you to:
628-- Retry connections on network hiccups
629-- Switch servers without losing data
630-- Queue updates while offline and sync when reconnected
631+### Custom URL Scheme
632+
633+When you want a custom URL scheme, provide a function to extract the document ID. Internally, this is changed to the URL scheme that y-sweet expects.
634+
635+```go
636+handler := ysweet.ProxyHandler("ws://upstream:8080",
637+ ysweet.WithDocIDFunc(func(r *http.Request) string {
638+ // Extract docID from custom URL: /ws/{docID}
639+ return r.PathValue("docID")
640+ }),
641+)
642+http.Handle("/ws/{docID}", handler)
643+```
644+
645+## Serialization
646+
647+Implements the binary encoding interfaces using the Yjs V1 format.
648+
649+```go
650+// Serialize
651+data, _ := doc.MarshalBinary()
652+os.WriteFile("document.yjs", data, 0644)
653+
654+// Deserialize
655+data, _ := os.ReadFile("document.yjs")
656+doc2, _ := ygo.NewDoc()
657+_ = doc2.UnmarshalBinary(data)
658+```
659+
660+## State Vectors and Updates
661+
662+```go
663+// Get state vector
664+var sv *ygo.StateVector
665+_ = doc.WithReadTransaction(func(txn *ygo.Transaction) error {
666+ sv = txn.GetStateVector()
667+ return nil
668+})
669+
670+// Get diff for remote
671+doc.WithReadTransaction(func(txn *ygo.Transaction) error {
672+ // this is the same as the Yjs function encodeStateAsUpdate V1 algorithm
673+ diff := txn.GetStateDiff(sv)
674+ // Send diff to remote
675+ return nil
676+})
677+
678+// Apply update from remote
679+_ = doc.WithWriteTransaction(func(txn *ygo.Transaction) error {
680+ update := ygo.UpdateFromBytes(data)
681+ return txn.ApplyUpdate(update)
682+})
683+```
684
685 ## Platform Support
686
687@@ -473,45 +336,26 @@ Pre-built static libraries are included for:
688
689 | Platform | Architecture | Status |
690 |----------|--------------|--------|
691-| Linux | amd64, arm64 | ✅ Pre-built libraries included |
692-| FreeBSD | amd64, arm64 | ✅ Pre-built libraries included |
693-
694-The library automatically selects the correct pre-built library based on your platform. Cross-compilation is not supported - build on the target platform or use the provided pre-built libraries.
695+| Linux | amd64 | ✅ Included |
696+| FreeBSD | amd64 | ✅ Included |
697
698 ## Updating the Native Library (for maintainers)
699
700-To update the pre-built libraries to the latest y-crdt release:
701-
702 ```bash
703 # Update submodule and version constant
704 make update-yffi
705
706-# Build for all supported platforms (requires Rust toolchain)
707+# Build for all supported platforms
708 make libyrs-cross
709
710-# The lib/ directory now contains updated libraries for all platforms
711-# Commit the changes to lib/ directory
712-```
713-
714-For local development (overwrites pre-built library for current platform):
715-
716-```bash
717+# For local development (current platform only)
718 make libyrs-local
719-go build .
720 ```
721
722 ## License
723
724 MIT License - See [LICENSE](LICENSE) for details.
725
726-## Contributing
727-
728-Contributions are welcome! Please ensure:
729-
730-1. Code follows existing patterns and conventions
731-2. Tests pass: `go test ./...`
732-3. Examples build: `cd examples && go build .`
733-
734 ## Acknowledgments
735
736-This project is a Go wrapper around the excellent [y-crdt](https://github.com/y-crdt/y-crdt) Rust library by the Yjs team. All CRDT logic and algorithms are implemented in y-crdt; ygo provides the Go bindings and API layer.
737+This project is a Go wrapper around [y-crdt](https://github.com/y-crdt/y-crdt) Rust library by the Yjs team. All CRDT logic and algorithms are implemented in y-crdt; ygo provides the Go bindings and API layer.