tiptap's markdown layer carries the highest activity risk — 5 functions to address first

Four of tiptap's five highest-risk functions live in the markdown parsing layer, all fire-quadrant with CC values up to 37 and nesting depths up to 9, touched within the last 17 days.

Stephen Collins ·
Generated by hotspots · free & open source
Activity Risk16.12Low
Hottest Functiontokenize

Antipatterns Detected

complex_branching5god_function5long_function5deeply_nested3exit_heavy3middle_man1

Run this on your own codebase

Hotspots runs locally in under a minute — no account, no data leaves your machine.

pip
$ pip install hotspots-cli
npm
$ npm install -g @stephencollinstech/hotspots
Run in any repo
$ hotspots analyze .
★ Star on GitHub

Key Points

What is a god function and why does it matter in tiptap?

A god function is a single function that takes on too many distinct responsibilities — in structural terms, it calls a large number of other functions (high fan-out) and contains many independent execution paths (high cyclomatic complexity). In tiptap, all five top hotspots exhibit this pattern, with fan-out values reaching 20 and cyclomatic complexity values up to 37. The concrete problem is coupling: when `parseListToken` calls 20 distinct functions, a change to any one of those callees might require coordinated changes inside `parseListToken` itself, and reasoning about whether a given change is safe requires mentally simulating all 22 of its branching paths. God functions are also hard to test in isolation — you cannot exercise a single responsibility without triggering the others.

How do I reduce cyclomatic complexity in TypeScript?

The most direct technique is extract-method refactoring: identify a cohesive block of logic inside the function — a nested loop, a lookahead scan, a transformation pipeline — and move it to a named helper function with a clear return type. A cyclomatic complexity above 15 is a reasonable threshold for scheduling an extraction; above 30 it warrants immediate action. For `parseInlineTokens`, which has a CC of 37, I would start by extracting the HTML fragment reassembly logic into `mergeInlineHTMLFragment(tokens, startIndex)` — that single extraction removes the inner lookahead loop, collapsing both the nesting depth and the CC of the parent function by a meaningful margin. In TypeScript specifically, replacing multi-branch `if/else if` chains over a discriminated union with a type-keyed handler map (a plain object of functions indexed by token type) eliminates branches from the parent function while preserving the type safety that TypeScript conditional narrowing provides.

Is tiptap actively maintained?

Yes — every function in the top five is in the fire quadrant, meaning structurally complex and recently changed. `parseInlineTokens` and `parseListToken` were each touched twice in the last 30 days; `tokenize`, `parseIndentedBlocks`, and `findBestDragTarget` were each touched once, all within 17 days of the analyzed commit. There are 308 fire-quadrant functions across the codebase, and zero in the debt quadrant — nothing is being left untouched indefinitely. High structural complexity and active maintenance are not contradictions: the markdown parsing layer is complex precisely because it is being extended to handle more edge cases, and every recorded commit to the markdown files so far has been a correction rather than a new feature, which indicates the team is actively fixing discovered issues.

How do I reproduce this analysis?

The Hotspots CLI is available at https://github.com/hotspots-dev/hotspots. After installing it, check out the analyzed commit with `git checkout c4c5e16` inside a local clone of ueberdosis/tiptap, then run `hotspots analyze . --mode snapshot --explain-patterns --force`. The same command works on any local git repository without additional configuration — no config file is required to get started.

What does activity-weighted risk mean?

Activity-weighted risk combines a function's structural complexity — derived from cyclomatic complexity, nesting depth, and fan-out — with how frequently the function has been touched by recent commits. A function with a cyclomatic complexity of 80 that has not been modified in two years scores much lower than one with a cyclomatic complexity of 20 that is being changed every week, because the dormant function poses lower near-term regression risk: no one is currently introducing bugs into it. The practical implication is that this score tells you where to spend refactoring effort to reduce the probability of bugs being introduced right now, not just where the code looks complicated in the abstract. In tiptap's case, `parseInlineTokens` scores 15.7 not only because its CC is 37 but because it was touched twice in the last 30 days — making every subsequent commit to it a live regression risk.

Across 2,733 functions in ueberdosis/tiptap at commit c4c5e16, Hotspots flagged 84 as critical — and the five with the highest activity-weighted risk scores all land in the fire quadrant, meaning they are both structurally complex and actively changing right now. The top-ranked function, tokenize in createBlockMarkdownSpec.ts, carries an activity-weighted risk score of 16.12 with a cyclomatic complexity of 16, a max nesting depth of 7, and was touched 17 days ago. I would start there because it sits at the entry point of tiptap’s custom block markdown tokenization, where a subtle branching error cascades into everything downstream. The pattern is consistent across all five hotspots: the markdown and drag-handle subsystems are where structural complexity and recent commit activity converge into live regression risk.

