Skip to main content

Pure-Python utilities for processing TipTap JSON on the server side. Parse, traverse, edit, and serialize TipTap documents — no JavaScript bridge required. Zero runtime dependencies, Python 3.9+.

Project description

tiptap_python_utils

PyPI Python CI License: MIT

TipTap is a JavaScript editor. If your backend is Python and you need to process TipTap JSON — extract text, query tasks, sync shared nodes — this library does it in pure Python with zero dependencies. No JS bridge, no Node.js subprocess.

Quick Start

from tiptap_python_utils import Content

raw = {
    "type": "doc",
    "content": [
        {
            "type": "paragraph",
            "attrs": {"id": "p1"},
            "content": [{"type": "text", "text": "Old"}],
        }
    ],
}

# Strict-load → descend to the text leaf → write a new value → serialize.
updated = Content.require(raw).where_id("p1").leaf().text("New").dump()

Features

  • Zero runtime dependencies. Standard library only.
  • Python 3.9+. Tested on 3.9, 3.10, 3.11, 3.12, 3.13.
  • Lossless round trip. Unknown node kinds and any extra fields are preserved.
  • Immutable AST. All mutations return new instances via a fluent selection API.

Install

pip install tiptap_python_utils

Three Ways to Load a Document

Pick a constructor by how much you trust the input — lenient, strict, or auto-wrapping a bare node into a doc.

Constructor When to use On invalid input
Content.parse(raw) Lenient — raw may be None, a string, or a dict Returns a Content with root=None
Content.require(raw) Strict — input must be a valid TipTap doc Raises TiptapValidationError
Content.wrap(node) Auto-wraps a non-doc node into a doc root Raises if the node is not parseable

Lossless Round Trip

Parsing never silently drops fields — custom nodes and unknown keys survive a parse-then-serialize cycle byte-for-byte. Two mechanisms preserve information:

  • Node.extra stores top-level keys that aren't part of the known schema (e.g. custom node attributes, vendor-specific keys).
  • Node.present records which structural keys (attrs, content, …) appeared in the raw input, so raw() emits empty attrs: {} or content: [] only when they were originally present.
  • Unknown node kinds become Unknown(raw_kind="…") rather than being rejected.
from tiptap_python_utils import Content

raw = {"type": "doc", "content": [
    {"type": "customPanel", "attrs": {"id": "p1"}, "content": [], "custom": {"x": 1}}
]}

assert Content.require(raw).to_dict() == raw  # byte-for-byte

Typed Nodes

Build typed nodes directly in Python and serialize them back to TipTap-compatible JSON.

from tiptap_python_utils import Content, Paragraph, Text

node = Paragraph(id="p1", content=(Text(value="Hello"),))
doc = Content.wrap(node.raw())

Selection and Editing

The fluent selection API is the single home for mutation: every method returns a new Content, so the original is never mutated.

Select by id or kind

from tiptap_python_utils import Content, kind

# By id (uses TipTap's id resolution rules under the hood).
content.where_id("p1")

# By TipTap kind.
content.of(kind.PARAGRAPH)

# By an arbitrary predicate over every node (and its descendants).
content.where(lambda node: getattr(node, "level", None) == 1)

Generic queries

Selection carries two predicate primitives that work for any kind, so you don't need a bespoke has_heading_text-style helper per node type:

# Narrow a selection further.
content.of(kind.HEADING).filter(lambda n: n.level == 2)

# Existence check (short-circuits).
content.of(kind.HEADING).any(lambda n: n.text.strip() == "Introduction")

Atomic mutations

# Write an attribute on the selected node.
content.where_id("p1").attr("color", "blue")

# Descend to the first text descendant, then write text or marks.
content.where_id("p1").leaf().text("Updated")
content.where_id("p1").leaf().marks([{"type": "bold"}])

# Replace the whole selected node, or append a child to it.
content.where_id("p1").replace({"type": "paragraph", "attrs": {"id": "p1"}, "content": []})
content.where_id("ul1").append({"type": "listItem", "attrs": {"id": "li-new"}, "content": []})

.text() and .marks() are strict — they only operate on Text refs. Chain .leaf() first to descend from a container.

Document-level commands

# Append a node to the document root.
content.append_root({"type": "paragraph", "attrs": {"id": "p2"}, "content": []})

# Build-and-append in one call — works for any kind, stamps a fresh id when
# none is given. Typed fields (e.g. Heading.level) hydrate correctly.
content.append(kind.HEADING, "New section", attrs={"level": 2})
content.append(kind.PARAGRAPH, "Body text", node_id="p3")

# Replace a node by id (the replacement's attrs.id must match).
content.replace_by_id("p1", {
    "type": "paragraph",
    "attrs": {"id": "p1"},
    "content": [{"type": "text", "text": "Replaced"}],
})

