tiptap-python-utils 0.1.0__tar.gz → 0.3.0__tar.gz

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.
Files changed (61) hide show
  1. tiptap_python_utils-0.3.0/PKG-INFO +323 -0
  2. tiptap_python_utils-0.3.0/README.md +272 -0
  3. {tiptap_python_utils-0.1.0 → tiptap_python_utils-0.3.0}/pyproject.toml +2 -1
  4. {tiptap_python_utils-0.1.0 → tiptap_python_utils-0.3.0}/src/tiptap_python_utils/__init__.py +3 -18
  5. {tiptap_python_utils-0.1.0 → tiptap_python_utils-0.3.0}/src/tiptap_python_utils/codec/__init__.py +5 -4
  6. tiptap_python_utils-0.1.0/src/tiptap_python_utils/codec/json.py → tiptap_python_utils-0.3.0/src/tiptap_python_utils/codec/raw.py +8 -46
  7. tiptap_python_utils-0.3.0/src/tiptap_python_utils/codec/reader.py +42 -0
  8. tiptap_python_utils-0.3.0/src/tiptap_python_utils/codec/writer.py +16 -0
  9. {tiptap_python_utils-0.1.0 → tiptap_python_utils-0.3.0}/src/tiptap_python_utils/content.py +43 -0
  10. tiptap_python_utils-0.3.0/src/tiptap_python_utils/model/__init__.py +49 -0
  11. tiptap_python_utils-0.3.0/src/tiptap_python_utils/model/base.py +139 -0
  12. tiptap_python_utils-0.3.0/src/tiptap_python_utils/model/nodes.py +123 -0
  13. tiptap_python_utils-0.3.0/src/tiptap_python_utils/model/payload.py +81 -0
  14. tiptap_python_utils-0.3.0/src/tiptap_python_utils/model/registry.py +57 -0
  15. tiptap_python_utils-0.3.0/src/tiptap_python_utils/select/selection.py +136 -0
  16. tiptap_python_utils-0.3.0/src/tiptap_python_utils/shared/__init__.py +11 -0
  17. tiptap_python_utils-0.3.0/src/tiptap_python_utils/shared/families.py +64 -0
  18. tiptap_python_utils-0.3.0/src/tiptap_python_utils/shared/fingerprint.py +20 -0
  19. tiptap_python_utils-0.3.0/src/tiptap_python_utils/shared/identity.py +9 -0
  20. tiptap_python_utils-0.3.0/src/tiptap_python_utils.egg-info/PKG-INFO +323 -0
  21. {tiptap_python_utils-0.1.0 → tiptap_python_utils-0.3.0}/src/tiptap_python_utils.egg-info/SOURCES.txt +13 -4
  22. tiptap_python_utils-0.3.0/tests/test_codec_raw.py +90 -0
  23. tiptap_python_utils-0.3.0/tests/test_compat_imports.py +124 -0
  24. {tiptap_python_utils-0.1.0 → tiptap_python_utils-0.3.0}/tests/test_content.py +163 -3
  25. {tiptap_python_utils-0.1.0 → tiptap_python_utils-0.3.0}/tests/test_filter.py +1 -2
  26. tiptap_python_utils-0.3.0/tests/test_mutations.py +405 -0
  27. tiptap_python_utils-0.3.0/tests/test_public_api.py +73 -0
  28. {tiptap_python_utils-0.1.0 → tiptap_python_utils-0.3.0}/tests/test_traverser.py +1 -1
  29. tiptap_python_utils-0.1.0/PKG-INFO +0 -176
  30. tiptap_python_utils-0.1.0/README.md +0 -126
  31. tiptap_python_utils-0.1.0/src/tiptap_python_utils/edit/__init__.py +0 -12
  32. tiptap_python_utils-0.1.0/src/tiptap_python_utils/edit/commands.py +0 -163
  33. tiptap_python_utils-0.1.0/src/tiptap_python_utils/model/__init__.py +0 -345
  34. tiptap_python_utils-0.1.0/src/tiptap_python_utils/select/selection.py +0 -72
  35. tiptap_python_utils-0.1.0/src/tiptap_python_utils/shared/__init__.py +0 -23
  36. tiptap_python_utils-0.1.0/src/tiptap_python_utils/shared/service.py +0 -147
  37. tiptap_python_utils-0.1.0/src/tiptap_python_utils.egg-info/PKG-INFO +0 -176
  38. tiptap_python_utils-0.1.0/tests/test_mutations.py +0 -338
  39. {tiptap_python_utils-0.1.0 → tiptap_python_utils-0.3.0}/LICENSE +0 -0
  40. {tiptap_python_utils-0.1.0 → tiptap_python_utils-0.3.0}/MANIFEST.in +0 -0
  41. {tiptap_python_utils-0.1.0 → tiptap_python_utils-0.3.0}/setup.cfg +0 -0
  42. {tiptap_python_utils-0.1.0 → tiptap_python_utils-0.3.0}/src/tiptap_python_utils/contract/__init__.py +0 -0
  43. {tiptap_python_utils-0.1.0 → tiptap_python_utils-0.3.0}/src/tiptap_python_utils/contract/key.py +0 -0
  44. {tiptap_python_utils-0.1.0 → tiptap_python_utils-0.3.0}/src/tiptap_python_utils/contract/kind.py +0 -0
  45. {tiptap_python_utils-0.1.0 → tiptap_python_utils-0.3.0}/src/tiptap_python_utils/contract/policy.py +0 -0
  46. {tiptap_python_utils-0.1.0 → tiptap_python_utils-0.3.0}/src/tiptap_python_utils/exceptions.py +0 -0
  47. {tiptap_python_utils-0.1.0 → tiptap_python_utils-0.3.0}/src/tiptap_python_utils/py.typed +0 -0
  48. {tiptap_python_utils-0.1.0 → tiptap_python_utils-0.3.0}/src/tiptap_python_utils/select/__init__.py +0 -0
  49. {tiptap_python_utils-0.1.0 → tiptap_python_utils-0.3.0}/src/tiptap_python_utils/tasks/__init__.py +0 -0
  50. {tiptap_python_utils-0.1.0 → tiptap_python_utils-0.3.0}/src/tiptap_python_utils/tasks/query.py +0 -0
  51. {tiptap_python_utils-0.1.0 → tiptap_python_utils-0.3.0}/src/tiptap_python_utils/text/__init__.py +0 -0
  52. {tiptap_python_utils-0.1.0 → tiptap_python_utils-0.3.0}/src/tiptap_python_utils/text/extract.py +0 -0
  53. {tiptap_python_utils-0.1.0 → tiptap_python_utils-0.3.0}/src/tiptap_python_utils/tree/__init__.py +0 -0
  54. {tiptap_python_utils-0.1.0 → tiptap_python_utils-0.3.0}/src/tiptap_python_utils/tree/path.py +0 -0
  55. {tiptap_python_utils-0.1.0 → tiptap_python_utils-0.3.0}/src/tiptap_python_utils/types.py +0 -0
  56. {tiptap_python_utils-0.1.0 → tiptap_python_utils-0.3.0}/src/tiptap_python_utils/walk/__init__.py +0 -0
  57. {tiptap_python_utils-0.1.0 → tiptap_python_utils-0.3.0}/src/tiptap_python_utils/walk/traversal.py +0 -0
  58. {tiptap_python_utils-0.1.0 → tiptap_python_utils-0.3.0}/src/tiptap_python_utils.egg-info/dependency_links.txt +0 -0
  59. {tiptap_python_utils-0.1.0 → tiptap_python_utils-0.3.0}/src/tiptap_python_utils.egg-info/requires.txt +0 -0
  60. {tiptap_python_utils-0.1.0 → tiptap_python_utils-0.3.0}/src/tiptap_python_utils.egg-info/top_level.txt +0 -0
  61. {tiptap_python_utils-0.1.0 → tiptap_python_utils-0.3.0}/tests/test_extract.py +0 -0
