MarkdigExtensions.Query is a powerful, jQuery-style query engine for traversing, filtering, and manipulating Markdown documents in .NET. Built on top of the Markdig Markdown parser, it provides an expressive API for searching and transforming Markdown nodes with CSS-like selectors and familiar LINQ-style chaining.
- 🔍 jQuery-style Querying: Familiar syntax with CSS selectors and method chaining
- 🎯 Type-safe Node Access: Strongly-typed access to all Markdown elements
- 📊 Document Analysis: Built-in statistics, outline generation, and link analysis
- 🔄 LINQ Integration: Seamless integration with LINQ operations
- 🌲 DOM-like Traversal: Parent, child, sibling, and ancestor navigation
- 🎨 CSS Selector Support: Full CSS selector syntax with combinators and pseudo-classes
- 📈 Performance Optimized: Indexed lookups and efficient querying
- 🔗 Extension Methods: Easy integration with existing Markdig workflows
dotnet add package MarkdigExtensions.QueryusingMarkdigExtensions.Query;// Convert any markdown string to a queryable documentvarmarkdown="""# Welcome to My DocumentThis is a paragraph with a [link](https://example.com) and **bold text**.## Section 2- Item 1- Item 2 with *emphasis*\```Console.WriteLine("Hello World!");\```| Column 1 | Column 2 ||----------|----------|| Value A | Value B |""";// Create queryable documentvardocument=markdown.AsQueryable();// Now you can query like jQuery!varheadings=document.GetHeadings();varlinks=document.GetLinks();varcodeBlocks=document.GetCodeBlocks();Convert markdown to queryable documents using extension methods:
// From stringvardocument=markdown.AsQueryable();// From Markdig documentvarmarkdigDoc=Markdown.Parse(markdown);vardocument=markdigDoc.AsQueryable();Access different types of markdown elements:
// Type-based selectionvarheadings=document.GetHeadings();// All headingsvarh1s=document.GetHeadings(1);// Only H1 headingsvarparagraphs=document.GetParagraphs();// All paragraphsvarlinks=document.GetLinks();// All linksvarimages=document.GetImages();// All imagesvarlists=document.GetLists();// All listsvarcodeBlocks=document.GetCodeBlocks();// All code blocksvartables=document.GetTables();// All tablesvartextNodes=document.GetTextNodes();// All text nodes// Generic selection with filteringvarstrongNodes=document.GetNodes<StrongNode>();varemphasizedText=document.GetNodes<EmphasisNode>(e =>e.Children.Any());Use familiar CSS selectors to find elements:
// Basic selectorsvarh1s=document.Query("h1");// All H1 headingsvarlinks=document.Query("link");// All linksvarimages=document.Query("image");// All imagesvarcodeBlocks=document.Query("codeblock");// All code blocks// Attribute selectorsvarcsharpCode=document.Query("codeblock[language=csharp]");varexternalLinks=document.Query("link[url^='https://']");varlevel2Headings=document.Query("heading[level='2']");// CombinatorsvarlinkTexts=document.Query("link text");// Text nodes inside linksvardirectChildren=document.Query("ul > li");// Direct list item childrenvaradjacentSiblings=document.Query("h1 + p");// Paragraphs after H1s// Pseudo-classesvarfirstHeading=document.Query("heading:first");varlastParagraph=document.Query("paragraph:last");varevenItems=document.Query("li:even");varnthChild=document.Query("li:nth-child(2n+1)");// Multiple selectorsvarheadingsAndLinks=document.Query("heading, link");Navigate the document tree like a DOM:
vartextNodes=document.GetTextNodes();// Parent navigationvarparents=textNodes.Parent();// Direct parentsvarancestors=textNodes.Parents();// All ancestorsvarclosestParagraph=textNodes.Closest("paragraph");// Child navigationvarchildren=document.Children();// Direct childrenvardescendants=document.Find("text");// All descendant text nodes// Sibling navigationvarnextSiblings=headings.Next();// Next siblingsvarprevSiblings=headings.Prev();// Previous siblingsvarallNextSiblings=headings.NextAll();// All following siblingsvarallPrevSiblings=headings.PrevAll();// All preceding siblings// Conditional traversalvarnextParagraphs=headings.Next("paragraph");varparentsUntilDocument=textNodes.ParentsUntil(n =>nisDocumentNode);Filter and refine your selections:
// Predicate filteringvarlongParagraphs=document.GetParagraphs().Filter(p =>p.Children.Count>10);varexternalLinks=document.GetLinks().Filter(link =>((LinkNode)link).Url?.StartsWith("http")==true);// CSS selector filteringvarh1AndH2=document.GetHeadings().Filter("h1, h2");varnotCodeBlocks=document.Not("codeblock");// Exclusion filteringvarnonEmptyParagraphs=document.GetParagraphs().Not(p =>p.Children.Count==0);// Has filtering (contains descendants)varparagraphsWithLinks=document.GetParagraphs().Has("link");varitemsWithCode=document.Query("li").Has(item =>item.Descendants().Any(d =>disCodeSpanNode));// Is testingboolhasH1=document.Is("h1");boolhasExternalLinks=document.GetLinks().Is(link =>((LinkNode)link).Url?.StartsWith("http")==true);Access elements by position:
varheadings=document.GetHeadings();// Index accessvarfirstHeading=headings.First();// First elementvarlastHeading=headings.Last();// Last elementvarthirdHeading=headings.ElementAt(2);// Zero-based indexvarsecondToLast=headings.ElementAt(^2);// From end// Range slicingvarfirstThree=headings.Slice(0..3);// First 3 elementsvarlastTwo=headings.Slice(^2..);// Last 2 elementsvarmiddle=headings.Slice(1..4);// Elements 1-3varskipTwo=headings.Slice(2);// Skip first 2// Safe accessvarmaybeFirst=headings.FirstOrDefault();// Null if emptyvarmaybeLast=headings.LastOrDefault();// Null if emptySeamlessly integrate with LINQ:
// Transform to other typesvarheadingTexts=document.GetHeadings().Select(h =>((HeadingNode)h).Value).Where(text =>!string.IsNullOrEmpty(text)).ToList();varlinkUrls=document.GetLinks().Select(link =>((LinkNode)link).Url).Where(url =>url?.StartsWith("https://")==true).ToArray();// Complex transformations with indexvarheadingInfo=document.GetHeadings().Select((index,node)=>new{Index=index,Level=((HeadingNode)node).Level,Text=node.Value,Depth=document.GetDepth(node)}).OrderBy(info =>info.Level).ToList();// Iterate with actionsdocument.GetHeadings().Each((index,heading)=>{Console.WriteLine($"Heading {index}: {heading.Value}");});document.GetLinks().Each(link =>{varlinkNode=(LinkNode)link;Console.WriteLine($"Link: {linkNode.Value} -> {linkNode.Url}");});Analyze and extract insights from your documents:
varstats=document.GetStatistics();// or: var stats = document.GetDocumentStatistics();Console.WriteLine($"Total nodes: {stats["TotalNodes"]}");Console.WriteLine($"Headings: {stats["HeadingCount"]}");Console.WriteLine($"Paragraphs: {stats["ParagraphCount"]}");Console.WriteLine($"Links: {stats["LinkCount"]}");Console.WriteLine($"Images: {stats["ImageCount"]}");Console.WriteLine($"Code blocks: {stats["CodeBlockCount"]}");Console.WriteLine($"Lists: {stats["ListCount"]}");Console.WriteLine($"Tables: {stats["TableCount"]}");Console.WriteLine($"Max depth: {stats["MaxDepth"]}");Console.WriteLine($"Word count: {stats["WordCount"]}");varoutline=document.GetDocumentOutline();foreach(variteminoutline){varindent=newstring(' ',(item.Level-1)*2);Console.WriteLine($"{indent}- {item.Title} (Level {item.Level})");}varlinkAnalysis=document.AnalyzeLinks();foreach(varlinkinlinkAnalysis){Console.WriteLine($"Link: {link.Text}");Console.WriteLine($" URL: {link.Url}");Console.WriteLine($" External: {link.IsExternal}");Console.WriteLine($" Relative: {link.IsRelative}");Console.WriteLine($" Anchor: {link.IsAnchor}");if(link.Title!=null)Console.WriteLine($" Title: {link.Title}");}Extract text content from selections:
// Default space separatorvarallText=document.GetTextContent();// Custom separatorvarcommaSeparated=document.GetTextContent(", ");// From specific selectionsvarheadingText=document.GetHeadings().GetTextContent();varparagraphText=document.GetParagraphs().GetTextContent(" | ");// Extract from complex selectionsvarlinkTexts=document.Query("link text").GetTextContent();Work with the document's tree structure:
vartextNode=document.GetTextNodes().First().Get()[0];// Tree navigationvarparent=document.GetParent(textNode);varancestors=document.GetAncestors(textNode);vardescendants=MarkdownDocument.GetDescendants(parent);varsiblings=document.GetSiblings(textNode);vardepth=document.GetDepth(textNode);// Tree relationshipsvarallNodes=document.AllNodes;varrootNode=document.Root;Chain operations fluently like jQuery:
// Complex chaining examplevarresult=document.GetHeadings()// Get all headings.Filter(h =>((HeadingNode)h).Level<=3)// Only H1-H3.Not("h1")// Exclude H1s.Parent()// Get their parents.Children("paragraph")// Find paragraph children.Has("link")// That contain links.Each((index,node)=>{// Process eachConsole.WriteLine($"Paragraph {index}: {node.Value}");}).End()// Return to previous selection.Slice(0..5);// Take first 5// Statistical analysis chainvarlinkStats=document.GetLinks().Select(link =>(LinkNode)link).Where(link =>!string.IsNullOrEmpty(link.Url)).GroupBy(link =>link.Url.StartsWith("http")?"External":"Internal").ToDictionary(g =>g.Key, g =>g.Count());vartoc=document.GetHeadings().Select(h =>(HeadingNode)h).Select(h =>new{Level=h.Level,Title=h.Value??"",Anchor=h.Value?.ToLower().Replace(" ","-")??""}).ToList();foreach(varitemintoc){varindent=newstring(' ',(item.Level-1)*2);Console.WriteLine($"{indent}- [{item.Title}](#{item.anchor})");}varbrokenLinks=document.AnalyzeLinks().Where(link =>link.IsExternal).Where(link =>!IsValidUrl(link.Url))// Your validation logic.ToList();foreach(varlinkinbrokenLinks){Console.WriteLine($"Broken link: {link.Text} -> {link.Url}");}varanalysis=new{WordCount=(int)document.GetStatistics()["WordCount"],ReadingTime=Math.Ceiling((int)document.GetStatistics()["WordCount"]/200.0),Structure=new{HasToc=document.GetHeadings().Length>3,HasCodeExamples=document.GetCodeBlocks().Length>0,HasTables=document.GetTables().Length>0,HasImages=document.GetImages().Length>0},LinkMetrics=new{Total=document.GetLinks().Length,External=document.AnalyzeLinks().Count(l =>l.IsExternal),Internal=document.AnalyzeLinks().Count(l =>!l.IsExternal&&!l.IsAnchor),Anchors=document.AnalyzeLinks().Count(l =>l.IsAnchor)}};// Extract all code examplesvarcodeExamples=document.GetCodeBlocks().Select(cb =>(CodeBlockNode)cb).Where(cb =>!string.IsNullOrEmpty(cb.Language)).GroupBy(cb =>cb.Language).ToDictionary(g =>g.Key, g =>g.Select(cb =>cb.Value).ToList());// Find all TODO items in commentsvartodos=document.GetCodeBlocks().SelectMany(cb =>cb.Value?.Split('\n')??[]).Where(line =>line.Contains("TODO",StringComparison.OrdinalIgnoreCase)).ToList();// Extract definition lists (heading + paragraph patterns)vardefinitions=document.GetHeadings().Where(h =>((HeadingNode)h).Level>=3).Select(h =>new{Term=h.Value,Definition=h.Next("paragraph").GetTextContent()}).Where(d =>!string.IsNullOrEmpty(d.Definition)).ToList();| Element Type | CSS Selector | Type-safe Access | Description |
|---|---|---|---|
| Headings | h1, h2, h3, h4, h5, h6, heading | GetHeadings() | All heading levels |
| Paragraphs | paragraph, p | GetParagraphs() | Text paragraphs |
| Links | link, a | GetLinks() | Hyperlinks |
| Images | image, img | GetImages() | Images |
| Code Blocks | codeblock, pre | GetCodeBlocks() | Fenced and indented code |
| Code Spans | code | GetNodes<CodeSpanNode>() | Inline code |
| Lists | list, ul, ol | GetLists() | Ordered and unordered lists |
| List Items | li, listitem | GetNodes<ListItemNode>() | Individual list items |
| Tables | table | GetTables() | Table structures |
| Table Rows | tr | GetNodes<TableRowNode>() | Table rows |
| Table Cells | td, th | GetNodes<TableCellNode>() | Table cells |
| Emphasis | em, emphasis | GetNodes<EmphasisNode>() | Italic text |
| Strong | strong | GetNodes<StrongNode>() | Bold text |
| Blockquotes | blockquote | GetNodes<QuoteBlockNode>() | Quote blocks |
| Text | text | GetTextNodes() | Raw text content |
| Line Breaks | br | GetNodes<HardLineBreakNode>() | Line breaks |
| Thematic Breaks | hr, thematicbreak | GetNodes<ThematicBreakNode>() | Horizontal rules |
string.AsQueryable()MarkdownDocument.AsQueryable()
Query(string selector)- CSS selector queryGetHeadings(int? level = null)- Get heading elementsGetParagraphs()- Get paragraph elementsGetLinks()- Get link elementsGetImages()- Get image elementsGetCodeBlocks()- Get code block elementsGetLists()- Get list elementsGetTables()- Get table elementsGetTextNodes()- Get text nodesGetNodes<T>(Func<T, bool>? predicate = null)- Generic type-based selection
Filter(Func<INode, bool> predicate)- Filter by predicateFilter(string selector)- Filter by CSS selectorNot(Func<INode, bool> predicate)- Exclude by predicateNot(string selector)- Exclude by CSS selectorHas(Func<INode, bool> predicate)- Has descendant matching predicateHas(string selector)- Has descendant matching selectorIs(Func<INode, bool> predicate)- Test if any match predicateIs(string selector)- Test if any match selector
Parent()- Get parent elementsParents()- Get all ancestorsClosest(Func<INode, bool> predicate)- Get closest ancestorChildren()- Get child elementsFind(string selector)- Find descendantsSiblings()- Get sibling elementsNext()/Prev()- Get adjacent siblingsNextAll()/PrevAll()- Get all following/preceding siblings
First()/Last()- Get first/last elementElementAt(Index index)- Get element at indexSlice(Range range)- Get range of elementsFirstOrDefault()/LastOrDefault()- Safe access methods
Select<T>(Func<INode, T> selector)- Transform elementsEach(Action<INode> action)- Iterate over elementsGet()- Get underlying node collectionGetTextContent(string separator = " ")- Extract text content
GetStatistics()- Get document statisticsGetDocumentStatistics()- Alias for GetStatisticsGetDocumentOutline()- Get heading-based outlineAnalyzeLinks()- Analyze all links in document
GetParent(INode node)- Get parent of specific nodeGetAncestors(INode node)- Get ancestors of specific nodeGetDescendants(INode node)- Get descendants of specific node (static)GetSiblings(INode node, bool includeSelf = false)- Get siblingsGetDepth(INode node)- Get node depth in tree
Contributions are welcome! Please feel free to submit a Pull Request. For major changes, please open an issue first to discuss what you would like to change.
This project is licensed under the MIT License - see the LICENSE file for details.
- Built on the excellent Markdig library
- Inspired by jQuery's fluent API design
- Supports all GitHub Flavored Markdown features
Happy querying! 🎉