Editing documents
editChange the tree and render it
Parse the document, change its elements, then render it with the same pipeline. For instance, to switch the links to HTTPS:
var markdown = "See [the docs](http://example.com).";
var pipeline = new MarkdownPipelineBuilder().Build();
var document = MarkdownConverter.Parse(markdown, pipeline);
foreach (var link in document.Descendants<LinkInline>())
{
link.Url = link.Url?.Replace("http://", "https://", StringComparison.Ordinal);
}
var html = document.ToHtml(pipeline);
Assert.Equal("<p>See <a href=\"https://example.com\">the docs</a>.</p>\n", html);To change every document that a pipeline parses, change the tree in the DocumentProcessed event instead.
Add and remove elements
Blocks and inlines are created with their constructors and added to their parent: a ContainerBlock is a list of blocks, and ContainerInline.AppendChild adds an inline. Remove() removes an element from its parent, and ReplaceBy replaces it:
var pipeline = new MarkdownPipelineBuilder().Build();
var document = MarkdownConverter.Parse("# Release notes\n\n<!-- TODO -->\n\nFirst version.", pipeline);
// Remove the HTML comments
foreach (var block in document.Descendants<HtmlBlock>().ToList())
{
block.Remove();
}
// Add a paragraph at the end: "Thanks to **all** the contributors."
var emphasis = new EmphasisInline { DelimiterChar = '*', DelimiterCount = 2 };
emphasis.AppendChild(new LiteralInline("all"));
var paragraph = new ParagraphBlock { Inline = new ContainerInline() };
paragraph.Inline
.AppendChild(new LiteralInline("Thanks to "))
.AppendChild(emphasis)
.AppendChild(new LiteralInline(" the contributors."));
document.Add(paragraph);
var html = document.ToHtml(pipeline);
Assert.Equal("<h1>Release notes</h1>\n<p>First version.</p>\n<p>Thanks to <strong>all</strong> the contributors.</p>\n", html);- Copy the list before removing elements in a loop. Descendants walks the tree while you change it: call
ToList()first, as above. - Inlines live in leaf blocks. A new ParagraphBlock or HeadingBlock needs an Inline container for its text.
- Positions are not updated. The new elements have no position, and the others keep the position they had in the source.
Write the document back as Markdown
Two renderers write Markdown:
| Renderer | Output | Use it to |
|---|---|---|
| NormalizeRenderer | Markdown in a consistent style: # headings, one blank line between blocks | Clean up or generate Markdown |
| RoundtripRenderer | The source exactly as it was parsed, with your changes | Change a file that people edit, without reformatting it |
Normalize the document
MarkdownConverter.Normalize converts a Markdown string to normalized Markdown. To normalize a document you changed, create a NormalizeRenderer and set it up with the pipeline:
var pipeline = new MarkdownPipelineBuilder().Build();
var document = MarkdownConverter.Parse("Title\n=====\n\nSee [the docs](http://example.com).", pipeline);
foreach (var link in document.Descendants<LinkInline>())
{
link.Url = link.Url?.Replace("http://", "https://", StringComparison.Ordinal);
}
using var writer = new StringWriter();
var renderer = new NormalizeRenderer(writer);
pipeline.Setup(renderer);
renderer.Render(document);
Assert.Equal("# Title\n\nSee [the docs](https://example.com).", writer.ToString().ReplaceLineEndings("\n"));Normalized Markdown describes the options of the output.
Keep the formatting (roundtrip)
The parser drops the characters that do not change the meaning of the document: extra spaces, the style of a heading, the line endings. With trivia tracking, it keeps them in the tree, and RoundtripRenderer (namespace Meziantou.Framework.Markdown.Renderers.Roundtrip) writes the document back exactly as it was:
var markdown = "# Title \n\n* item\n";
var document = MarkdownConverter.Parse(markdown, trackTrivia: true);
// Change the tree here...
using var writer = new StringWriter();
var renderer = new RoundtripRenderer(writer);
renderer.Write(document);
var output = writer.ToString();
Assert.Equal(markdown, output); // Same as markdown when nothing changedParse(markdown, trackTrivia: true) uses a pipeline without extensions. To use extensions, call EnableTrackTrivia() on the builder.
The trivia is stored in the tree: blank lines in Block.LinesBefore and Block.LinesAfter, and the other characters in the properties named Trivia… (TriviaBefore, TriviaAfter…) of the blocks and the inlines. These properties are only filled when trivia tracking is enabled.
Values also keep their source text, in the properties named Unescaped…, and the roundtrip renderer writes this text when it is set. To change such a value, change both properties. The text is written as is, so it must be valid Markdown:
var markdown = "Read [the docs](http://example.com 'Docs') first.\n\n* item\n";
var document = MarkdownConverter.Parse(markdown, trackTrivia: true);
foreach (var link in document.Descendants<LinkInline>())
{
// The roundtrip renderer writes the URL as it is in the source (UnescapedUrl): change both
link.Url = "https://example.com";
link.UnescapedUrl = new StringSlice(link.Url);
}
using var writer = new StringWriter();
new RoundtripRenderer(writer).Write(document);
// Only the URL changed: the spaces, the quotes and the list marker are the same
Assert.Equal("Read [the docs](https://example.com 'Docs') first.\n\n* item\n", writer.ToString());Use trivia tracking for the roundtrip only: the HTML of a document parsed with trivia keeps some of the extra whitespace of the source, for instance at the start of a heading.
Change the attributes
With UseGenericAttributes, the roundtrip renderer writes the attributes that you change, as {#id .class name=value}:
var pipeline = new MarkdownPipelineBuilder()
.EnableTrackTrivia()
.UseGenericAttributes()
.Build();
var document = MarkdownConverter.Parse("# Introduction\n\nSome text.\n", pipeline);
document.Descendants<HeadingBlock>().First().GetAttributes().Id = "intro";
document.Descendants<ParagraphBlock>().First().GetAttributes().AddClass("lead");
using var writer = new StringWriter();
var renderer = new RoundtripRenderer(writer);
pipeline.Setup(renderer);
renderer.Write(document);
Assert.Equal("# Introduction {#intro}\n\nSome text. {.lead}\n", writer.ToString());The new attributes replace the attributes of the source attached to the same element; when there are several, the others are written as empty attributes, {}, so that the lines stay the same. An element without attributes in the source gets them where the parser reads them back:
| Element | Where the attributes are written |
|---|---|
| Paragraph, heading | At the end of the text |
| Fenced code block, math block, custom container | At the end of the opening fence line |
| Quote, list, thematic break, HTML block | On a line before the block, for a block of the document that follows a blank line |
| Link, image, autolink, code span, raw HTML | Right after the inline |
The attributes that the parser adds without them being in the source, such as an identifier computed from the text of a heading or the class of the language of a fenced code block, are not written while they are unchanged.
The renderer throws an InvalidOperationException when the parser would not read the attributes back:
- a value cannot be written, such as an identifier with a space or a value with a line break;
- the element has no syntax for attributes: a list item, a table, an indented code block, a literal;
- the text before them would read them: an unfinished
{name=, an unclosed code span, a link found by the autolinks extension. A pipe table is parsed with the paragraphs right before and after it, so their text counts too; - an inline ends a line: the parser reads the line ending after attributes as part of them, which would join the next line, or the first row of a table that follows the paragraph;
- the last line of a paragraph would then start another block, such as
*or|; - a new attribute line would join the paragraph before the block.
Emphasis keeps the attributes of the source, but cannot get new ones: they would change which delimiters are emphasis.