tiptap-python-utils 0.1.0__tar.gz → 0.2.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 (60) hide show
  1. tiptap_python_utils-0.2.0/PKG-INFO +315 -0
  2. tiptap_python_utils-0.2.0/README.md +264 -0
  3. {tiptap_python_utils-0.1.0 → tiptap_python_utils-0.2.0}/pyproject.toml +2 -1
  4. {tiptap_python_utils-0.1.0 → tiptap_python_utils-0.2.0}/src/tiptap_python_utils/__init__.py +0 -3
  5. {tiptap_python_utils-0.1.0 → tiptap_python_utils-0.2.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.2.0/src/tiptap_python_utils/codec/raw.py +8 -46
  7. tiptap_python_utils-0.2.0/src/tiptap_python_utils/codec/reader.py +42 -0
  8. tiptap_python_utils-0.2.0/src/tiptap_python_utils/codec/writer.py +16 -0
  9. {tiptap_python_utils-0.1.0 → tiptap_python_utils-0.2.0}/src/tiptap_python_utils/content.py +12 -0
  10. tiptap_python_utils-0.2.0/src/tiptap_python_utils/model/__init__.py +49 -0
  11. tiptap_python_utils-0.2.0/src/tiptap_python_utils/model/base.py +136 -0
  12. tiptap_python_utils-0.2.0/src/tiptap_python_utils/model/nodes.py +127 -0
  13. tiptap_python_utils-0.2.0/src/tiptap_python_utils/model/payload.py +81 -0
  14. tiptap_python_utils-0.2.0/src/tiptap_python_utils/model/registry.py +57 -0
  15. tiptap_python_utils-0.2.0/src/tiptap_python_utils/select/selection.py +133 -0
  16. {tiptap_python_utils-0.1.0 → tiptap_python_utils-0.2.0}/src/tiptap_python_utils/shared/__init__.py +4 -10
  17. tiptap_python_utils-0.2.0/src/tiptap_python_utils/shared/families.py +47 -0
  18. tiptap_python_utils-0.2.0/src/tiptap_python_utils/shared/fingerprint.py +21 -0
  19. tiptap_python_utils-0.2.0/src/tiptap_python_utils/shared/identity.py +48 -0
  20. tiptap_python_utils-0.2.0/src/tiptap_python_utils/shared/sync.py +63 -0
  21. tiptap_python_utils-0.2.0/src/tiptap_python_utils.egg-info/PKG-INFO +315 -0
  22. {tiptap_python_utils-0.1.0 → tiptap_python_utils-0.2.0}/src/tiptap_python_utils.egg-info/SOURCES.txt +14 -4
  23. tiptap_python_utils-0.2.0/tests/test_codec_raw.py +90 -0
  24. tiptap_python_utils-0.2.0/tests/test_compat_imports.py +129 -0
  25. {tiptap_python_utils-0.1.0 → tiptap_python_utils-0.2.0}/tests/test_content.py +163 -3
  26. {tiptap_python_utils-0.1.0 → tiptap_python_utils-0.2.0}/tests/test_filter.py +1 -2
  27. {tiptap_python_utils-0.1.0 → tiptap_python_utils-0.2.0}/tests/test_mutations.py +15 -22
  28. tiptap_python_utils-0.2.0/tests/test_public_api.py +77 -0
  29. {tiptap_python_utils-0.1.0 → tiptap_python_utils-0.2.0}/tests/test_traverser.py +1 -1
  30. tiptap_python_utils-0.1.0/PKG-INFO +0 -176
  31. tiptap_python_utils-0.1.0/README.md +0 -126
  32. tiptap_python_utils-0.1.0/src/tiptap_python_utils/edit/__init__.py +0 -12
  33. tiptap_python_utils-0.1.0/src/tiptap_python_utils/edit/commands.py +0 -163
  34. tiptap_python_utils-0.1.0/src/tiptap_python_utils/model/__init__.py +0 -345
  35. tiptap_python_utils-0.1.0/src/tiptap_python_utils/select/selection.py +0 -72
  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 → tiptap_python_utils-0.2.0}/LICENSE +0 -0
  39. {tiptap_python_utils-0.1.0 → tiptap_python_utils-0.2.0}/MANIFEST.in +0 -0
  40. {tiptap_python_utils-0.1.0 → tiptap_python_utils-0.2.0}/setup.cfg +0 -0
  41. {tiptap_python_utils-0.1.0 → tiptap_python_utils-0.2.0}/src/tiptap_python_utils/contract/__init__.py +0 -0
  42. {tiptap_python_utils-0.1.0 → tiptap_python_utils-0.2.0}/src/tiptap_python_utils/contract/key.py +0 -0
  43. {tiptap_python_utils-0.1.0 → tiptap_python_utils-0.2.0}/src/tiptap_python_utils/contract/kind.py +0 -0
  44. {tiptap_python_utils-0.1.0 → tiptap_python_utils-0.2.0}/src/tiptap_python_utils/contract/policy.py +0 -0
  45. {tiptap_python_utils-0.1.0 → tiptap_python_utils-0.2.0}/src/tiptap_python_utils/exceptions.py +0 -0
  46. {tiptap_python_utils-0.1.0 → tiptap_python_utils-0.2.0}/src/tiptap_python_utils/py.typed +0 -0
  47. {tiptap_python_utils-0.1.0 → tiptap_python_utils-0.2.0}/src/tiptap_python_utils/select/__init__.py +0 -0
  48. {tiptap_python_utils-0.1.0 → tiptap_python_utils-0.2.0}/src/tiptap_python_utils/tasks/__init__.py +0 -0
  49. {tiptap_python_utils-0.1.0 → tiptap_python_utils-0.2.0}/src/tiptap_python_utils/tasks/query.py +0 -0
  50. {tiptap_python_utils-0.1.0 → tiptap_python_utils-0.2.0}/src/tiptap_python_utils/text/__init__.py +0 -0
  51. {tiptap_python_utils-0.1.0 → tiptap_python_utils-0.2.0}/src/tiptap_python_utils/text/extract.py +0 -0
  52. {tiptap_python_utils-0.1.0 → tiptap_python_utils-0.2.0}/src/tiptap_python_utils/tree/__init__.py +0 -0
  53. {tiptap_python_utils-0.1.0 → tiptap_python_utils-0.2.0}/src/tiptap_python_utils/tree/path.py +0 -0
  54. {tiptap_python_utils-0.1.0 → tiptap_python_utils-0.2.0}/src/tiptap_python_utils/types.py +0 -0
  55. {tiptap_python_utils-0.1.0 → tiptap_python_utils-0.2.0}/src/tiptap_python_utils/walk/__init__.py +0 -0
  56. {tiptap_python_utils-0.1.0 → tiptap_python_utils-0.2.0}/src/tiptap_python_utils/walk/traversal.py +0 -0
  57. {tiptap_python_utils-0.1.0 → tiptap_python_utils-0.2.0}/src/tiptap_python_utils.egg-info/dependency_links.txt +0 -0
  58. {tiptap_python_utils-0.1.0 → tiptap_python_utils-0.2.0}/src/tiptap_python_utils.egg-info/requires.txt +0 -0
  59. {tiptap_python_utils-0.1.0 → tiptap_python_utils-0.2.0}/src/tiptap_python_utils.egg-info/top_level.txt +0 -0
  60. {tiptap_python_utils-0.1.0 → tiptap_python_utils-0.2.0}/tests/test_extract.py +0 -0