The table below ranks functions by activity-weighted risk — a score that multiplies structural complexity by recent commit frequency. A function that is both hard to understand (high cyclomatic complexity) and actively changing is a higher priority than one that is complex but untouched. CC = cyclomatic complexity (independent execution paths); ND = max nesting depth; FO = fan-out (distinct callees).

Top 5 Hotspots

FunctionFileRiskCCNDFO
tokenizepackages/core/src/utilities/markdown/createBlockMarkdownSpec.ts16.116711
parseInlineTokenspackages/markdown/src/MarkdownManager.ts15.737910
parseListTokenpackages/markdown/src/MarkdownManager.ts15.322620
parseIndentedBlockspackages/core/src/utilities/markdown/parseIndentedBlocks.ts15.119420
findBestDragTargetpackages/extension-drag-handle/src/helpers/findBestDragTarget.ts14.717415

Large Repo Analysis

tiptap is a large repository. To stay within memory constraints, this analysis used hybrid touch mode: structural complexity — CC, ND, FO — is measured precisely for every function. Git activity is tracked at the function level (via git log -L) only for files with 5 or more commits in the last 30 days; other files use a file-level approximation. Rankings therefore surface functions that are both structurally complex and in the most actively-changing parts of the codebase. Dormant code with high structural complexity will rank lower than it would under a full per-function analysis — to surface it, run hotspots analyze . --per-function-touches on a machine with sufficient memory.

Quadrant and Pattern Overview

Triage Band Distribution
Fire308Watch2425

2,733 functions analyzed

Every function in this codebase falls into either the fire or watch quadrant — there is no dormant structural debt to speak of. The 308 fire-quadrant functions are actively changing and structurally complex. The risk is concentrated: 84 functions are critical, 224 are high. The markdown layer accounts for three of the top five hotspots, and every one of them is in motion.

Detected Antipatterns
Complex Branching×5Complex Branching
High cyclomatic complexity — many independent execution paths, each a potential bug surface and required test case.
God Function×5God Function
Calls an unusually large number of distinct functions (high fan-out), making it the structural centre of gravity for a subsystem.
Long Function×5Long Function
Function body is too long to review in a single pass; likely contains multiple distinct responsibilities.
Deeply Nested×3Deeply Nested
Control structures nested 4+ levels deep, making it hard to reason about the full execution state at inner branches.
Exit Heavy×3Exit Heavy
Multiple return or throw paths dispersed through the body — each exit needs separate test coverage.
Middle Man×1Middle Man
Mostly delegates to one other function without adding meaningful logic — a refactoring candidate for removal or consolidation.

Every function in the top five exhibits both god_function and long_function characteristics — they each handle more cases than a single function should, and all five also show complex_branching. Three additionally carry deeply_nested or exit_heavy patterns. This is not a coincidence: markdown parsing is inherently stateful and case-driven, but that pressure has been absorbed into monolithic functions rather than distributed across smaller, testable units.


tokenize — createBlockMarkdownSpec.ts

tokenize
packages/core/src/utilities/markdown/createBlockMarkdownSpec.ts
16.12
critical
CC 16
ND 7
FO 11
touches/30d 1

This function is responsible for recognizing and consuming custom fenced block syntax (the :::blockName delimiter pattern) from a raw markdown source string and producing a structured token for the rest of the pipeline. From the source excerpt, it does this by running a regex to detect an opening tag, then entering an unbounded loop that scans for matching closing delimiters while tracking nesting level manually. When it finds the closing tag, it dispatches to either lexer.blockTokens or lexer.inlineTokens depending on the content mode, and then post-processes the result to strip trailing empty paragraphs.

The cyclomatic complexity of 16 understates the actual decision surface here. In TypeScript, the optional chaining on match[2]?.endsWith(':::') and the multiple continue/break paths through the for (;;) loop add implicit control flow that CC doesn’t fully capture. The max nesting depth of 7 is the more telling signal: the deepest logic — deciding whether to trim and which tokenizer branch to call — is buried inside nested conditionals inside the loop, making it hard to reason about invariants at each level. The exit-heavy pattern (multiple early return undefined paths plus break and continue inside the loop) means test coverage requires exercising at least a dozen distinct paths to reach full branch coverage.

The single commit touching this file in the last 30 days, 17 days ago, combined with every recorded commit to this file being a fix rather than a new feature, tells me the file has been corrected repeatedly. That is a meaningful historical signal: the file has been corrected, not extended.

