Table of Contents

Class MarkdownEditor

Namespace
DocToolkit
Assembly
DocToolkit.dll

Reads and updates an existing Markdown document — front matter, headings, tables, and one section's content — without converting to another format first.

public static class MarkdownEditor
Inheritance
MarkdownEditor
Inherited Members

Remarks

No Stream source overload, on any method here. The input is a string rather than a document, so a caller holding bytes decides their own encoding — the same reason MarkdownToDocxConverter has no Stream source overload either.

No async overload either, for a different reason. An overload here is async only where there is real I/O to await — that is what a Stream overload earns, because draining or emitting a stream genuinely awaits. Every method on this class is CPU-bound: it parses a string and returns a value. Wrapping that in a Task would make it look async without making it so.

No *Core split. That convention exists so a byte[] overload and a Stream overload cannot drift apart; no method here has more than one overload, so there is nothing for a split to keep in sync.

No dependency-injection mirror yet, deliberately deferred rather than argued by analogy. DocToolkit.Extensions.DependencyInjection references the published core package (see that project's own notes), so the service delegating to this class — the mirror itself — cannot be implemented before this class has shipped. That is a scheduling constraint, not a design decision to leave it unmirrored.

Methods

FindHeading(string, string, StringComparison)

The heading in markdown whose text matches headingText, or null if none does. When more than one heading shares the same text, the first one in document order is returned.

public static MarkdownHeading? FindHeading(string markdown, string headingText, StringComparison comparison = StringComparison.OrdinalIgnoreCase)

Parameters

markdown string

The Markdown to search.

headingText string

The heading's text to match, without the leading # markers.

comparison StringComparison

How headingText is compared. Case-insensitive by default.

Returns

MarkdownHeading

Remarks

Every heading is searched, including a nested one — a heading inside a blockquote or a list item is found here like any other. That is deliberately wider than ReplaceSection(string, string, string, StringComparison), which considers top-level headings only because it has to index the document's own block list to find a section's boundaries. Reading a heading has no such constraint, so it does not inherit the restriction.

Exceptions

ArgumentNullException

markdown or headingText is null.

DocumentConversionException

The Markdown could not be parsed.

ReadFrontMatter(string)

Every front-matter key in markdown, with its parsed value. A document with no front matter returns an empty dictionary, never null.

public static IReadOnlyDictionary<string, object> ReadFrontMatter(string markdown)

Parameters

markdown string

The Markdown to read.

Returns

IReadOnlyDictionary<string, object>

Remarks

Values are whatever the underlying reader produced, and not every YAML shape survives. Every statement below was measured against the pinned OfficeIMO.Markdown 3.2.6 rather than inferred from YAML in general.

A scalar arrives as one of three runtime types: a quoted or bare word is a string, a number is a double (never int or longversion: 3 comes back as 3.0), and true/false is a bool. An absent value is the empty string: key: with nothing after it, key: null, key: ~ and key: "" are read as "", "null", "~" and "" respectively. No value is ever null.

An inline sequencetags: [alpha, beta] — is a fourth runtime type, a List<string>. Its items are always strings, so nums: [1, 2] yields "1" and "2" rather than two doubles, and tags: [] yields an empty list.

A block sequence — a tags: line with indented - alpha / - beta items beneath it — is not read. The key is present and maps to an empty string; the items are lost entirely, with nothing raised. Write the sequence inline if you need to read it back.

A nested mapping does not nest — it flattens. Given an author: line with indented name: and email: beneath it, author maps to an empty string while name and email appear as their own top-level keys of the returned dictionary; deeper indentation flattens the same way, to the same one level. If a flattened key collides with another key in the same front matter, only one entry survives and it carries the later value — silently, exactly as a duplicate top-level key does. A single-line inline mapping is neither parsed nor flattened: author: {name: Ada} comes back as the literal string {name: Ada}.

Exceptions

ArgumentNullException

markdown is null.

DocumentConversionException

The Markdown could not be parsed.

ReadTable(string, int)

The table at index, as rows of cell text — the header row is row 0, followed by every data row in document order. A row is returned with the shape it has: a row with fewer or more cells than its neighbours is not padded into a rectangle.

public static IReadOnlyList<IReadOnlyList<string>> ReadTable(string markdown, int index)

Parameters

markdown string

The Markdown to read.

index int

0-based, indexing what TableCount(string) reports.

Returns

IReadOnlyList<IReadOnlyList<string>>

Exceptions

ArgumentNullException

markdown is null.

ArgumentOutOfRangeException

index is negative, or at or beyond TableCount(string).

DocumentConversionException

The Markdown could not be parsed.

ReplaceSection(string, string, string, StringComparison)

Replaces the content of the section under the heading matching headingText with newContent, and returns the whole updated document. Front matter and every other section are left untouched.

public static string ReplaceSection(string markdown, string headingText, string newContent, StringComparison comparison = StringComparison.OrdinalIgnoreCase)

Parameters

markdown string

The Markdown to edit.

headingText string

The target heading's text, without the leading # markers, matched against the document's top-level headings only. This is deliberately narrower than FindHeading(string, string, StringComparison), which also matches a heading nested inside a blockquote or a list item — see the remarks for why the two differ.

newContent string

The section's new body, inserted verbatim in place of the blocks between the heading's own line and the start of the next section. Include your own surrounding newlines — this method does not add or normalise whitespace around what you pass. "Blocks" is meant literally: a CommonMark link reference definition leaves no block behind, so one immediately after the heading is kept rather than replaced. See the remarks.

comparison StringComparison

How headingText is compared. Case-insensitive by default.

Returns

string

Remarks

A section runs from immediately after the target heading's own line to the start of the next heading at the same or a shallower level (a level-2 target's section can only be closed by another level-1 or level-2 heading, never by a level-3 one), or to the end of the document if there is no such heading.

Line endings that came from markdown are normalised to \n, and only \r\n is recognised as a line ending here — a lone \r is left exactly as it is. Normalising is not a stylistic choice: every boundary above is an offset that OfficeIMO.Markdown computes against LF-normalised text, so splicing those offsets into an original \r\n string would misalign every one of them by the number of line breaks preceding it — silently truncating the heading and corrupting an unrelated line further down. The input is normalised once, then parsed and spliced as one consistent string. newContent is inserted verbatim and keeps whatever line endings you pass it, so a \r\n written there survives into the result.

Only top-level headings are ever considered. A heading nested inside a blockquote or a list item is not a candidate — it is not searched, rather than found and then refused — even when no top-level heading shares its text, in which case the call reports that no heading matched. It has to be invisible rather than merely refused: the section boundaries are computed by walking the document's own top-level block list, and searching the whole document first made a legitimate top-level heading uneditable whenever a nested heading earlier in the document happened to share its text.

One measured limitation. A CommonMark link reference definition ([label]: https://example.com) is consumed into the parser's link registry and leaves no block in the document, so the body's start is found past it. One sitting immediately after the target heading is therefore not treated as part of the section body and survives untouched even when the rest of the section is replaced. One sitting after other content in the same section falls inside the replaced range and does go. Rewriting the splice to track them was judged the worse trade — the offset arithmetic here is correct and heavily pinned, and link reference definitions are rare in the section bodies this method exists for.

Exceptions

ArgumentNullException

Any argument is null.

ArgumentException

No top-level heading matches headingText.

DocumentConversionException

The Markdown could not be parsed.

TableCount(string)

The number of tables in markdown, in document order.

public static int TableCount(string markdown)

Parameters

markdown string

The Markdown to read.

Returns

int

Exceptions

ArgumentNullException

markdown is null.

DocumentConversionException

The Markdown could not be parsed.