A semantic parser for ProseMirror/TipTap content structures that helps bridge the gap between natural content writing and component-based web development.
The parser transforms rich text editor content (ProseMirror/TipTap) into structured, semantic groups that web components can easily consume. It provides two complementary views of your content:
- Sequence: An ordered list of all content elements (for rendering in document order)
- Groups: Content organized into semantic sections (main content + items)
npm install @uniweb/semantic-parserimport { parseContent } from "@uniweb/semantic-parser";
// Your ProseMirror/TipTap document
const doc = {
type: "doc",
content: [
{
type: "heading",
attrs: { level: 1 },
content: [{ type: "text", text: "Welcome" }],
},
{
type: "paragraph",
content: [{ type: "text", text: "Get started today." }],
},
],
};
// Parse the content
const result = parseContent(doc);
// Access different views
console.log(result.sequence); // Ordered array of elements
console.log(result.title); // Main content fields at top level
console.log(result.items); // Additional content groupsAn ordered array of semantic elements preserving document order:
result.sequence = [
{ type: "heading", level: 1, content: "Welcome" },
{ type: "paragraph", content: "Get started today." }
]Main content fields are at the top level. The items array contains additional content groups (created when headings appear after content), each with the same field structure:
result = {
// Header fields (from headings) — each is a string, or an array when the
// part spans several lines
pretitle: "", // `#>` label line(s), or smaller headings above the title
title: "Welcome", // Main heading
subtitle: "", // Line(s) one step below the title
// Body fields
paragraphs: ["Get started today."],
links: [], // All links (including buttons, documents)
images: [],
videos: [],
icons: [],
lists: [],
quotes: [],
insets: [], // Inline @Component references — { refId }
snippets: [], // Fenced code blocks — { language, code }
data: {}, // Structured data (tagged data blocks, forms, cards)
headings: [], // Only from nested content (quote/list bodies)
// Additional content groups (from headings after content)
// Each item has the SAME flat structure as the top level — title,
// pretitle, subtitle, paragraphs, links, images, icons, lists,
// snippets, data, etc. Use this for cards, features, FAQ entries.
items: [
{ title: "Feature 1", paragraphs: [...], links: [...], /* ...full shape */ },
{ title: "Feature 2", paragraphs: [...], links: [...], /* ...full shape */ }
],
// Ordered sequence for document-order rendering
sequence: [...],
// Original document
raw: { type: "doc", content: [...] }
}const content = parseContent(doc);
const title = content.title;
const description = content.paragraphs.join(" ");
const image = content.images[0]?.src;const content = parseContent(doc);
// Main content
console.log("Title:", content.title);
console.log("Description:", content.paragraphs);
// Additional content groups
content.items.forEach(item => {
console.log("Section:", item.title);
console.log("Content:", item.paragraphs);
});const { sequence } = parseContent(doc);
sequence.forEach(element => {
switch(element.type) {
case 'heading':
renderHeading(element);
break;
case 'paragraph':
renderParagraph(element);
break;
case 'image':
renderImage(element);
break;
}
});The parser interprets heading levels relatively, not absolutely. There is no requirement that a section start with H1 — what matters is the relationship between headings (which is more important, which is deeper, which comes after body content). Starting at H1 is natural and recommended, but the same structure works starting at H2 or H3 — every rule below is about relative levels.
A new group is started whenever a heading appears after non-heading content (paragraphs, images, links, lists, etc.). A horizontal rule (---) explicitly closes the current group.
When a group begins with headings, the parser reads one headline stack with the staircase rule — each heading relates to the one before it:
- Same size — another line of the same part: a split title, or another subtitle line. Merged slots become arrays.
- One step smaller — the next line of the headline: the subtitle first, then further subtitle lines (
subtitleis a string for one line, an array for several — a three-line header like name / role / affiliation fits in one headline). - Two or more steps smaller, or a step back up — the stack ends; the rest of the run starts a new group (an item), read the same way.
Before the title, #> label lines (headings carrying role: "pretitle") and every smaller heading stacked above the most important one — in whatever order — join the pretitle. A label line anywhere names the block it sits in; a label with no following heading labels an untitled block.
Once groups are split, the parser decides whether the first group is the section's main content or whether all groups should be treated as items. The first group becomes main content when either:
- It has no heading at all (body content appearing before the first heading), or
- Its heading level is more important (lower-numbered) than the second group's heading level.
Otherwise, every group becomes an item and the top-level header fields stay empty. For example, a document that starts with ## Card A followed by ## Card B produces two items and no main title — because the first group is not more important than the second. The same is true for two H1s in a row, or two H3s; the rule is about relative importance, not a specific level.
This is the mechanism that creates repeating content groups (cards, features, FAQ entries) without any extra configuration.
Two spellings fill the pretitle slot:
- A
#>label line — a heading withrole: "pretitle". It names the block that starts next, at any depth, with no level arithmetic; the hash count is carried for round-tripping and means nothing. - Position — smaller headings stacked above the most important heading of a run (H3 before H1, H6 before H5, …), in any order.
#### / ### / # Titleand## / ### / # Titleboth yield a two-line pretitle.
If a section's very first element is an image (or an image followed immediately by a heading), the parser keeps the image and the following heading in the same group rather than splitting them. This lets a leading image act as a banner for the section's title without becoming an unrelated item.
A horizontal rule (---) explicitly closes the current group and starts a new one. Dividers compose with the heading rules above — they don't replace them — and are useful in three situations:
- Resolving ambiguity. When the heading rules would group content one way but you want it grouped another way. Most common case: forcing items without a subtitle. Without a divider,
# Our Statsfollowed by## 15,000+makes## 15,000+the subtitle (one step down). Adding---between them closes the headline so## 15,000+becomesitems[0].titleinstead — as does writing the entries two steps down (### 15,000+), the divider-free spelling. - Forcing splits within same-level runs. Same-level headings that would otherwise merge into a multi-line title/subtitle become separate groups when separated by a divider.
- Personal preference. Even when the heading rules would already produce the desired structure, authors can use
---as an explicit visual separator between groups. It never changes a structure that's already correct — it just makes the boundary obvious in the markdown source.
Inline formatting is preserved as HTML tags:
// Input: Text with bold mark
// Output: "Text with <strong>bold</strong>"
// Input: Text with italic mark
// Output: "Text with <em>emphasis</em>"
// Input: Link mark
// Output: "Click <a href=\"/docs\">here</a>"
// Input: Span mark (bracketed spans)
// Output: "This is <span class=\"highlight\">highlighted</span> text"Bracketed spans ([text]{.class}) are converted to <span> elements with their attributes:
// Input mark
{ type: "span", attrs: { class: "highlight", id: "note-1" } }
// Output HTML
'<span class="highlight" id="note-1">text</span>'Spans can have classes, IDs, and custom attributes. They combine with other marks—a span with bold becomes <strong><span class="...">text</span></strong>.
The parser handles inline inset_placeholder nodes — generated by
@uniweb/build's content collector when it pulls
{k=v}, [text](@Component){k=v}, [@key]{k=v},
or [#id]{k=v} references out of paragraph content. Two artifacts
ship to consumers:
-
childrencarries aninsetentry per placeholder — each carrying therefIdandembedKind('text'for textual sugars like[@key]/[#id]/[text](@Component);'visual'for). Renderers that walkparagraph.childrenpick these up alongside icons, math, and links. -
textcarries a<uniweb-inset data-ref-id="…"></uniweb-inset>marker at the placeholder position — useful for renderers that readparagraph.textas a flat HTML string (kit's<Prose>is the canonical example; it splits on the marker and intersperses React-rendered insets via the framework's child-block renderer).
The two views stay in lockstep — both record every inline inset the parser sees, in source order.
- Component-based websites: Extract structured data for React/Vue components
- Content management: Parse editor content into database-friendly structures
- Static site generation: Transform rich content into template-ready data
- Content analysis: Analyze document structure and content types
GPL-3.0-or-later