Explanation

The AST

The fully typed abstract syntax tree that datamark produces from Markdown.

When you call parse(content) on a Markdown string, datamark produces a Document — a fully typed tree of nodes that mirrors the structure of the original text.

Document

interface Document {
  type: "document";
  frontmatter: Record<string, unknown> | null;
  root: SectionNode;
}
  • frontmatter — parsed YAML from --- fences, or null
  • root — the root section of the document tree

Section tree

The root is a SectionNode that organizes the entire document by heading depth:

interface SectionNode {
  type: "section";
  heading: HeadingNode | null;
  children: Node[];
}

Headings become section boundaries. A section contains its heading (if any) and all content until the next heading of the same or lesser depth. The algorithm is single-pass, stack-based, and depth-agnostic — any heading depth works.

import { parse } from "datamark";

const doc = parse(`
# Title

Intro paragraph.

## Section A

Some text.

### Subsection A1

More text.

## Section B

Final text.
`);

// Section tree navigation
const topSection = doc.root.children.find(
  (n) => n.type === "section"
) as any;

const topSectionDepth = topSection?.heading?.depth;
const subSections = topSection?.children?.filter(
  (n: any) => n.type === "section"
);
const subSubSections = subSections?.[0]?.children?.filter(
  (n: any) => n.type === "section"
);
const subSubDepth = subSubSections?.[0]?.heading?.depth;

// Node example
const paraDoc = parse(`# Hello\n\nThis is a **paragraph**.`);
const firstSection = paraDoc.root.children[0] as any;
const firstSectionType = firstSection.type;
const firstSectionHeadingDepth = firstSection.heading.depth;
const firstParagraph = firstSection.children.find(
  (n: any) => n.type === "paragraph"
);
const firstSectionChildType = firstParagraph?.type ?? "none";

Content before the first heading stays in root.children. If there are no headings at all, the entire document is in root.children.

Node hierarchy

All nodes share a common base:

interface Node {
  type: string;
  raw?: string;
  position?: SourceSpan;
}

interface ParentNode extends Node {
  children: Node[];
}

Block nodes

TypeInterfaceKey properties
headingHeadingNodedepth: number, children: InlineNode[]
paragraphParagraphNodechildren: InlineNode[]
codeCodeNodelang?: string, meta?: string, value: string
blockquoteBlockquoteNodechildren: BlockNode[]
hrHrNode
listListNodeordered: boolean, children: ListItemNode[]
listItemListItemNodechildren: BlockNode[]
tableTableNodechildren: TableRowNode[], align
tableRowTableRowNodechildren: TableCellNode[]
tableCellTableCellNodechildren: InlineNode[]
htmlHtmlBlockNodevalue: string
spaceSpaceNode

Inline nodes

TypeInterfaceKey properties
textTextNodevalue: string
escapeEscapeNodevalue: string
strongStrongNodechildren: InlineNode[]
emEmNodechildren: InlineNode[]
codespanCodeSpanNodevalue: string
linkLinkNodehref: string, title?: string
imageImageNodesrc: string, alt: string
brBreakNode
delDelNodechildren: InlineNode[]

Positions

Every node carries an optional position with start and end line/column:

interface SourceSpan {
  start: { line: number; column: number; offset: number };
  end: { line: number; column: number; offset: number };
}

Positions are computed relative to the original input string, even when frontmatter is stripped. This means errors and traces point to the right place in your source file.

Example

import { parse } from "datamark";

const doc = parse(`# Hello\n\nThis is a **paragraph**.`);
const topSection = doc.root.children[0] as any;
const paragraph = topSection.children.find((n: any) => n.type === "paragraph");
console.log(topSection.type);        // "section"
console.log(topSection.heading.depth); // 1
console.log(paragraph.type);           // "paragraph"

On this page