prosemirror-rs 0.3.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.
package/Cargo.toml ADDED
@@ -0,0 +1,27 @@
1
+ [package]
2
+ name = "prosemirror-rs-node"
3
+ version = "0.3.0"
4
+ authors = [
5
+ "Johannes Wilm <johannes@fiduswriter.org>",
6
+ "Daniel Seiler <me@dseiler.eu>",
7
+ ]
8
+ edition = "2021"
9
+ license = "MIT"
10
+ description = "Node.js bindings for prosemirror-rs"
11
+ repository = "https://github.com/fiduswriter/prosemirror-rs"
12
+ homepage = "https://github.com/fiduswriter/prosemirror-rs"
13
+ # This crate is published via npm/napi-rs, not crates.io
14
+ publish = false
15
+
16
+ [lib]
17
+ name = "prosemirror_rs"
18
+ crate-type = ["cdylib"]
19
+
20
+ [dependencies]
21
+ napi = { version = "2", features = ["napi4"] }
22
+ napi-derive = "2"
23
+ prosemirror = { path = ".." }
24
+ serde_json = "1"
25
+
26
+ [build-dependencies]
27
+ napi-build = "2"
package/README.md ADDED
@@ -0,0 +1,129 @@
1
+ # prosemirror-rs — Node.js bindings
2
+
3
+ Node.js bindings for [`prosemirror`](https://crates.io/crates/prosemirror), a Rust
4
+ implementation of [ProseMirror](https://prosemirror.net)'s document model and
5
+ transform pipeline.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ npm install prosemirror-rs
11
+ ```
12
+
13
+ ## Design goals
14
+
15
+ - **Zero unnecessary copies.** The schema and document live entirely in Rust
16
+ memory. Only JSON strings cross the JavaScript/Rust boundary.
17
+ - **Wire-efficient.** Steps arriving as JSON (e.g. from a WebSocket) can be
18
+ passed directly to `applyStepsJson()` without any JS-level parsing.
19
+ - **Database-efficient.** `docJson()` serializes the document in Rust and
20
+ returns a plain JS `string`, ready to write to a database with no
21
+ intermediate objects.
22
+
23
+ ## Quick start
24
+
25
+ ```js
26
+ const { Editor } = require('prosemirror-rs');
27
+
28
+ const schemaJson = JSON.stringify({
29
+ nodes: {
30
+ doc: { content: 'paragraph+' },
31
+ paragraph: { content: 'text*', group: 'block' },
32
+ text: { group: 'inline' },
33
+ },
34
+ marks: { strong: {}, em: {} },
35
+ });
36
+
37
+ const docJson = JSON.stringify({
38
+ type: 'doc',
39
+ content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Hello' }] }],
40
+ });
41
+
42
+ const editor = new Editor(schemaJson, docJson);
43
+ console.log(editor.version); // 0
44
+
45
+ // Typical server loop: steps arrive as raw JSON from a WebSocket client
46
+ wss.on('message', (raw) => {
47
+ const data = JSON.parse(raw); // parse envelope only
48
+ const ok = editor.applyStepsJson( // steps stay as JSON string
49
+ JSON.stringify(data.steps)
50
+ );
51
+ if (ok) {
52
+ db.execute('UPDATE docs SET body = $1', [editor.docJson()]);
53
+ }
54
+ });
55
+ ```
56
+
57
+ ## Building from source
58
+
59
+ ```bash
60
+ cd node
61
+ npm run build # runs cargo build --release + copies the .node artifact
62
+ npm test # build + run the test suite
63
+ ```
64
+
65
+ ## API reference
66
+
67
+ ### `new Editor(schemaJson, docJson)`
68
+
69
+ Create an editor. Both arguments are JSON strings (schema spec and initial
70
+ document). Throws `Error` on malformed input.
71
+
72
+ ### `editor.applyStep(stepJson) → boolean`
73
+
74
+ Apply one step supplied as a JSON string. Returns `true` on success, `false`
75
+ if the step cannot be applied (document is left unchanged). Throws `Error`
76
+ on invalid JSON.
77
+
78
+ ### `editor.applyStepsJson(stepsJson) → boolean`
79
+
80
+ **Preferred method for incoming network data.** Accepts a JSON *array* of
81
+ steps as a single string — passed directly to Rust and parsed there, so
82
+ nothing touches JS's JSON machinery.
83
+
84
+ The batch is **atomic**: if any step fails the document and version are rolled
85
+ back entirely and `false` is returned. Throws `Error` on invalid JSON.
86
+
87
+ ### `editor.applySteps(steps) → boolean`
88
+
89
+ Convenience method for when steps are constructed or modified in JavaScript.
90
+ Each element of `steps` is a JSON string for one step.
91
+
92
+ The batch is **atomic**: if any step fails the document and version are rolled
93
+ back entirely and `false` is returned. Throws `Error` on invalid JSON.
94
+
95
+ ### `editor.reset(docJson)`
96
+
97
+ Replace the document with a new one, reusing the already-parsed schema.
98
+ Resets the version counter to zero. Throws `Error` on malformed input.
99
+
100
+ ### `editor.docJson() → string`
101
+
102
+ Return the current document as a compact JSON string. Serialized entirely in
103
+ Rust; only the final string is passed to JavaScript — suitable for direct
104
+ database writes with no intermediate objects.
105
+
106
+ ### `editor.version` *(number, read-only)*
107
+
108
+ Number of steps successfully applied since construction or the last `reset()`.
109
+ Use as a document version counter in collaborative-editing protocols.
110
+
111
+ ## Credits
112
+
113
+ The underlying Rust library was originally written by
114
+ **Daniel Seiler** ([Xiphoseer](https://github.com/Xiphoseer), <me@dseiler.eu>),
115
+ who designed and implemented the document model, transform pipeline, and
116
+ runtime schema system. Currently maintained by
117
+ **Johannes Wilm** ([FidusWriter](https://fiduswriter.org),
118
+ <johannes@fiduswriter.org>).
119
+
120
+ ProseMirror is by **Marijn Haverbeke** and contributors —
121
+ see [prosemirror.net](https://prosemirror.net).
122
+
123
+ ## License
124
+
125
+ MIT — see [LICENSE](../LICENSE).
126
+
127
+ Copyright 2026 Johannes Wilm
128
+ Copyright 2020 Daniel Seiler
129
+ Copyright 2015–2026 Marijn Haverbeke and others
package/build.rs ADDED
@@ -0,0 +1,3 @@
1
+ fn main() {
2
+ napi_build::setup();
3
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Copies the compiled Rust native addon to a platform-specific .node file.
3
+ *
4
+ * The filename follows the napi-rs triple convention so that index.js can
5
+ * load the correct binary at runtime. Called automatically by `npm run build`
6
+ * after `cargo build --release`.
7
+ */
8
+ import { cpSync } from 'fs';
9
+ import { platform, arch } from 'os';
10
+ import { fileURLToPath } from 'url';
11
+ import { join, dirname } from 'path';
12
+
13
+ const __dirname = dirname(fileURLToPath(import.meta.url));
14
+
15
+ // Map OS → shared library extension
16
+ function libExtension() {
17
+ const p = platform();
18
+ if (p === 'win32') return '.dll';
19
+ if (p === 'darwin') return '.dylib';
20
+ return '.so';
21
+ }
22
+
23
+ // Map Node platform+arch → napi-rs triple
24
+ function triple() {
25
+ const p = platform();
26
+ const a = arch();
27
+ if (p === 'darwin' && a === 'x64') return 'darwin-x64';
28
+ if (p === 'darwin' && a === 'arm64') return 'darwin-arm64';
29
+ if (p === 'linux' && a === 'x64') return 'linux-x64-gnu';
30
+ if (p === 'linux' && a === 'arm64') return 'linux-arm64-gnu';
31
+ if (p === 'win32' && a === 'x64') return 'win32-x64-msvc';
32
+ throw new Error(`Unsupported platform/arch: ${p}-${a}`);
33
+ }
34
+
35
+ const src = join(__dirname, '..', 'target', 'release', `libprosemirror_rs${libExtension()}`);
36
+ const dest = join(__dirname, `prosemirror-rs.${triple()}.node`);
37
+
38
+ cpSync(src, dest);
39
+ console.log(`Copied ${src} → ${dest}`);
package/index.d.ts ADDED
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Node.js bindings for prosemirror-rs.
3
+ *
4
+ * Provides a memory- and CPU-efficient interface to ProseMirror's document
5
+ * model and transform pipeline. Document state lives entirely in Rust; only
6
+ * JSON strings cross the JavaScript/Rust boundary.
7
+ *
8
+ * Schema caching:
9
+ * - The first `new Editor(schemaJson, ...)` call parses the schema and stores
10
+ * it in a global Rust cache keyed by the exact schema-JSON string.
11
+ * - All subsequent `Editor` constructions with the same string reuse the
12
+ * cached schema at the cost of a single `Arc` clone.
13
+ */
14
+ export declare class Editor {
15
+ /**
16
+ * Create a new Editor.
17
+ *
18
+ * The parsed schema is cached inside Rust (keyed by the exact
19
+ * `schemaJson` string), so repeated construction with the same schema
20
+ * only parses it once.
21
+ *
22
+ * @param schemaJson ProseMirror schema specification as a JSON string.
23
+ * @param docJson Initial document state as a JSON string.
24
+ * @throws {Error} If either string is not valid JSON, or the schema /
25
+ * document does not conform to the ProseMirror spec.
26
+ */
27
+ constructor(schemaJson: string, docJson: string);
28
+
29
+ /**
30
+ * Apply a single step to the document.
31
+ *
32
+ * @param stepJson The step as a JSON string.
33
+ * @returns `true` if applied successfully, `false` if the step could not
34
+ * be applied (document is left unchanged).
35
+ * @throws {Error} If `stepJson` is not valid JSON or not a recognised step type.
36
+ */
37
+ applyStep(stepJson: string): boolean;
38
+
39
+ /**
40
+ * Apply a batch of steps supplied as a single JSON array string, atomically.
41
+ *
42
+ * Preferred method when steps arrive from a network client: the string is
43
+ * passed directly to Rust and parsed there, so nothing touches JS's JSON
44
+ * machinery.
45
+ *
46
+ * All steps are parsed before any are applied, so a malformed array throws
47
+ * without mutating the document.
48
+ *
49
+ * The batch is fully atomic: if any step fails to apply the document is
50
+ * rolled back to its state before the call, leaving it completely unchanged.
51
+ * The version counter is likewise rolled back.
52
+ *
53
+ * @param stepsJson A JSON array of step objects, e.g.
54
+ * `'[{"stepType":"replace",...},...]'`.
55
+ * @returns `true` if every step applied successfully; `false` if any step
56
+ * failed (document and version are rolled back entirely).
57
+ * @throws {Error} If `stepsJson` is not a valid JSON array of steps.
58
+ */
59
+ applyStepsJson(stepsJson: string): boolean;
60
+
61
+ /**
62
+ * Apply a batch of steps from a JS array of JSON strings, atomically.
63
+ *
64
+ * Use this when steps are constructed or modified in JS. For steps arriving
65
+ * directly from a network client prefer `applyStepsJson` to avoid unnecessary
66
+ * JS-level JSON parsing.
67
+ *
68
+ * All steps are parsed before any are applied, so a bad JSON string throws
69
+ * without mutating the document.
70
+ *
71
+ * The batch is fully atomic: if any step fails to apply the document is
72
+ * rolled back to its state before the call, leaving it completely unchanged.
73
+ * The version counter is likewise rolled back.
74
+ *
75
+ * @param steps An array where each element is a JSON string for one step.
76
+ * @returns `true` if every step applied successfully; `false` if any step
77
+ * failed (document and version are rolled back entirely).
78
+ * @throws {Error} If any element is not valid step JSON.
79
+ */
80
+ applySteps(steps: string[]): boolean;
81
+
82
+ /**
83
+ * Reset the document to a new state, reusing the already-parsed schema.
84
+ *
85
+ * More efficient than constructing a brand-new `Editor` when you need to
86
+ * restore a snapshot (e.g. after an unrecoverable conflict), because the
87
+ * schema is never re-parsed — only the document JSON is processed.
88
+ * The version counter is reset to zero.
89
+ *
90
+ * @param docJson The replacement document as a JSON string.
91
+ * @throws {Error} If `docJson` is not valid JSON or does not conform to
92
+ * the schema.
93
+ */
94
+ reset(docJson: string): void;
95
+
96
+ /**
97
+ * Serialize the current document to a JSON string.
98
+ *
99
+ * Serialization happens entirely in Rust; only the resulting string is
100
+ * passed to JavaScript. Suitable for saving directly to a database without
101
+ * creating any intermediate JS objects.
102
+ *
103
+ * @returns The current document as a compact JSON string.
104
+ */
105
+ docJson(): string;
106
+
107
+ /**
108
+ * Number of steps successfully applied since construction or last `reset()`.
109
+ *
110
+ * Use as a document version counter in collaborative-editing protocols.
111
+ */
112
+ readonly version: number;
113
+ }
package/index.js ADDED
@@ -0,0 +1,37 @@
1
+ 'use strict';
2
+
3
+ const { existsSync } = require('fs');
4
+ const { join } = require('path');
5
+
6
+ const { platform, arch } = process;
7
+
8
+ // Map Node.js platform + arch → napi-rs platform triple.
9
+ // Every triple listed here must have a corresponding build in the CI matrix.
10
+ const TRIPLES = {
11
+ 'darwin-x64': 'prosemirror-rs.darwin-x64.node',
12
+ 'darwin-arm64': 'prosemirror-rs.darwin-arm64.node',
13
+ 'linux-x64': 'prosemirror-rs.linux-x64-gnu.node',
14
+ 'linux-arm64': 'prosemirror-rs.linux-arm64-gnu.node',
15
+ 'win32-x64': 'prosemirror-rs.win32-x64-msvc.node',
16
+ };
17
+
18
+ const key = `${platform}-${arch}`;
19
+ const filename = TRIPLES[key];
20
+
21
+ if (!filename) {
22
+ throw new Error(
23
+ `Unsupported platform/architecture: ${key}. ` +
24
+ `prosemirror-rs distributes prebuilt binaries for ` +
25
+ `darwin-x64, darwin-arm64, linux-x64, linux-arm64, and win32-x64.`,
26
+ );
27
+ }
28
+
29
+ const localPath = join(__dirname, filename);
30
+ if (!existsSync(localPath)) {
31
+ throw new Error(
32
+ `No native binary found for ${key} at ${filename}. ` +
33
+ `Try reinstalling the package, or build from source with \`npm run build\`.`,
34
+ );
35
+ }
36
+
37
+ module.exports = require(localPath);
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "prosemirror-rs",
3
+ "version": "0.3.0",
4
+ "description": "Node.js bindings for prosemirror-rs: a Rust implementation of ProseMirror's document model and transform pipeline",
5
+ "main": "index.js",
6
+ "types": "index.d.ts",
7
+ "scripts": {
8
+ "build": "cargo build --release && node copy-artifact.mjs",
9
+ "test": "npm run build && node --test tests/editor.test.js"
10
+ },
11
+ "keywords": [
12
+ "prosemirror",
13
+ "collaborative-editing",
14
+ "editor",
15
+ "document",
16
+ "napi",
17
+ "rust"
18
+ ],
19
+ "license": "MIT",
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "https://github.com/fiduswriter/prosemirror-rs"
23
+ },
24
+ "homepage": "https://github.com/fiduswriter/prosemirror-rs#readme",
25
+ "engines": {
26
+ "node": ">=18"
27
+ }
28
+ }
Binary file
package/src/lib.rs ADDED
@@ -0,0 +1,285 @@
1
+ use std::collections::HashMap;
2
+ use std::sync::{Arc, Mutex, OnceLock};
3
+
4
+ use napi::bindgen_prelude::*;
5
+ use napi_derive::napi;
6
+ use prosemirror::dynamic::types::Dyn;
7
+ use prosemirror::dynamic::{DynamicNode, DynamicSchema};
8
+ use prosemirror::transform::Step;
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // Schema cache
12
+ // ---------------------------------------------------------------------------
13
+
14
+ /// Global cache mapping raw schema-JSON strings → parsed schemas.
15
+ ///
16
+ /// Keyed by the exact bytes of the JSON string, so two textually-identical
17
+ /// strings always hit the same entry. Parsing a schema is the expensive
18
+ /// part of Editor construction; once cached, every subsequent `Editor::new`
19
+ /// for the same schema is just an `Arc` clone + a document parse.
20
+ static SCHEMA_CACHE: OnceLock<Mutex<HashMap<String, Arc<DynamicSchema>>>> = OnceLock::new();
21
+
22
+ fn get_or_create_schema(schema_json: &str) -> napi::Result<Arc<DynamicSchema>> {
23
+ let cache = SCHEMA_CACHE.get_or_init(|| Mutex::new(HashMap::new()));
24
+ // Recover from a poisoned lock (would only happen if a previous thread panicked mid-insert).
25
+ let mut guard = cache.lock().unwrap_or_else(|e| e.into_inner());
26
+
27
+ if let Some(existing) = guard.get(schema_json) {
28
+ return Ok(Arc::clone(existing));
29
+ }
30
+
31
+ let schema_val: serde_json::Value = serde_json::from_str(schema_json)
32
+ .map_err(|e| napi::Error::new(Status::InvalidArg, format!("Invalid schema JSON: {e}")))?;
33
+ let schema = DynamicSchema::from_json(&schema_val)
34
+ .map_err(|e| napi::Error::new(Status::InvalidArg, format!("Invalid schema: {e}")))?;
35
+
36
+ let arc = Arc::new(schema);
37
+ guard.insert(schema_json.to_owned(), Arc::clone(&arc));
38
+ Ok(arc)
39
+ }
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // Editor
43
+ // ---------------------------------------------------------------------------
44
+
45
+ /// A stateful ProseMirror document editor backed by Rust.
46
+ ///
47
+ /// The schema and document state live entirely in Rust memory. Only JSON
48
+ /// strings cross the JavaScript/Rust boundary, keeping data-transfer overhead
49
+ /// to the absolute minimum for each operation:
50
+ ///
51
+ /// * Steps arrive as a JSON string → parsed in Rust → applied in Rust.
52
+ /// * The document is serialized in Rust → returned as a plain JS `string`.
53
+ ///
54
+ /// The parsed schema is automatically cached inside Rust, keyed by the exact
55
+ /// schema-JSON string. Constructing many `Editor` objects that share the
56
+ /// same schema therefore only pays the parse cost once.
57
+ #[napi]
58
+ pub struct Editor {
59
+ schema: Arc<DynamicSchema>,
60
+ doc: DynamicNode,
61
+ version: usize,
62
+ }
63
+
64
+ #[napi]
65
+ impl Editor {
66
+ /// Create a new Editor.
67
+ ///
68
+ /// The parsed schema is cached inside Rust (keyed by the exact
69
+ /// `schemaJson` string), so repeated construction with the same schema
70
+ /// only parses it once.
71
+ ///
72
+ /// @param schemaJson ProseMirror schema specification as a JSON string.
73
+ /// @param docJson Initial document state as a JSON string.
74
+ /// @throws {Error} If either string is not valid JSON, or the schema /
75
+ /// document does not conform to the ProseMirror spec.
76
+ #[napi(constructor)]
77
+ pub fn new(schema_json: String, doc_json: String) -> napi::Result<Self> {
78
+ let schema = get_or_create_schema(&schema_json)?;
79
+
80
+ let doc_val: serde_json::Value = serde_json::from_str(&doc_json)
81
+ .map_err(|e| napi::Error::new(Status::InvalidArg, format!("Invalid document JSON: {e}")))?;
82
+ let doc = schema
83
+ .node_from_json(&doc_val)
84
+ .map_err(|e| napi::Error::new(Status::InvalidArg, format!("Invalid document: {e}")))?;
85
+
86
+ Ok(Editor { schema, doc, version: 0 })
87
+ }
88
+
89
+ /// Apply a single step to the document.
90
+ ///
91
+ /// @param stepJson The step as a JSON string.
92
+ /// @returns `true` if applied successfully, `false` if the step could not
93
+ /// be applied (document is left unchanged).
94
+ /// @throws {Error} If `stepJson` is not valid JSON or not a recognised step type.
95
+ #[napi]
96
+ pub fn apply_step(&mut self, step_json: String) -> napi::Result<bool> {
97
+ let result = {
98
+ let schema = &self.schema;
99
+ let doc = &self.doc;
100
+ schema.with_types(|| -> napi::Result<Option<DynamicNode>> {
101
+ let step: Step<Dyn> = serde_json::from_str(&step_json)
102
+ .map_err(|e| napi::Error::new(Status::InvalidArg, format!("Invalid step JSON: {e}")))?;
103
+ Ok(step.apply(doc).ok())
104
+ })
105
+ }?;
106
+
107
+ if let Some(new_doc) = result {
108
+ self.doc = new_doc;
109
+ self.version += 1;
110
+ Ok(true)
111
+ } else {
112
+ Ok(false)
113
+ }
114
+ }
115
+
116
+ /// Apply a batch of steps supplied as a single JSON array string, atomically.
117
+ ///
118
+ /// **This is the preferred method when steps arrive from a network client.**
119
+ /// The entire string is handed to Rust and parsed there in one pass — no
120
+ /// JS JSON machinery is involved, and no intermediate JS objects are created.
121
+ ///
122
+ /// All steps are parsed before any are applied, so a malformed JSON array
123
+ /// throws without mutating the document.
124
+ ///
125
+ /// The batch is fully atomic: if any step fails to apply the document is
126
+ /// rolled back to its state before the call, leaving it completely
127
+ /// unchanged. The version counter is likewise rolled back.
128
+ ///
129
+ /// @param stepsJson A JSON array of step objects, e.g.
130
+ /// `'[{"stepType":"replace",...},...]'`.
131
+ /// @returns `true` if every step applied successfully; `false` if any
132
+ /// step failed (document and version are rolled back entirely).
133
+ /// @throws {Error} If `stepsJson` is not a valid JSON array of steps.
134
+ #[napi]
135
+ pub fn apply_steps_json(&mut self, steps_json: String) -> napi::Result<bool> {
136
+ // Phase 1: parse the whole array before touching the document.
137
+ let steps: Vec<Step<Dyn>> = {
138
+ let schema = &self.schema;
139
+ schema.with_types(|| {
140
+ serde_json::from_str(&steps_json)
141
+ .map_err(|e| napi::Error::new(Status::InvalidArg, format!("Invalid steps JSON: {e}")))
142
+ })?
143
+ };
144
+
145
+ // A snapshot is only needed when there are at least two steps: with a
146
+ // single step the document is either untouched (failure) or cleanly
147
+ // advanced (success), so no previously-committed state can need rolling back.
148
+ let mut snapshot: Option<(DynamicNode, usize)> = if steps.len() > 1 {
149
+ Some((self.doc.clone(), self.version))
150
+ } else {
151
+ None
152
+ };
153
+
154
+ // Phase 2: apply each step; roll back and return false on the first failure.
155
+ for step in steps {
156
+ let result = {
157
+ let schema = &self.schema;
158
+ let doc = &self.doc;
159
+ schema.with_types(|| step.apply(doc))
160
+ };
161
+ match result {
162
+ Ok(new_doc) => {
163
+ self.doc = new_doc;
164
+ self.version += 1;
165
+ }
166
+ Err(_) => {
167
+ // Option::take moves the contents out via &mut self rather
168
+ // than an unconditional move, which the borrow checker would
169
+ // reject inside a loop body.
170
+ if let Some((snap_doc, snap_version)) = snapshot.take() {
171
+ self.doc = snap_doc;
172
+ self.version = snap_version;
173
+ }
174
+ return Ok(false);
175
+ }
176
+ }
177
+ }
178
+ Ok(true)
179
+ }
180
+
181
+ /// Apply a batch of steps from a JS array of JSON strings, atomically.
182
+ ///
183
+ /// Use this when steps are constructed or modified in JS (e.g.
184
+ /// programmatically building a step object and calling `JSON.stringify`).
185
+ /// For steps that arrive directly from a network client prefer
186
+ /// `applyStepsJson` to avoid unnecessary JS-level parsing.
187
+ ///
188
+ /// All steps are parsed before any are applied, so a bad JSON string
189
+ /// throws without mutating the document.
190
+ ///
191
+ /// The batch is fully atomic: if any step fails to apply the document is
192
+ /// rolled back to its state before the call, leaving it completely
193
+ /// unchanged. The version counter is likewise rolled back.
194
+ ///
195
+ /// @param steps An array where each element is a JSON string for one step.
196
+ /// @returns `true` if every step applied successfully; `false` if any
197
+ /// step failed (document and version are rolled back entirely).
198
+ /// @throws {Error} If any element is not valid step JSON.
199
+ #[napi]
200
+ pub fn apply_steps(&mut self, steps: Vec<String>) -> napi::Result<bool> {
201
+ // Parse all steps up-front so that a bad step throws
202
+ // before any mutation takes place.
203
+ let parsed: Vec<Step<Dyn>> = {
204
+ let schema = &self.schema;
205
+ schema.with_types(|| {
206
+ steps
207
+ .iter()
208
+ .map(|s| {
209
+ serde_json::from_str::<Step<Dyn>>(s)
210
+ .map_err(|e| napi::Error::new(Status::InvalidArg, format!("Invalid step JSON: {e}")))
211
+ })
212
+ .collect::<napi::Result<Vec<_>>>()
213
+ })?
214
+ };
215
+
216
+ // Snapshot the pre-batch state so we can roll back cheaply.
217
+ let snapshot = self.doc.clone();
218
+ let snapshot_version = self.version;
219
+
220
+ for step in parsed {
221
+ let result = {
222
+ let schema = &self.schema;
223
+ let doc = &self.doc;
224
+ schema.with_types(|| step.apply(doc))
225
+ };
226
+ match result {
227
+ Ok(new_doc) => {
228
+ self.doc = new_doc;
229
+ self.version += 1;
230
+ }
231
+ Err(_) => {
232
+ self.doc = snapshot;
233
+ self.version = snapshot_version;
234
+ return Ok(false);
235
+ }
236
+ }
237
+ }
238
+ Ok(true)
239
+ }
240
+
241
+ /// Reset the document to a new state, reusing the already-parsed schema.
242
+ ///
243
+ /// This is more efficient than constructing a brand-new `Editor` when
244
+ /// you need to restore a snapshot (e.g. after an unrecoverable conflict),
245
+ /// because the schema is never re-parsed — only the document JSON is
246
+ /// processed. The version counter is reset to zero.
247
+ ///
248
+ /// @param docJson The replacement document as a JSON string.
249
+ /// @throws {Error} If `docJson` is not valid JSON or does not conform to
250
+ /// the schema.
251
+ #[napi]
252
+ pub fn reset(&mut self, doc_json: String) -> napi::Result<()> {
253
+ let doc_val: serde_json::Value = serde_json::from_str(&doc_json)
254
+ .map_err(|e| napi::Error::new(Status::InvalidArg, format!("Invalid document JSON: {e}")))?;
255
+ let doc = self
256
+ .schema
257
+ .node_from_json(&doc_val)
258
+ .map_err(|e| napi::Error::new(Status::InvalidArg, format!("Invalid document: {e}")))?;
259
+ self.doc = doc;
260
+ self.version = 0;
261
+ Ok(())
262
+ }
263
+
264
+ /// Serialize the current document to a JSON string.
265
+ ///
266
+ /// Serialization happens entirely in Rust; only the resulting string is
267
+ /// passed to JavaScript. This makes the method suitable for saving the
268
+ /// document directly to a database without creating any intermediate
269
+ /// JS objects.
270
+ ///
271
+ /// @returns The document as a compact JSON string.
272
+ #[napi]
273
+ pub fn doc_json(&self) -> napi::Result<String> {
274
+ serde_json::to_string(&self.doc)
275
+ .map_err(|e| napi::Error::new(Status::GenericFailure, format!("Serialization error: {e}")))
276
+ }
277
+
278
+ /// Number of steps successfully applied since construction (or last `reset()`).
279
+ ///
280
+ /// Use as a document version counter in collaborative-editing protocols.
281
+ #[napi(getter)]
282
+ pub fn version(&self) -> u32 {
283
+ self.version as u32
284
+ }
285
+ }
@@ -0,0 +1,303 @@
1
+ 'use strict';
2
+ /**
3
+ * Tests for the prosemirror-rs Node.js bindings.
4
+ *
5
+ * Covers: construction, docJson, applyStep, applyStepsJson, applySteps,
6
+ * reset, version, error handling, atomicity, and schema caching.
7
+ *
8
+ * Run via: npm test (from the node/ directory)
9
+ * Or: node --test tests/ (after building the native addon)
10
+ */
11
+ const { test } = require('node:test');
12
+ const assert = require('node:assert/strict');
13
+ const { Editor } = require('../index.js');
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Fixtures
17
+ // ---------------------------------------------------------------------------
18
+
19
+ const SCHEMA = JSON.stringify({
20
+ nodes: {
21
+ doc: { content: 'paragraph+' },
22
+ paragraph: { content: 'text*', group: 'block' },
23
+ text: { group: 'inline' },
24
+ },
25
+ marks: { strong: {}, em: {} },
26
+ });
27
+
28
+ const DOC = JSON.stringify({
29
+ type: 'doc',
30
+ content: [{ type: 'paragraph', content: [
31
+ { type: 'text', text: 'hello' },
32
+ ]}],
33
+ });
34
+
35
+ // Replace step: insert 'x' at position 2 (inside first paragraph, after 'h').
36
+ // Stays valid across repeated inserts because the paragraph grows each time.
37
+ const INSERT_STEP = JSON.stringify({
38
+ stepType: 'replace',
39
+ from: 2,
40
+ to: 2,
41
+ slice: { content: [{ type: 'text', text: 'x' }] },
42
+ });
43
+
44
+ // A step that points way past the end of the document → always fails to apply.
45
+ const BAD_POSITION_STEP = JSON.stringify({
46
+ stepType: 'replace',
47
+ from: 9999,
48
+ to: 9999,
49
+ slice: { content: [{ type: 'text', text: 'x' }] },
50
+ });
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // Construction
54
+ // ---------------------------------------------------------------------------
55
+
56
+ test('creates an editor with initial version 0', () => {
57
+ const editor = new Editor(SCHEMA, DOC);
58
+ assert.equal(editor.version, 0);
59
+ });
60
+
61
+ test('constructor throws on invalid schema JSON', () => {
62
+ assert.throws(
63
+ () => new Editor('not-valid-json', DOC),
64
+ /Invalid schema JSON/,
65
+ );
66
+ });
67
+
68
+ test('constructor throws on invalid doc JSON', () => {
69
+ assert.throws(
70
+ () => new Editor(SCHEMA, 'not-valid-json'),
71
+ /Invalid document JSON/,
72
+ );
73
+ });
74
+
75
+ test('constructor throws when doc JSON is not a node object', () => {
76
+ // A JSON array cannot be deserialized as a document node.
77
+ assert.throws(
78
+ () => new Editor(SCHEMA, JSON.stringify([])),
79
+ /Invalid document/,
80
+ );
81
+ });
82
+
83
+ // ---------------------------------------------------------------------------
84
+ // docJson
85
+ // ---------------------------------------------------------------------------
86
+
87
+ test('docJson returns a valid JSON string containing the initial text', () => {
88
+ const editor = new Editor(SCHEMA, DOC);
89
+ const raw = editor.docJson();
90
+ assert.equal(typeof raw, 'string');
91
+ const doc = JSON.parse(raw);
92
+ assert.equal(doc.type, 'doc');
93
+ assert.ok(raw.includes('hello'), `Expected "hello" in: ${raw}`);
94
+ });
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // applyStep
98
+ // ---------------------------------------------------------------------------
99
+
100
+ test('applyStep returns true on success and increments version', () => {
101
+ const editor = new Editor(SCHEMA, DOC);
102
+ const ok = editor.applyStep(INSERT_STEP);
103
+ assert.equal(ok, true);
104
+ assert.equal(editor.version, 1);
105
+ });
106
+
107
+ test('applyStep mutates the document', () => {
108
+ const editor = new Editor(SCHEMA, DOC);
109
+ editor.applyStep(INSERT_STEP);
110
+ const doc = JSON.parse(editor.docJson());
111
+ // The inserted 'x' should appear in the serialised document.
112
+ assert.ok(JSON.stringify(doc).includes('x'));
113
+ });
114
+
115
+ test('applyStep accumulates version across multiple calls', () => {
116
+ const editor = new Editor(SCHEMA, DOC);
117
+ editor.applyStep(INSERT_STEP);
118
+ editor.applyStep(INSERT_STEP);
119
+ editor.applyStep(INSERT_STEP);
120
+ assert.equal(editor.version, 3);
121
+ });
122
+
123
+ test('applyStep returns false when the step cannot be applied', () => {
124
+ const editor = new Editor(SCHEMA, DOC);
125
+ const ok = editor.applyStep(BAD_POSITION_STEP);
126
+ assert.equal(ok, false);
127
+ assert.equal(editor.version, 0);
128
+ });
129
+
130
+ test('applyStep leaves the document unchanged on failure', () => {
131
+ const editor = new Editor(SCHEMA, DOC);
132
+ const before = editor.docJson();
133
+ editor.applyStep(BAD_POSITION_STEP);
134
+ assert.equal(editor.docJson(), before);
135
+ });
136
+
137
+ test('applyStep throws on invalid JSON', () => {
138
+ const editor = new Editor(SCHEMA, DOC);
139
+ assert.throws(() => editor.applyStep('not-json'), /Invalid step JSON/);
140
+ });
141
+
142
+ // ---------------------------------------------------------------------------
143
+ // applyStepsJson
144
+ // ---------------------------------------------------------------------------
145
+
146
+ test('applyStepsJson applies all steps from a JSON array string', () => {
147
+ const editor = new Editor(SCHEMA, DOC);
148
+ const stepsJson = JSON.stringify([
149
+ JSON.parse(INSERT_STEP),
150
+ JSON.parse(INSERT_STEP),
151
+ ]);
152
+ const ok = editor.applyStepsJson(stepsJson);
153
+ assert.equal(ok, true);
154
+ assert.equal(editor.version, 2);
155
+ });
156
+
157
+ test('applyStepsJson is atomic: rolls back version on step failure', () => {
158
+ const editor = new Editor(SCHEMA, DOC);
159
+ const stepsJson = JSON.stringify([
160
+ JSON.parse(INSERT_STEP),
161
+ JSON.parse(BAD_POSITION_STEP),
162
+ ]);
163
+ const ok = editor.applyStepsJson(stepsJson);
164
+ assert.equal(ok, false);
165
+ assert.equal(editor.version, 0);
166
+ });
167
+
168
+ test('applyStepsJson is atomic: rolls back document on step failure', () => {
169
+ const editor = new Editor(SCHEMA, DOC);
170
+ const before = editor.docJson();
171
+ const stepsJson = JSON.stringify([
172
+ JSON.parse(INSERT_STEP),
173
+ JSON.parse(BAD_POSITION_STEP),
174
+ ]);
175
+ editor.applyStepsJson(stepsJson);
176
+ assert.equal(editor.docJson(), before);
177
+ });
178
+
179
+ test('applyStepsJson succeeds for an empty array', () => {
180
+ const editor = new Editor(SCHEMA, DOC);
181
+ const ok = editor.applyStepsJson('[]');
182
+ assert.equal(ok, true);
183
+ assert.equal(editor.version, 0);
184
+ });
185
+
186
+ test('applyStepsJson throws on invalid JSON', () => {
187
+ const editor = new Editor(SCHEMA, DOC);
188
+ assert.throws(() => editor.applyStepsJson('not-json'), /Invalid steps JSON/);
189
+ });
190
+
191
+ // ---------------------------------------------------------------------------
192
+ // applySteps
193
+ // ---------------------------------------------------------------------------
194
+
195
+ test('applySteps applies all steps from an array of JSON strings', () => {
196
+ const editor = new Editor(SCHEMA, DOC);
197
+ const ok = editor.applySteps([INSERT_STEP, INSERT_STEP]);
198
+ assert.equal(ok, true);
199
+ assert.equal(editor.version, 2);
200
+ });
201
+
202
+ test('applySteps is atomic: rolls back version on step failure', () => {
203
+ const editor = new Editor(SCHEMA, DOC);
204
+ const ok = editor.applySteps([INSERT_STEP, BAD_POSITION_STEP]);
205
+ assert.equal(ok, false);
206
+ assert.equal(editor.version, 0);
207
+ });
208
+
209
+ test('applySteps is atomic: rolls back document on step failure', () => {
210
+ const editor = new Editor(SCHEMA, DOC);
211
+ const before = editor.docJson();
212
+ editor.applySteps([INSERT_STEP, BAD_POSITION_STEP]);
213
+ assert.equal(editor.docJson(), before);
214
+ });
215
+
216
+ test('applySteps succeeds for an empty array', () => {
217
+ const editor = new Editor(SCHEMA, DOC);
218
+ const ok = editor.applySteps([]);
219
+ assert.equal(ok, true);
220
+ assert.equal(editor.version, 0);
221
+ });
222
+
223
+ test('applySteps throws on an invalid step JSON string', () => {
224
+ const editor = new Editor(SCHEMA, DOC);
225
+ assert.throws(() => editor.applySteps(['not-json']), /Invalid step JSON/);
226
+ });
227
+
228
+ test('applySteps throws before mutating when a later element is invalid JSON', () => {
229
+ const editor = new Editor(SCHEMA, DOC);
230
+ const before = editor.docJson();
231
+ // All steps are parsed first, so the bad second element prevents any mutation.
232
+ assert.throws(() => editor.applySteps([INSERT_STEP, 'not-json']));
233
+ assert.equal(editor.docJson(), before);
234
+ assert.equal(editor.version, 0);
235
+ });
236
+
237
+ // ---------------------------------------------------------------------------
238
+ // reset
239
+ // ---------------------------------------------------------------------------
240
+
241
+ test('reset restores version to 0', () => {
242
+ const editor = new Editor(SCHEMA, DOC);
243
+ editor.applyStep(INSERT_STEP);
244
+ editor.applyStep(INSERT_STEP);
245
+ assert.equal(editor.version, 2);
246
+
247
+ editor.reset(DOC);
248
+ assert.equal(editor.version, 0);
249
+ });
250
+
251
+ test('reset replaces the document', () => {
252
+ const editor = new Editor(SCHEMA, DOC);
253
+ editor.applyStep(INSERT_STEP); // mutate
254
+
255
+ editor.reset(DOC);
256
+ // After reset the document should match the original (no 'x' inserted).
257
+ const doc = JSON.parse(editor.docJson());
258
+ assert.equal(doc.type, 'doc');
259
+ assert.ok(!JSON.stringify(doc.content).startsWith('[{"type":"paragraph","content":[{"type":"text","text":"x'));
260
+ });
261
+
262
+ test('reset allows applying steps again after rollback', () => {
263
+ const editor = new Editor(SCHEMA, DOC);
264
+ editor.applyStep(INSERT_STEP);
265
+ editor.reset(DOC);
266
+
267
+ const ok = editor.applyStep(INSERT_STEP);
268
+ assert.equal(ok, true);
269
+ assert.equal(editor.version, 1);
270
+ });
271
+
272
+ test('reset throws on invalid JSON', () => {
273
+ const editor = new Editor(SCHEMA, DOC);
274
+ assert.throws(() => editor.reset('not-json'), /Invalid document JSON/);
275
+ });
276
+
277
+ test('reset throws when new doc JSON is not a node object', () => {
278
+ const editor = new Editor(SCHEMA, DOC);
279
+ // A JSON array cannot be deserialized as a document node.
280
+ assert.throws(() => editor.reset(JSON.stringify([])), /Invalid document/);
281
+ });
282
+
283
+ // ---------------------------------------------------------------------------
284
+ // Schema caching
285
+ // ---------------------------------------------------------------------------
286
+
287
+ test('multiple editors with the same schema all work correctly', () => {
288
+ const editors = Array.from({ length: 5 }, () => new Editor(SCHEMA, DOC));
289
+ for (const editor of editors) {
290
+ assert.equal(editor.version, 0);
291
+ const doc = JSON.parse(editor.docJson());
292
+ assert.equal(doc.type, 'doc');
293
+ }
294
+ });
295
+
296
+ test('editors with the same schema do not share document state', () => {
297
+ const editorA = new Editor(SCHEMA, DOC);
298
+ const editorB = new Editor(SCHEMA, DOC);
299
+
300
+ editorA.applyStep(INSERT_STEP);
301
+ assert.equal(editorA.version, 1);
302
+ assert.equal(editorB.version, 0); // B is untouched
303
+ });