@takazudo/mindmap-parser
Pure markdown parser and serializer for mind map tree data. Parses markdown files with type: mindmap frontmatter into structured MindMapTree objects. All operations are immutable. Zero dependencies. The parser normalizes CRLF and lone-CR input to LF so an editor can safely round-trip files from any platform.
Main Exports
import {
parseMindMapMarkdown,
serializeMindMapMarkdown,
hasMindmapFrontmatter,
addNode,
addNodeAtIndex,
insertParent,
removeNode,
removeNodes,
removeNodeKeepChildren,
moveNode,
moveNodeInSiblings,
moveNodesInSiblings,
indentNode,
indentNodes,
outdentNode,
outdentNodes,
updateNodeLabel,
findNode,
findParent,
computeIdRemap,
slugify,
uniqueSlug,
validateMindmapMarkdown,
encodeMindMapLabel,
decodeMindMapLabel,
labelNeedsMindMapCodec,
isValidEncodedMindMapLabel,
} from "@takazudo/mindmap-parser";
import type {
MindMapNode,
MindMapTree,
BatchMoveOptions,
} from "@takazudo/mindmap-parser";Types
interface MindMapNode {
id: string; // slugified from label
label: string; // display text
children: MindMapNode[];
}
interface MindMapTree {
title: string; // from frontmatter or root heading
root: MindMapNode; // the root node (from # heading)
rawFrontmatter: Record<string, string>; // preserved for round-trip
}parseMindMapMarkdown()
Parses a markdown string into a MindMapTree. The format uses YAML frontmatter for metadata, a # heading for the root node, and indented bullet lists for branches:
const tree = parseMindMapMarkdown(`---
type: mindmap
title: Project Plan
---
# Project Plan
- Research
- User Interviews
- Market Analysis
- Development
- Backend
- Frontend
`);
// tree.title === "Project Plan"
// tree.root.label === "Project Plan"
// tree.root.children.length === 2Parsing rules:
The
#heading becomes the root nodeBullet items (
-) become child nodesIndentation (2 spaces per level) determines nesting depth
Node IDs are generated via
slugify()with uniqueness guarantees
Blank lines and prose outside the tree do not become nodes. Call validateMindmapMarkdown() before setup or persistence to report malformed tree structure rather than committing it.
Multiline labels and codec safety
Labels containing a newline are stored on one physical Markdown line using the declared mindmap-label-codec: base64url-v1 frontmatter field and an unpadded base64url ~mml1~ payload. The parser decodes the label before deriving its ID; the serializer preserves internal, leading, and trailing line breaks and spaces. The same encoding protects literal marker-looking labels from being interpreted as data. Unsupported codec declarations and malformed payloads remain visible as literal text and are validation errors, rather than silently changing the tree. Manual edits should normalize line endings to LF and use canonical base64url spelling.
Empty labels
A bare - bullet (or # heading with nothing after it) cannot be written to Markdown — the bullet/heading regexes require at least one character after the marker, so a literal empty line would silently disappear on the next parse. Empty labels are instead round-tripped through the same label codec as multiline labels: labelNeedsMindMapCodec("") is true, so the serializer declares mindmap-label-codec: base64url-v1 whenever any label in the tree is empty and writes the empty node as the bare marker ~mml1~ (a non-empty bullet); parsing decodes it back to "". This means empty nodes — including chains of them, several empty siblings, or an empty root — always survive a parse → serialize → parse round-trip; the parser never has to fall back to dropping the node. uniqueSlug("") falls back to the node base word (via slugify's built-in fallback), so distinct empty siblings still get distinct, stable ids (node, node-2, node-3, ...).
serializeMindMapMarkdown()
Converts a MindMapTree back to markdown. Preserves the original frontmatter for round-trip fidelity.
const markdown = serializeMindMapMarkdown(tree);
// ---
// type: mindmap
// title: Project Plan
// ---
//
// # Project Plan
// - Research
// - User Interviews
// - Market Analysis
// ...hasMindmapFrontmatter()
Returns true if the markdown content has type: mindmap in its frontmatter. Used to detect mind map files.
hasMindmapFrontmatter("---\ntype: mindmap\n---\n# Root");
// true
hasMindmapFrontmatter("---\ntitle: Note\n---\n# Just a note");
// falsevalidateMindmapMarkdown(markdown) returns line-aware warnings and errors for missing frontmatter/root headings, skipped indentation levels, unsupported codecs, and malformed encoded labels. Setup flows should validate and preview the exact serialized candidate before writing; cancellation or a validation or write failure must leave the original source untouched.
Tree Operations
All operations are immutable — they return a new MindMapTree. When an operation cannot do anything (no-op targets, missing ids, boundary conditions), it returns the same tree reference it was given rather than an equivalent copy, so callers can compare with === to skip recording an undo entry.
Single-node operations
| Function | Description |
|---|---|
addNode(tree, parentId, label) | Add a new child node at the end of a parent's children |
addNodeAtIndex(tree, parentId, label, index) | Add a new child node at a specific index |
insertParent(tree, nodeId, label?) | Insert a new parent between a node and its current parent (cannot wrap root) |
removeNode(tree, nodeId) | Remove a node and its children (cannot remove root) |
removeNodeKeepChildren(tree, nodeId) | Remove one node but promote its children in place at its own index (root: no-op) |
moveNode(tree, nodeId, newParentId, index?) | Reparent a node (cannot move root or into own descendant) |
indentNode(tree, nodeId) | Demote a node beneath its immediately previous sibling |
outdentNode(tree, nodeId) | Promote a node to become the next sibling of its current parent |
moveNodeInSiblings(tree, nodeId, direction) | Move a node up (-1) or down (1) among its siblings |
updateNodeLabel(tree, nodeId, newLabel) | Rename a node (regenerates its ID; updates title if root) |
Batch operations
These act on a whole id selection in one call — one call, one resulting tree, one undo entry. Ids whose ancestor is also in the selection are redundant and dropped (the ancestor's own move/delete already carries them along), and the root is always skipped as a target. For the sibling-reorder operations, a node in the selection never swaps with, or indents into, another node in the same selection — so a contiguous, ordered block of selected nodes moves as one unit and stops together the moment any member would hit the boundary, instead of partially scattering. This follows the batch-move interaction documented in issue #6546.
| Function | Description |
|---|---|
removeNodes(tree, ids) | Delete every listed id's subtree in one operation |
moveNodesInSiblings(tree, ids, direction, opts?) | Move the whole selection up (-1) / down (1) among siblings, as a block |
indentNodes(tree, ids, opts?) | Indent the whole selection beneath each item's immediately previous sibling, as a block |
outdentNodes(tree, ids) | Outdent the whole selection to become the next siblings of their respective parents, as a block |
opts.sameGroup?: (a: MindMapNode, b: MindMapNode) => boolean restricts the "previous sibling" / "next sibling" search to a subgroup — e.g. free placement's left/right side of the root. It is only consulted among direct children of the tree root (grouping is a depth-1-only concept); at every other depth all siblings are eligible regardless of sameGroup.
Examples
// Add a child under "research"
const updated = addNode(tree, "research", "Competitor Analysis");
// Remove a node
const pruned = removeNode(tree, "market-analysis");
// Move "frontend" under "research"
const moved = moveNode(tree, "frontend", "research");
// Rename a node
const renamed = updateNodeLabel(tree, "backend", "Server");
// Move a contiguous multi-select down as one block
const reordered = moveNodesInSiblings(tree, ["leaf-a1", "leaf-a2"], 1);
// Indent a multi-select, restricted to the root's "left" side
const sameGroup = (a: MindMapNode, b: MindMapNode) =>
sideOf(a.id) === sideOf(b.id);
const indented = indentNodes(tree, selectedIds, { sameGroup });
// Delete a whole selection, dropping any id whose ancestor is also selected
const cleaned = removeNodes(tree, selectedIds);Query Functions
| Function | Description |
|---|---|
findNode(tree, nodeId) | Find a node by ID. Returns null if not found |
findParent(tree, nodeId) | Find the parent of a node. Returns null if root or not found |
computeIdRemap(before, after) | Map old ids to new ids between two tree snapshots, to preserve UI state keyed by node id across an operation |
Utilities
slugify(text)— convert to kebab-case slug (preserves non-ASCII characters)uniqueSlug(title, existingIds)— generate unique slug with-2,-3suffix if needed
Dependencies
None (standalone package).