Recommendation: Extract the nesting-level scanner into a standalone findMatchingClose(src, position, blockPattern) function that returns a {matchedContent, fullMatch} result or null. That single extraction removes the deepest nesting levels and isolates the most error-prone logic — the level-tracking loop — into a unit that can be tested independently with a table of nested and malformed inputs.


parseInlineTokens — MarkdownManager.ts

parseInlineTokens
packages/markdown/src/MarkdownManager.ts
15.7
critical
CC 37
ND 9
FO 10
touches/30d 2

With a cyclomatic complexity of 37 and a max nesting depth of 9, parseInlineTokens is the most structurally complex function in the top five. It was touched twice in the last 30 days, most recently 17 days ago, putting it squarely in the fire quadrant. The function walks an array of MarkdownToken objects and converts them into JSONContent nodes for the ProseMirror document model. What makes it genuinely hard is the HTML fragment reassembly logic: when the tokenizer splits an inline HTML element like <em>text</em> across multiple tokens, this function has to detect the opening tag, scan forward through subsequent tokens to find the matching closing tag, merge the raw fragments back into a single string, parse the merged result, and then advance the loop index past all the consumed tokens.

That lookahead-and-merge path creates a secondary loop nested inside the primary token-walking loop, which is where the nesting depth of 9 comes from. Each conditional on token.type'text', 'escape', 'html', and presumably others in the full function — is a branch. At CC 37, there are 37 independent execution paths, each of which is a required test case. In TypeScript, the type narrowing happening inside each branch (e.g., casting parsedSingle through normalizeParseResult) adds further implicit paths that the CC metric doesn’t count.

This file has accumulated more PR review comments per commit than any other file in the top five — a concrete signal that reviewers have found this code worth discussing at length. Every recorded commit to the file so far has been a correction, not a new feature.

Recommendation: The HTML fragment reassembly logic is the clearest extraction candidate — pull it into a mergeInlineHTMLFragment(tokens, startIndex) function that returns the merged JSONContent nodes and the new index. This reduces the nesting depth of the main loop immediately and makes the lookahead algorithm independently testable. After that extraction, the remaining token.type dispatch can be converted into a registry of per-type handlers, which brings the CC of the main function down below 10.


parseListToken — MarkdownManager.ts

parseListToken
packages/markdown/src/MarkdownManager.ts
15.33
critical
CC 22
ND 6
FO 20
touches/30d 2

This function sits in the same file as parseInlineTokens and shares its commit history — two touches in 30 days, 17 days since last changed, every recorded commit a correction, and the same high review comment density as parseInlineTokens. Its fan-out of 20 is the second highest in the top five and is the metric I would emphasize here: 20 distinct function calls means a change to parseListToken can trigger unexpected ripple effects across a wide surface of the codebase.

From the source excerpt, the function’s complexity comes from a specific and non-trivial problem: a markdown list can contain a mix of plain list items and task-list items (- [ ] text), and tiptap needs to split that mixed list into separate sub-lists of each type before mapping them to ProseMirror nodes. The function detects this mixed case, then manually groups items by type, transforms task items into a custom taskItem token (reconstructing their raw content from line-splitting and indentation stripping), and finally uses the lexer to re-parse nested content. Each of those steps is implemented inline, which is why the CC reaches 22 and the nesting depth reaches 6.

The god-function pattern is particularly visible here: the function is doing type detection, item grouping, token transformation, indentation normalization, and recursive lexing all in one body. A change to how indentation is stripped — a plausible follow-up to any of the recent fix commits — has to navigate all of that simultaneously.

Recommendation: Extract the task-item transformation — the block that splits raw content by lines, strips common indentation, and reconstructs a taskItem token — into a dedicated transformToTaskItemToken(item, lexer) function. That alone removes the deepest nesting in the grouping loop and reduces the fan-out of parseListToken by consolidating several of the 20 callees into the extracted function. The mixed-list splitting logic can then be read as a straightforward group-and-map pipeline.


parseIndentedBlocks — parseIndentedBlocks.ts

parseIndentedBlocks
packages/core/src/utilities/markdown/parseIndentedBlocks.ts
15.06
critical
CC 19
ND 4
FO 20
touches/30d 1

parseIndentedBlocks implements a line-by-line parser for indented block structures — think task lists or custom indented nodes where each item can have arbitrarily indented nested content. From the source excerpt, it walks the source string line by line using a while loop, matches each line against a configurable itemPattern, then enters a secondary lookahead loop to collect indented continuation lines and empty lines that might be part of the same nested block. Once it has collected an item’s full content, it calls lexer.inlineTokens or lexer.blockTokens to parse it.