Text Extraction

Pull the visible plain text out of a document — useful for search indexing, word counts, or previews.

from tiptap_python_utils import Content, text_slices, visible_text, word_count

content = Content.require(raw)

plain_text = visible_text(content)
count = word_count(content)
slices = text_slices(content, context=True)

Tasks

Query task lists in a document — find every task item or check whether any are still open.

from tiptap_python_utils import Content, has_open_tasks, open_tasks

content = Content.require(raw)

pending = has_open_tasks(content)
items = open_tasks(content)

Each TaskItem exposes derived state as properties:

task = open_tasks(content)[0]

task.task_item_id       # canonical id (falls back to local id)
task.is_completed       # status / checked interpretation
task.is_linked_copy     # True when local id differs from canonical id
task.shared_id          # sharedId attr, if any

Shared-Node Synchronization

Keep copies of the same logical node (linked by sharedId) in sync — collect canonical bodies, then rewrite every matching node from them.

Content.shared_families() collects canonical bodies grouped by sharedId into a SharedFamilies value object. Content.sync_shared(families) rewrites every matching node in the document from those canonical bodies, preserving per-instance identity (id, sharedId). Both return immutable values — the original Content is never mutated.

from tiptap_python_utils import Content

# Canonical doc: the source of truth for every shared body.
canonical = Content.require({"type": "doc", "content": [
    {
        "type": "paragraph",
        "attrs": {"id": "p1", "sharedId": "intro"},
        "content": [{"type": "text", "text": "Authoritative intro"}],
    }
]})

# Doc that mirrors the same sharedId but with a stale body.
target = Content.require({"type": "doc", "content": [
    {
        "type": "paragraph",
        "attrs": {"id": "p1-copy", "sharedId": "intro"},
        "content": [{"type": "text", "text": "Stale copy"}],
    }
]})

synced = target.sync_shared(canonical.shared_families())
assert synced.has_shared("intro")

Related helpers on Content:

  • content.where_shared_id(sid)Selection over every node with that sharedId.
  • content.has_shared(sid) — quick presence check.
  • node.with_shared_id(sid) — stamp a sharedId onto a node (returns a new node).
  • new_shared_id() — mint a fresh shared-… identifier.

Public API

Common imports are available from the package root:

from tiptap_python_utils import (
    Content,
    Paragraph,
    SharedFamilies,
    TaskItem,
    Text,
    has_open_tasks,
    kind,
    new_node_id,
    new_shared_id,
    open_tasks,
    text_slices,
    visible_text,
    word_count,
)

Contributing

Issues and pull requests are welcome. Please read CONTRIBUTING.md for the local setup, architecture overview, and release checklist, and open an issue at github.com/tugkanpilka/tiptap-python-utils/issues before opening a pull request so we can align on the approach.

License

MIT — see LICENSE.

Stability

The project is pre-1.0; minor versions may include breaking changes. See CHANGELOG.md for what changed and when.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

tiptap_python_utils-0.6.0.tar.gz (34.1 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

tiptap_python_utils-0.6.0-py3-none-any.whl (30.0 kB view details)

Uploaded Python 3

File details

Details for the file tiptap_python_utils-0.6.0.tar.gz.

File metadata

  • Download URL: tiptap_python_utils-0.6.0.tar.gz
  • Upload date:
  • Size: 34.1 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for tiptap_python_utils-0.6.0.tar.gz
Algorithm Hash digest
SHA256 92e851421276d5d428668d706f5275a374513691f88fb49175ce6f8ca3519e97
MD5 e17ab9e4f2e91f1973fdb35c0073f0ef
BLAKE2b-256 f0ade3deb7cebefb9cf75c9b9aee687f2590e033a12e22637aad2b6f9d0b1ed6

See more details on using hashes here.

Provenance

The following attestation bundles were made for tiptap_python_utils-0.6.0.tar.gz:

Publisher: publish.yml on tugkanpilka/tiptap-python-utils

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file tiptap_python_utils-0.6.0-py3-none-any.whl.

File metadata

File hashes

Hashes for tiptap_python_utils-0.6.0-py3-none-any.whl
Algorithm Hash digest
SHA256 850fc50641eb0bbeac4932f73a6ed61a5fd291a18776036fc75f512319790544
MD5 e6f408154ffffd367c5fc0c1f1b7df2e
BLAKE2b-256 5ff501acdb51440a4cc974d32cf96b00b843d31b9e420f02d94449e8fcb6f90a

See more details on using hashes here.

Provenance

The following attestation bundles were made for tiptap_python_utils-0.6.0-py3-none-any.whl:

Publisher: publish.yml on tugkanpilka/tiptap-python-utils

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page