@@ -0,0 +1,315 @@
1
+ Metadata-Version: 2.4
2
+ Name: tiptap_python_utils
3
+ Version: 0.2.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
+ `shared_families` collects canonical bodies grouped by `sharedId`;
223
+ `sync_shared` rewrites every matching node in a document using those canonical
224
+ bodies while preserving per-instance identity (`id`, `sharedId`).
225
+
226
+ ```python
227
+ from tiptap_python_utils import shared_families, sync_shared
228
+
229
+ # Canonical doc: the source of truth for every shared body.
230
+ canonical = {"type": "doc", "content": [
231
+ {
232
+ "type": "paragraph",
233
+ "attrs": {"id": "p1", "sharedId": "intro"},
234
+ "content": [{"type": "text", "text": "Authoritative intro"}],
235
+ }
236
+ ]}
237
+
238
+ # Doc that mirrors the same sharedId but with a stale body.
239
+ target = {"type": "doc", "content": [
240
+ {
241
+ "type": "paragraph",
242
+ "attrs": {"id": "p1-copy", "sharedId": "intro"},
243
+ "content": [{"type": "text", "text": "Stale copy"}],
244
+ }
245
+ ]}
246
+
247
+ families = shared_families(canonical)
248
+ synced_json, changed = sync_shared(target, families)
249
+ assert changed is True
250
+ ```
251
+
252
+ ## Architecture (one paragraph)
253
+
254
+ The package is layered: `contract` (key/kind/policy primitives) → `model`
255
+ (immutable AST with a registry of node classes; unknown kinds round-trip via
256
+ `Unknown`) → `codec` (raw I/O in `raw.py`, hydration in `reader.py`, dump in
257
+ `writer.py`) → `walk` & `tree` (traversal + path-based replacement on the
258
+ immutable tree) → `select` (fluent `Selection` — the single home for mutation)
259
+ → `content` (public facade) → `text` / `tasks` / `shared` (user-facing
260
+ workflows built on `Content`). All nodes are `@dataclass(frozen=True)`;
261
+ mutations return new instances.
262
+
263
+ ## Public API
264
+
265
+ Common imports are available from the package root:
266
+
267
+ ```python
268
+ from tiptap_python_utils import (
269
+ Content,
270
+ Paragraph,
271
+ TaskItem,
272
+ Text,
273
+ has_open_tasks,
274
+ kind,
275
+ open_tasks,
276
+ shared_families,
277
+ sync_shared,
278
+ text_slices,
279
+ visible_text,
280
+ word_count,
281
+ )
282
+ ```
283
+
284
+ ## Stability
285
+
286
+ The project is pre-1.0; minor versions may include breaking changes. See
287
+ [CHANGELOG.md](CHANGELOG.md) for what changed and when.
288
+
289
+ ## Development
290
+
291
+ ```bash
292
+ python -m venv .venv
293
+ . .venv/bin/activate
294
+ python -m pip install -e ".[dev]"
295
+ pytest -q
296
+ ```
297
+
298
+ Build and validate a release artifact:
299
+
300
+ ```bash
301
+ python -m build
302
+ python -m twine check dist/*
303
+ ```
304
+
305
+ ## Contributing
306
+
307
+ Issues and pull requests are welcome. Please read
308
+ [CONTRIBUTING.md](CONTRIBUTING.md) for the local setup and release checklist,
309
+ and open an issue at
310
+ [github.com/tugkanpilka/tiptap-python-utils/issues](https://github.com/tugkanpilka/tiptap-python-utils/issues)
311
+ before larger changes so we can align on the approach.
312
+
313
+ ## License
314
+
315
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,264 @@
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
+ `shared_families` collects canonical bodies grouped by `sharedId`;
172
+ `sync_shared` rewrites every matching node in a document using those canonical
173
+ bodies while preserving per-instance identity (`id`, `sharedId`).
174
+
175
+ ```python
176
+ from tiptap_python_utils import shared_families, sync_shared
177
+
178
+ # Canonical doc: the source of truth for every shared body.
179
+ canonical = {"type": "doc", "content": [
180
+ {
181
+ "type": "paragraph",
182
+ "attrs": {"id": "p1", "sharedId": "intro"},
183
+ "content": [{"type": "text", "text": "Authoritative intro"}],
184
+ }
185
+ ]}
186
+
187
+ # Doc that mirrors the same sharedId but with a stale body.
188
+ target = {"type": "doc", "content": [
189
+ {
190
+ "type": "paragraph",
191
+ "attrs": {"id": "p1-copy", "sharedId": "intro"},
192
+ "content": [{"type": "text", "text": "Stale copy"}],
193
+ }
194
+ ]}
195
+
196
+ families = shared_families(canonical)
197
+ synced_json, changed = sync_shared(target, families)
198
+ assert changed is True
199
+ ```
200
+
201
+ ## Architecture (one paragraph)
202
+
203
+ The package is layered: `contract` (key/kind/policy primitives) → `model`
204
+ (immutable AST with a registry of node classes; unknown kinds round-trip via
205
+ `Unknown`) → `codec` (raw I/O in `raw.py`, hydration in `reader.py`, dump in
206
+ `writer.py`) → `walk` & `tree` (traversal + path-based replacement on the
207
+ immutable tree) → `select` (fluent `Selection` — the single home for mutation)
208
+ → `content` (public facade) → `text` / `tasks` / `shared` (user-facing
209
+ workflows built on `Content`). All nodes are `@dataclass(frozen=True)`;
210
+ mutations return new instances.
211
+
212
+ ## Public API
213
+
214
+ Common imports are available from the package root:
215
+
216
+ ```python
217
+ from tiptap_python_utils import (
218
+ Content,
219
+ Paragraph,
220
+ TaskItem,
221
+ Text,
222
+ has_open_tasks,
223
+ kind,
224
+ open_tasks,
225
+ shared_families,
226
+ sync_shared,
227
+ text_slices,
228
+ visible_text,
229
+ word_count,
230
+ )
231
+ ```
232
+
233
+ ## Stability
234
+
235
+ The project is pre-1.0; minor versions may include breaking changes. See
236
+ [CHANGELOG.md](CHANGELOG.md) for what changed and when.
237
+
238
+ ## Development
239
+
240
+ ```bash
241
+ python -m venv .venv
242
+ . .venv/bin/activate
243
+ python -m pip install -e ".[dev]"
244
+ pytest -q
245
+ ```
246
+
247
+ Build and validate a release artifact:
248
+
249
+ ```bash
250
+ python -m build
251
+ python -m twine check dist/*
252
+ ```
253
+
254
+ ## Contributing
255
+
256
+ Issues and pull requests are welcome. Please read
257
+ [CONTRIBUTING.md](CONTRIBUTING.md) for the local setup and release checklist,
258
+ and open an issue at
259
+ [github.com/tugkanpilka/tiptap-python-utils/issues](https://github.com/tugkanpilka/tiptap-python-utils/issues)
260
+ before larger changes so we can align on the approach.
261
+
262
+ ## License
263
+
264
+ 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.2.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,
@@ -60,7 +59,6 @@ __all__ = [
60
59
  "TiptapValidationError",
61
60
  "Unknown",
62
61
  "Walker",
63
- "append_node",
64
62
  "content_id",
65
63
  "fingerprint_shared",
66
64
  "has_open_tasks",
@@ -72,7 +70,6 @@ __all__ = [
72
70
  "node_id",
73
71
  "open_tasks",
74
72
  "registry",
75
- "replace_node",
76
73
  "shared_families",
77
74
  "shared_id",
78
75
  "stamp_shared",
@@ -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",