The fan-out of 20 matches parseListToken’s and points to the same architectural pressure: a general-purpose parser that has accumulated calls to many downstream utilities. The exit-heavy pattern — multiple return undefined paths for cases where the input doesn’t match — combined with break and continue inside two nested while loops means the control flow is branchy even though the max nesting depth of 4 looks modest on its own. CC 19 confirms there are 19 independent paths, each of which requires a distinct test input to exercise.

This file has a single author in the last 90 days and zero PR review comments, which contrasts with MarkdownManager.ts’s dense review history. That makes it a slightly more isolated risk: the logic hasn’t been stress-tested by multiple reviewers the way the inline and list parsing has.

Recommendation: The empty-line lookahead block — the inner while loop that peeks ahead to determine whether an empty line is a separator or a continuation — is a good first extraction target. A findContinuationEnd(lines, fromIndex, currentIndentLevel) helper would make the intent explicit and remove the most confusing branching from the main loop body. Given the fan-out of 20, I would also audit which of those 20 callees are truly distinct responsibilities versus helper utilities that could be consolidated.


findBestDragTarget — findBestDragTarget.ts

findBestDragTarget
packages/extension-drag-handle/src/helpers/findBestDragTarget.ts
14.66
critical
CC 17
ND 4
FO 15
touches/30d 1

This function is the outlier in the top five — it lives in the drag-handle extension rather than the markdown layer, and its job is to determine which ProseMirror document node a drag handle should attach to given a set of screen coordinates. From the source excerpt, it works by resolving the cursor position from coordinates, walking up the document tree from the deepest ancestor to the root, evaluating each candidate node against a configurable set of DragHandleRule objects, scoring each candidate, and then also checking for atom nodes (like images) that the ancestor walk would miss because they sit at $pos.nodeAfter rather than in the ancestor chain.

The CC of 17 comes from the rule evaluation logic, the allowedContainers check, the atom-node special case, and several guard conditions at the top of the function. The fan-out of 15 reflects the breadth of what this function touches: coordinate validation, ProseMirror position resolution, document traversal, rule application, DOM node lookup, and the atom-node fallback path. The exit-heavy pattern (early returns for invalid coordinates, no position info, negative scores, null candidates) is correct defensive programming here but still represents paths that need test coverage.

The context-only data shows that edgeBlockRect and keydown in the same extension-drag-handle package have been touched 2 and 3 times respectively in the last 30 days, both changed just 2 days ago. That tells me the drag-handle extension is under active development right now, which raises the practical urgency of findBestDragTarget’s complexity — it is the core decision function for a subsystem that is currently being iterated on.

Recommendation: Extract the candidate-building logic — the depthLevels.map(...) block that resolves each ancestor node, checks container constraints, builds the rule context, calculates the score, and fetches the DOM element — into a buildCandidates($pos, options, rules, coords, view) function. The atom-node check for $pos.nodeAfter can then be a clearly separated second step. This reduces findBestDragTarget to coordinate validation, candidate collection, and winner selection, which is the level of abstraction the function name implies.


Summary

The markdown parsing layer — MarkdownManager.ts and the utilities under packages/core/src/utilities/markdown/ — is where I would concentrate the first refactoring sprint. Three of the top four functions live there, all are fire-quadrant, all have a commit history that is entirely corrective, and two of them share a file with unusually high PR review comment density — reviewers have already been flagging complexity in code review. The drag-handle extension deserves attention second, not because its score is lower, but because its surrounding files are the most actively changing right now.

Patterns Found

Antipatterns detected across the top functions in this snapshot:

PatternOccurrences
complex_branching5
god_function5
long_function5
deeply_nested3
exit_heavy3
middle_man1

These labels belong to two tiers — Tier 1 (structural): complex_branching, deeply_nested, exit_heavy, long_function, god_function. Tier 2 (relational/temporal): hub_function, cyclic_hub, middle_man, neighbor_risk, stale_complex, churn_magnet, shotgun_target, volatile_god.

Reproduce This Analysis

git clone https://github.com/ueberdosis/tiptap
cd tiptap
git checkout c4c5e16b65b27688a9cd3217ab40f86f023f7a22
hotspots analyze . --mode snapshot --explain-patterns --force --hybrid-touches 5

To run the same analysis on your own codebase, run hotspots analyze . --mode snapshot in any local git repo — no configuration required.

I use Hotspots to highlight structural and activity risk — not “bad code.” I treat these findings as a prioritization aid, not a bug predictor. Editorial policy →

Related Analyses