CLAUDE.md
May 28, 2026 · View on GitHub
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Commands
python -m pip install -e ".[dev]" # install with dev extras
pytest -q # run full suite (also: `make test`)
pytest tests/test_content.py -q # single file
pytest tests/test_content.py::test_name -q # single test
python -m build # build sdist + wheel (also: `make build`)
python -m twine check dist/* # validate artifact (also: `make check`)
make clean # remove build/dist/egg-info/.pytest_cache
pyproject.toml sets pythonpath = ["src"], so pytest works without installing. Python ≥ 3.9. No runtime dependencies.
Architecture
The package converts raw TipTap JSON to a typed, immutable AST and back, preserving unknown fields for lossless round-trip. Layering (lowest to highest):
contract/— Raw JSON contract.key(field names liketype/attrs/content),kind(node kind strings likeparagraph/taskItem),policy(identity rules:content_id,node_id,tiptap_id,shared_id,is_parseable).model/— Frozen dataclass AST.Nodeis the base; concrete classes (Paragraph,Heading,TaskItem,Text,Doc, list/container nodes) register themselves inregistry.Unknowncaptures any kind not in the registry — this is how round-trip preservation works. EveryNodecarriesextra(unknown top-level fields) andpresent(which keys appeared in raw input), soraw()re-emits the same shape it parsed.codec/json.py— Raw JSON ↔ model boundary.parse_raw,read_doc,read_node,read_childrengo raw→typed viaregistry.read;dump/dumpsgo typed→raw.tree/path.py—node_at_path/replace_at_pathoperate on tuple paths(int, int, ...)into the immutable tree.walk/traversal.py—Walkerdoes depth-first iteration.Ref(node, path, parent_kind)is the addressable handle aSelectionworks against.Ref.parseablehonorspolicy.is_parseable(used to skip non-selectable kinds like raw text).select/selection.py—Selectionis the fluent edit API and the single home for mutation. Atomic methods (.text,.marks,.attr,.append,.replace,.set) callNode/Textprimitives directly..textand.marksare strict — they require Text refs; callers chain.leaf()first to descend._applysorts refs by path length descending so deeper edits land before shallower ones shift their paths..set(name, value)round-trips through codec so subclass-typed fields (e.g.Heading.level) re-hydrate; this is the OCP-respecting escape hatch.content.py—Contentis the public facade. Three constructors with different strictness:parse(lenient, allowsNone),require(must be a validdoc),wrap(auto-wraps a non-doc node in adoc).where_id(id)andof(kind)returnSelections.append_root(node)andreplace_by_id(id, node)are the document-level entry points; they compose Selection chains internally.text/,tasks/,shared/— User-facing workflows built onContent.shared/service.pyhandles synchronization of nodes that share asharedIdacross the document (fingerprint + merge).
Round-trip invariant
Parsing must not silently drop fields. The mechanism:
Node.extrastores top-level keys other thantype/attrs/content(and per-node known keys liketext/marksforText).Node.presentrecords which structural keys appeared in the raw input, soraw()emits emptyattrs: {}orcontent: []only when they were originally present.- Unknown kinds become
Unknown(raw_kind=…)rather than being rejected.
When adding behavior to Node.raw() or subclasses, do not lose extra or violate present semantics — tests/test_content.py exercises round-trip cases.
Identity model
Multiple identity sources exist; contract/policy.py is the single source of truth:
content_id— generic node ID from attrs.tiptap_id— TipTap's own attr key.node_id— resolution between the two.shared_id— used byshared/to link copies of the same logical node.TaskItemadditionally trackslocal_task_item_id,canonical_task_item_id, andis_linked_copyto model linked-copy tasks.
When selecting by ID, prefer Content.where_id, which uses selection_id (in walk/traversal.py) — that helper unifies the rules.
Immutability
All nodes are @dataclass(frozen=True). Mutations always return new instances via dataclasses.replace or Node.with_* helpers. Selection._apply rebuilds the path from leaf upward. Do not mutate attrs/extra dicts in place — deepcopy before changing.
Known architecture debt
docs/architecture-audit.md (dated 2026-05-28) is an internal audit. Status:
- Phase 1 (model split into
base/nodes/registry/payloadre-exports): done. - Phase 2 (edit layer): done — different shape than the audit proposed. The audit suggested splitting
edit/commands.py; the actual outcome was deletingedit/entirely. Selection methods now call Node primitives directly;append_node/replace_nodebecameContent.append_root/Content.replace_by_id. The audit's text on Phase 2 is stale. - Phase 3 (
codec/json.pysplit into raw I/O + AST hydration): done.codec/raw.pyholds JSON parsing + dict-shape helpers with zero..modeldependency;codec/reader.pyhydrates the typed AST;codec/writer.pydumps. The barrelcodec/__init__.pyre-exports the same 11 names.tests/test_codec_raw.pyexercises the raw layer in isolation. - Phase 4 (
shared/aligned withContent/Selectionchain): done — full breaking rewrite. The dict-in/dict-out functional API (shared_families,sync_shared,has_shared,shared_id,stamp_shared,fingerprint_shared,normalize_shared_id) is gone. New shape:Node.with_shared_id(value)mirrorswith_attr;Content.where_shared_id(sid) -> Selection,Content.has_shared(sid),Content.shared_families() -> SharedFamilies,Content.sync_shared(families) -> Contentare the entry points.SharedFamilies(inshared/families.py) is an immutable value object indexed by sharedId with canonicalNodebodies and a.merge(target)helper that preserves local id/sharedId via codec round-trip.shared/fingerprint.pynow takes aNode(not a raw dict).shared/identity.pycollapsed to justnew_shared_id().shared/sync.pydeleted — sync lives onContent, which usesSelection.transform(fn)(new general-purpose primitive) to apply per-node rewrites.shared/__init__.pyexportsSharedFamilies,fingerprint,new_shared_id. - Phase 5 (public API classification —
content_id,is_parseable,EMPTY_DOCUMENT_CONTENTetc.): pending.
Compatibility requirement for any refactor: keep from tiptap_python_utils import Paragraph, from tiptap_python_utils.model import ContentTuple, and from tiptap_python_utils.model import registry working. Public API snapshot lives in tests/test_public_api.py and tests/test_compat_imports.py.
Release
Trusted publishing to PyPI via the publish.yml GitHub workflow on tag push (vX.Y.Z). Full checklist in docs/release.md and CONTRIBUTING.md. Bump pyproject.toml version and update CHANGELOG.md before tagging.