@@ -0,0 +1,323 @@
1
+ Metadata-Version: 2.4
2
+ Name: tiptap_python_utils
3
+ Version: 0.3.0
4
+ Summary: Python utilities for parsing, traversing, editing, and serializing TipTap JSON content.
5
+ Author: tiptap_python_utils contributors
6
+ License: MIT License
7
+
8
+ Copyright (c) 2026 tiptap_python_utils contributors
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+
28
+ Project-URL: Homepage, https://github.com/tugkanpilka/tiptap-python-utils
29
+ Project-URL: Repository, https://github.com/tugkanpilka/tiptap-python-utils
30
+ Project-URL: Issues, https://github.com/tugkanpilka/tiptap-python-utils/issues
31
+ Project-URL: Changelog, https://github.com/tugkanpilka/tiptap-python-utils/blob/main/CHANGELOG.md
32
+ Keywords: tiptap,prosemirror,json,ast,editor
33
+ Classifier: Development Status :: 3 - Alpha
34
+ Classifier: Intended Audience :: Developers
35
+ Classifier: License :: OSI Approved :: MIT License
36
+ Classifier: Programming Language :: Python :: 3
37
+ Classifier: Programming Language :: Python :: 3.9
38
+ Classifier: Programming Language :: Python :: 3.10
39
+ Classifier: Programming Language :: Python :: 3.11
40
+ Classifier: Programming Language :: Python :: 3.12
41
+ Classifier: Programming Language :: Python :: 3.13
42
+ Classifier: Typing :: Typed
43
+ Requires-Python: >=3.9
44
+ Description-Content-Type: text/markdown
45
+ License-File: LICENSE
46
+ Provides-Extra: dev
47
+ Requires-Dist: build>=1.2; extra == "dev"
48
+ Requires-Dist: pytest>=8; extra == "dev"
49
+ Requires-Dist: twine>=5; extra == "dev"
50
+ Dynamic: license-file
51
+
52
+ # tiptap_python_utils
53
+
54
+ [![PyPI](https://img.shields.io/pypi/v/tiptap_python_utils.svg)](https://pypi.org/project/tiptap_python_utils/)
55
+ [![Python](https://img.shields.io/pypi/pyversions/tiptap_python_utils.svg)](https://pypi.org/project/tiptap_python_utils/)
56
+ [![CI](https://github.com/tugkanpilka/tiptap-python-utils/actions/workflows/ci.yml/badge.svg)](https://github.com/tugkanpilka/tiptap-python-utils/actions/workflows/ci.yml)
57
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
58
+
59
+ Python utilities for [TipTap](https://tiptap.dev) JSON content.
60
+
61
+ `tiptap_python_utils` parses TipTap documents into typed, immutable Python
62
+ nodes, preserves unknown/custom nodes for lossless round trips, and provides
63
+ small helpers for traversal, immutable edits, visible text extraction, task
64
+ queries, and shared-node synchronization.
65
+
66
+ - **Zero runtime dependencies.** Standard library only.
67
+ - **Python 3.9+.** Tested on 3.9, 3.10, 3.11, 3.12, 3.13.
68
+ - **Lossless round trip.** Unknown node kinds and any extra fields are preserved.
69
+ - **Immutable AST.** All mutations return new instances via a fluent selection API.
70
+
71
+ ## Install
72
+
73
+ ```bash
74
+ pip install tiptap_python_utils
75
+ ```
76
+
77
+ ## Quick Start
78
+
79
+ ```python
80
+ from tiptap_python_utils import Content
81
+
82
+ raw = {
83
+ "type": "doc",
84
+ "content": [
85
+ {
86
+ "type": "paragraph",
87
+ "attrs": {"id": "p1"},
88
+ "content": [{"type": "text", "text": "Old"}],
89
+ }
90
+ ],
91
+ }
92
+
93
+ # Strict-load → descend to the text leaf → write a new value → serialize.
94
+ updated = Content.require(raw).where_id("p1").leaf().text("New").dump()
95
+ ```
96
+
97
+ ## Three Ways to Load a Document
98
+
99
+ | Constructor | When to use | On invalid input |
100
+ |---|---|---|
101
+ | `Content.parse(raw)` | Lenient — `raw` may be `None`, a string, or a dict | Returns a `Content` with `root=None` |
102
+ | `Content.require(raw)` | Strict — input must be a valid TipTap `doc` | Raises `TiptapValidationError` |
103
+ | `Content.wrap(node)` | Auto-wraps a non-doc node into a `doc` root | Raises if the node is not parseable |
104
+
105
+ ## Lossless Round Trip
106
+
107
+ Parsing never silently drops fields. Two mechanisms preserve information:
108
+
109
+ - `Node.extra` stores top-level keys that aren't part of the known schema
110
+ (e.g. custom node attributes, vendor-specific keys).
111
+ - `Node.present` records which structural keys (`attrs`, `content`, …) appeared
112
+ in the raw input, so `raw()` emits empty `attrs: {}` or `content: []` only
113
+ when they were originally present.
114
+ - Unknown node kinds become `Unknown(raw_kind="…")` rather than being rejected.
115
+
116
+ ```python
117
+ from tiptap_python_utils import Content
118
+
119
+ raw = {"type": "doc", "content": [
120
+ {"type": "customPanel", "attrs": {"id": "p1"}, "content": [], "custom": {"x": 1}}
121
+ ]}
122
+
123
+ assert Content.require(raw).to_dict() == raw # byte-for-byte
124
+ ```
125
+
126
+ ## Typed Nodes
127
+
128
+ Build typed nodes directly and serialize them back to TipTap-compatible JSON:
129
+
130
+ ```python
131
+ from tiptap_python_utils import Content, Paragraph, Text
132
+
133
+ node = Paragraph(id="p1", content=(Text(value="Hello"),))
134
+ doc = Content.wrap(node.raw())
135
+ ```
136
+
137
+ ## Selection and Editing
138
+
139
+ The fluent selection API is the single home for mutation. Selection methods
140
+ return a new `Content`; the original is never mutated.
141
+
142
+ ### Select by id or kind
143
+
144
+ ```python
145
+ from tiptap_python_utils import Content, kind
146
+
147
+ # By id (uses TipTap's id resolution rules under the hood).
148
+ content.where_id("p1")
149
+
150
+ # By TipTap kind.
151
+ content.of(kind.PARAGRAPH)
152
+ ```
153
+
154
+ ### Atomic mutations
155
+
156
+ ```python
157
+ # Write an attribute on the selected node.
158
+ content.where_id("p1").attr("color", "blue")
159
+
160
+ # Descend to the first text descendant, then write text or marks.
161
+ content.where_id("p1").leaf().text("Updated")
162
+ content.where_id("p1").leaf().marks([{"type": "bold"}])
163
+
164
+ # Replace the whole selected node, or append a child to it.
165
+ content.where_id("p1").replace({"type": "paragraph", "attrs": {"id": "p1"}, "content": []})
166
+ content.where_id("ul1").append({"type": "listItem", "attrs": {"id": "li-new"}, "content": []})
167
+ ```
168
+
169
+ `.text()` and `.marks()` are strict — they only operate on `Text` refs. Chain
170
+ `.leaf()` first to descend from a container.
171
+
172
+ ### Document-level commands
173
+
174
+ ```python
175
+ # Append a node to the document root.
176
+ content.append_root({"type": "paragraph", "attrs": {"id": "p2"}, "content": []})
177
+
178
+ # Replace a node by id (the replacement's attrs.id must match).
179
+ content.replace_by_id("p1", {
180
+ "type": "paragraph",
181
+ "attrs": {"id": "p1"},
182
+ "content": [{"type": "text", "text": "Replaced"}],
183
+ })
184
+ ```
185
+
186
+ ## Text Extraction
187
+
188
+ ```python
189
+ from tiptap_python_utils import Content, text_slices, visible_text, word_count
190
+
191
+ content = Content.require(raw)
192
+
193
+ plain_text = visible_text(content)
194
+ count = word_count(content)
195
+ slices = text_slices(content, context=True)
196
+ ```
197
+
198
+ ## Tasks
199
+
200
+ ```python
201
+ from tiptap_python_utils import Content, has_open_tasks, open_tasks
202
+
203
+ content = Content.require(raw)
204
+
205
+ pending = has_open_tasks(content)
206
+ items = open_tasks(content)
207
+ ```
208
+
209
+ Each `TaskItem` exposes derived state as properties:
210
+
211
+ ```python
212
+ task = open_tasks(content)[0]
213
+
214
+ task.task_item_id # canonical id (falls back to local id)
215
+ task.is_completed # status / checked interpretation
216
+ task.is_linked_copy # True when local id differs from canonical id
217
+ task.shared_id # sharedId attr, if any
218
+ ```
219
+
220
+ ## Shared-Node Synchronization
221
+
222
+ `Content.shared_families()` collects canonical bodies grouped by `sharedId` into
223
+ a `SharedFamilies` value object. `Content.sync_shared(families)` rewrites every
224
+ matching node in the document from those canonical bodies, preserving
225
+ per-instance identity (`id`, `sharedId`). Both return immutable values — the
226
+ original `Content` is never mutated.
227
+
228
+ ```python
229
+ from tiptap_python_utils import Content
230
+
231
+ # Canonical doc: the source of truth for every shared body.
232
+ canonical = Content.require({"type": "doc", "content": [
233
+ {
234
+ "type": "paragraph",
235
+ "attrs": {"id": "p1", "sharedId": "intro"},
236
+ "content": [{"type": "text", "text": "Authoritative intro"}],
237
+ }
238
+ ]})
239
+
240
+ # Doc that mirrors the same sharedId but with a stale body.
241
+ target = Content.require({"type": "doc", "content": [
242
+ {
243
+ "type": "paragraph",
244
+ "attrs": {"id": "p1-copy", "sharedId": "intro"},
245
+ "content": [{"type": "text", "text": "Stale copy"}],
246
+ }
247
+ ]})
248
+
249
+ synced = target.sync_shared(canonical.shared_families())
250
+ assert synced.has_shared("intro")
251
+ ```
252
+
253
+ Related helpers on `Content`:
254
+
255
+ - `content.where_shared_id(sid)` — `Selection` over every node with that sharedId.
256
+ - `content.has_shared(sid)` — quick presence check.
257
+ - `node.with_shared_id(sid)` — stamp a sharedId onto a node (returns a new node).
258
+ - `new_shared_id()` — mint a fresh `shared-…` identifier.
259
+
260
+ ## Architecture (one paragraph)
261
+
262
+ The package is layered: `contract` (key/kind/policy primitives) → `model`
263
+ (immutable AST with a registry of node classes; unknown kinds round-trip via
264
+ `Unknown`) → `codec` (raw I/O in `raw.py`, hydration in `reader.py`, dump in
265
+ `writer.py`) → `walk` & `tree` (traversal + path-based replacement on the
266
+ immutable tree) → `select` (fluent `Selection` — the single home for mutation)
267
+ → `content` (public facade) → `text` / `tasks` / `shared` (user-facing
268
+ workflows built on `Content`). All nodes are `@dataclass(frozen=True)`;
269
+ mutations return new instances.
270
+
271
+ ## Public API
272
+
273
+ Common imports are available from the package root:
274
+
275
+ ```python
276
+ from tiptap_python_utils import (
277
+ Content,
278
+ Paragraph,
279
+ SharedFamilies,
280
+ TaskItem,
281
+ Text,
282
+ has_open_tasks,
283
+ kind,
284
+ new_shared_id,
285
+ open_tasks,
286
+ text_slices,
287
+ visible_text,
288
+ word_count,
289
+ )
290
+ ```
291
+
292
+ ## Stability
293
+
294
+ The project is pre-1.0; minor versions may include breaking changes. See
295
+ [CHANGELOG.md](CHANGELOG.md) for what changed and when.
296
+
297
+ ## Development
298
+
299
+ ```bash
300
+ python -m venv .venv
301
+ . .venv/bin/activate
302
+ python -m pip install -e ".[dev]"
303
+ pytest -q
304
+ ```
305
+
306
+ Build and validate a release artifact:
307
+
308
+ ```bash
309
+ python -m build
310
+ python -m twine check dist/*
311
+ ```
312
+
313
+ ## Contributing
314
+
315
+ Issues and pull requests are welcome. Please read
316
+ [CONTRIBUTING.md](CONTRIBUTING.md) for the local setup and release checklist,
317
+ and open an issue at
318
+ [github.com/tugkanpilka/tiptap-python-utils/issues](https://github.com/tugkanpilka/tiptap-python-utils/issues)
319
+ before larger changes so we can align on the approach.
320
+
321
+ ## License
322
+
323
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,272 @@
1
+ # tiptap_python_utils
2
+
3
+ [![PyPI](https://img.shields.io/pypi/v/tiptap_python_utils.svg)](https://pypi.org/project/tiptap_python_utils/)
4
+ [![Python](https://img.shields.io/pypi/pyversions/tiptap_python_utils.svg)](https://pypi.org/project/tiptap_python_utils/)
5
+ [![CI](https://github.com/tugkanpilka/tiptap-python-utils/actions/workflows/ci.yml/badge.svg)](https://github.com/tugkanpilka/tiptap-python-utils/actions/workflows/ci.yml)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
7
+
8
+ Python utilities for [TipTap](https://tiptap.dev) JSON content.
9
+
10
+ `tiptap_python_utils` parses TipTap documents into typed, immutable Python
11
+ nodes, preserves unknown/custom nodes for lossless round trips, and provides
12
+ small helpers for traversal, immutable edits, visible text extraction, task
13
+ queries, and shared-node synchronization.
14
+
15
+ - **Zero runtime dependencies.** Standard library only.
16
+ - **Python 3.9+.** Tested on 3.9, 3.10, 3.11, 3.12, 3.13.
17
+ - **Lossless round trip.** Unknown node kinds and any extra fields are preserved.
18
+ - **Immutable AST.** All mutations return new instances via a fluent selection API.
19
+
20
+ ## Install
21
+
22
+ ```bash
23
+ pip install tiptap_python_utils
24
+ ```
25
+
26
+ ## Quick Start
27
+
28
+ ```python
29
+ from tiptap_python_utils import Content
30
+
31
+ raw = {
32
+ "type": "doc",
33
+ "content": [
34
+ {
35
+ "type": "paragraph",
36
+ "attrs": {"id": "p1"},
37
+ "content": [{"type": "text", "text": "Old"}],
38
+ }
39
+ ],
40
+ }
41
+
42
+ # Strict-load → descend to the text leaf → write a new value → serialize.
43
+ updated = Content.require(raw).where_id("p1").leaf().text("New").dump()
44
+ ```
45
+
46
+ ## Three Ways to Load a Document
47
+
48
+ | Constructor | When to use | On invalid input |
49
+ |---|---|---|
50
+ | `Content.parse(raw)` | Lenient — `raw` may be `None`, a string, or a dict | Returns a `Content` with `root=None` |
51
+ | `Content.require(raw)` | Strict — input must be a valid TipTap `doc` | Raises `TiptapValidationError` |
52
+ | `Content.wrap(node)` | Auto-wraps a non-doc node into a `doc` root | Raises if the node is not parseable |
53
+
54
+ ## Lossless Round Trip
55
+
56
+ Parsing never silently drops fields. Two mechanisms preserve information:
57
+
58
+ - `Node.extra` stores top-level keys that aren't part of the known schema
59
+ (e.g. custom node attributes, vendor-specific keys).
60
+ - `Node.present` records which structural keys (`attrs`, `content`, …) appeared
61
+ in the raw input, so `raw()` emits empty `attrs: {}` or `content: []` only
62
+ when they were originally present.
63
+ - Unknown node kinds become `Unknown(raw_kind="…")` rather than being rejected.
64
+
65
+ ```python
66
+ from tiptap_python_utils import Content
67
+
68
+ raw = {"type": "doc", "content": [
69
+ {"type": "customPanel", "attrs": {"id": "p1"}, "content": [], "custom": {"x": 1}}
70
+ ]}
71
+
72
+ assert Content.require(raw).to_dict() == raw # byte-for-byte
73
+ ```
74
+
75
+ ## Typed Nodes
76
+
77
+ Build typed nodes directly and serialize them back to TipTap-compatible JSON:
78
+
79
+ ```python
80
+ from tiptap_python_utils import Content, Paragraph, Text
81
+
82
+ node = Paragraph(id="p1", content=(Text(value="Hello"),))
83
+ doc = Content.wrap(node.raw())
84
+ ```
85
+
86
+ ## Selection and Editing
87
+
88
+ The fluent selection API is the single home for mutation. Selection methods
89
+ return a new `Content`; the original is never mutated.
90
+
91
+ ### Select by id or kind
92
+
93
+ ```python
94
+ from tiptap_python_utils import Content, kind
95
+
96
+ # By id (uses TipTap's id resolution rules under the hood).
97
+ content.where_id("p1")
98
+
99
+ # By TipTap kind.
100
+ content.of(kind.PARAGRAPH)
101
+ ```
102
+
103
+ ### Atomic mutations
104
+
105
+ ```python
106
+ # Write an attribute on the selected node.
107
+ content.where_id("p1").attr("color", "blue")
108
+
109
+ # Descend to the first text descendant, then write text or marks.
110
+ content.where_id("p1").leaf().text("Updated")
111
+ content.where_id("p1").leaf().marks([{"type": "bold"}])
112
+
113
+ # Replace the whole selected node, or append a child to it.
114
+ content.where_id("p1").replace({"type": "paragraph", "attrs": {"id": "p1"}, "content": []})
115
+ content.where_id("ul1").append({"type": "listItem", "attrs": {"id": "li-new"}, "content": []})
116
+ ```
117
+
118
+ `.text()` and `.marks()` are strict — they only operate on `Text` refs. Chain
119
+ `.leaf()` first to descend from a container.
120
+
121
+ ### Document-level commands
122
+
123
+ ```python
124
+ # Append a node to the document root.
125
+ content.append_root({"type": "paragraph", "attrs": {"id": "p2"}, "content": []})
126
+
127
+ # Replace a node by id (the replacement's attrs.id must match).
128
+ content.replace_by_id("p1", {
129
+ "type": "paragraph",
130
+ "attrs": {"id": "p1"},
131
+ "content": [{"type": "text", "text": "Replaced"}],
132
+ })
133
+ ```
134
+
135
+ ## Text Extraction
136
+
137
+ ```python
138
+ from tiptap_python_utils import Content, text_slices, visible_text, word_count
139
+
140
+ content = Content.require(raw)
141
+
142
+ plain_text = visible_text(content)
143
+ count = word_count(content)
144
+ slices = text_slices(content, context=True)
145
+ ```
146
+
147
+ ## Tasks
148
+
149
+ ```python
150
+ from tiptap_python_utils import Content, has_open_tasks, open_tasks
151
+
152
+ content = Content.require(raw)
153
+
154
+ pending = has_open_tasks(content)
155
+ items = open_tasks(content)
156
+ ```
157
+
158
+ Each `TaskItem` exposes derived state as properties:
159
+
160
+ ```python
161
+ task = open_tasks(content)[0]
162
+
163
+ task.task_item_id # canonical id (falls back to local id)
164
+ task.is_completed # status / checked interpretation
165
+ task.is_linked_copy # True when local id differs from canonical id
166
+ task.shared_id # sharedId attr, if any
167
+ ```
168
+
169
+ ## Shared-Node Synchronization
170
+
171
+ `Content.shared_families()` collects canonical bodies grouped by `sharedId` into
172
+ a `SharedFamilies` value object. `Content.sync_shared(families)` rewrites every
173
+ matching node in the document from those canonical bodies, preserving
174
+ per-instance identity (`id`, `sharedId`). Both return immutable values — the
175
+ original `Content` is never mutated.
176
+
177
+ ```python
178
+ from tiptap_python_utils import Content
179
+
180
+ # Canonical doc: the source of truth for every shared body.
181
+ canonical = Content.require({"type": "doc", "content": [
182
+ {
183
+ "type": "paragraph",
184
+ "attrs": {"id": "p1", "sharedId": "intro"},
185
+ "content": [{"type": "text", "text": "Authoritative intro"}],
186
+ }
187
+ ]})
188
+
189
+ # Doc that mirrors the same sharedId but with a stale body.
190
+ target = Content.require({"type": "doc", "content": [
191
+ {
192
+ "type": "paragraph",
193
+ "attrs": {"id": "p1-copy", "sharedId": "intro"},
194
+ "content": [{"type": "text", "text": "Stale copy"}],
195
+ }
196
+ ]})
197
+
198
+ synced = target.sync_shared(canonical.shared_families())
199
+ assert synced.has_shared("intro")
200
+ ```
201
+
202
+ Related helpers on `Content`:
203
+
204
+ - `content.where_shared_id(sid)` — `Selection` over every node with that sharedId.
205
+ - `content.has_shared(sid)` — quick presence check.
206
+ - `node.with_shared_id(sid)` — stamp a sharedId onto a node (returns a new node).
207
+ - `new_shared_id()` — mint a fresh `shared-…` identifier.
208
+
209
+ ## Architecture (one paragraph)
210
+
211
+ The package is layered: `contract` (key/kind/policy primitives) → `model`
212
+ (immutable AST with a registry of node classes; unknown kinds round-trip via
213
+ `Unknown`) → `codec` (raw I/O in `raw.py`, hydration in `reader.py`, dump in
214
+ `writer.py`) → `walk` & `tree` (traversal + path-based replacement on the
215
+ immutable tree) → `select` (fluent `Selection` — the single home for mutation)
216
+ → `content` (public facade) → `text` / `tasks` / `shared` (user-facing
217
+ workflows built on `Content`). All nodes are `@dataclass(frozen=True)`;
218
+ mutations return new instances.
219
+
220
+ ## Public API
221
+
222
+ Common imports are available from the package root:
223
+
224
+ ```python
225
+ from tiptap_python_utils import (
226
+ Content,
227
+ Paragraph,
228
+ SharedFamilies,
229
+ TaskItem,
230
+ Text,
231
+ has_open_tasks,
232
+ kind,
233
+ new_shared_id,
234
+ open_tasks,
235
+ text_slices,
236
+ visible_text,
237
+ word_count,
238
+ )
239
+ ```
240
+
241
+ ## Stability
242
+
243
+ The project is pre-1.0; minor versions may include breaking changes. See
244
+ [CHANGELOG.md](CHANGELOG.md) for what changed and when.
245
+
246
+ ## Development
247
+
248
+ ```bash
249
+ python -m venv .venv
250
+ . .venv/bin/activate
251
+ python -m pip install -e ".[dev]"
252
+ pytest -q
253
+ ```
254
+
255
+ Build and validate a release artifact:
256
+
257
+ ```bash
258
+ python -m build
259
+ python -m twine check dist/*
260
+ ```
261
+
262
+ ## Contributing
263
+
264
+ Issues and pull requests are welcome. Please read
265
+ [CONTRIBUTING.md](CONTRIBUTING.md) for the local setup and release checklist,
266
+ and open an issue at
267
+ [github.com/tugkanpilka/tiptap-python-utils/issues](https://github.com/tugkanpilka/tiptap-python-utils/issues)
268
+ before larger changes so we can align on the approach.
269
+
270
+ ## License
271
+
272
+ MIT — see [LICENSE](LICENSE).
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "tiptap_python_utils"
7
- version = "0.1.0"
7
+ version = "0.3.0"
8
8
  description = "Python utilities for parsing, traversing, editing, and serializing TipTap JSON content."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -22,6 +22,7 @@ classifiers = [
22
22
  "Programming Language :: Python :: 3.10",
23
23
  "Programming Language :: Python :: 3.11",
24
24
  "Programming Language :: Python :: 3.12",
25
+ "Programming Language :: Python :: 3.13",
25
26
  "Typing :: Typed",
26
27
  ]
27
28
  dependencies = []
@@ -2,7 +2,6 @@
2
2
 
3
3
  from .contract import key, kind
4
4
  from .content import Content
5
- from .edit import append_node, replace_node
6
5
  from .exceptions import TiptapValidationError
7
6
  from .model import (
8
7
  Blockquote,
@@ -23,15 +22,7 @@ from .model import (
23
22
  )
24
23
  from .contract.policy import content_id, is_parseable, node_id, tiptap_id
25
24
  from .select import Selection
26
- from .shared import (
27
- fingerprint_shared,
28
- has_shared,
29
- new_shared_id,
30
- shared_id,
31
- shared_families,
32
- stamp_shared,
33
- sync_shared,
34
- )
25
+ from .shared import SharedFamilies, fingerprint, new_shared_id
35
26
  from .tasks import has_open_tasks, open_tasks, syncable_tasks
36
27
  from .text import NodeText, text_slices, visible_text, word_count
37
28
  from .walk import Ref, Walker
@@ -53,6 +44,7 @@ __all__ = [
53
44
  "Paragraph",
54
45
  "Ref",
55
46
  "Selection",
47
+ "SharedFamilies",
56
48
  "TableCell",
57
49
  "TaskItem",
58
50
  "TaskList",
@@ -60,11 +52,9 @@ __all__ = [
60
52
  "TiptapValidationError",
61
53
  "Unknown",
62
54
  "Walker",
63
- "append_node",
64
55
  "content_id",
65
- "fingerprint_shared",
56
+ "fingerprint",
66
57
  "has_open_tasks",
67
- "has_shared",
68
58
  "is_parseable",
69
59
  "key",
70
60
  "kind",
@@ -72,11 +62,6 @@ __all__ = [
72
62
  "node_id",
73
63
  "open_tasks",
74
64
  "registry",
75
- "replace_node",
76
- "shared_families",
77
- "shared_id",
78
- "stamp_shared",
79
- "sync_shared",
80
65
  "syncable_tasks",
81
66
  "text_slices",
82
67
  "tiptap_id",
@@ -1,18 +1,19 @@
1
1
  """Raw JSON codec exports."""
2
2
 
3
- from .json import (
4
- dump,
5
- dumps,
3
+ from .raw import (
6
4
  normalize_text,
7
5
  parse_raw,
8
6
  raw_node_id,
9
7
  raw_text,
8
+ require_object,
9
+ )
10
+ from .reader import (
10
11
  read_children,
11
12
  read_doc,
12
13
  read_node,
13
14
  read_node_input,
14
- require_object,
15
15
  )
16
+ from .writer import dump, dumps
16
17
 
17
18
  __all__ = [
18
19
  "dump",