From 93406f115eb4186687d039a54185d217094d3c27 Mon Sep 17 00:00:00 2001 From: upstream Date: Fri, 4 Sep 2026 03:24:13 +0200 Subject: [PATCH] Upstream xoofx/markdig state of the touched files before 67a2e4948f --- src/Markdig.Tests/TestAutoLinks.cs | 24 + .../HeadingLinkReferenceDefinition.cs | 27 ++ .../Parsers/Inlines/LinkInlineParser.cs | 450 ++++++++++++++++++ src/Markdig/Syntax/LinkReferenceDefinition.cs | 244 ++++++++++ 4 files changed, 745 insertions(+) create mode 100644 src/Markdig.Tests/TestAutoLinks.cs create mode 100644 src/Markdig/Extensions/AutoIdentifiers/HeadingLinkReferenceDefinition.cs create mode 100644 src/Markdig/Parsers/Inlines/LinkInlineParser.cs create mode 100644 src/Markdig/Syntax/LinkReferenceDefinition.cs diff --git a/src/Markdig.Tests/TestAutoLinks.cs b/src/Markdig.Tests/TestAutoLinks.cs new file mode 100644 index 0000000..005c3d1 --- /dev/null +++ b/src/Markdig.Tests/TestAutoLinks.cs @@ -0,0 +1,24 @@ +using Markdig.Extensions.AutoLinks; + +namespace Markdig.Tests; + +[TestFixture] +public class TestAutoLinks +{ + [Test] + [TestCase("https://localhost", "

https://localhost

")] + [TestCase("http://localhost", "

http://localhost

")] + [TestCase("https://l", "

https://l

")] + [TestCase("www.l", "

www.l

")] + [TestCase("https://localhost:5000", "

https://localhost:5000

")] + [TestCase("www.l:5000", "

www.l:5000

")] + public void TestLinksWithAllowDomainWithoutPeriod(string markdown, string expected) + { + var pipeline = new MarkdownPipelineBuilder() + .UseAutoLinks(new AutoLinkOptions { AllowDomainWithoutPeriod = true }) + .Build(); + var html = Markdown.ToHtml(markdown, pipeline); + + Assert.That(html, Is.EqualTo(expected).IgnoreWhiteSpace); + } +} diff --git a/src/Markdig/Extensions/AutoIdentifiers/HeadingLinkReferenceDefinition.cs b/src/Markdig/Extensions/AutoIdentifiers/HeadingLinkReferenceDefinition.cs new file mode 100644 index 0000000..32562cc --- /dev/null +++ b/src/Markdig/Extensions/AutoIdentifiers/HeadingLinkReferenceDefinition.cs @@ -0,0 +1,27 @@ +// Copyright (c) Alexandre Mutel. All rights reserved. +// This file is licensed under the BSD-Clause 2 license. +// See the license.txt file in the project root for more information. + +using Markdig.Syntax; + +namespace Markdig.Extensions.AutoIdentifiers; + +/// +/// A link reference definition to a stored at the level. +/// +/// +public class HeadingLinkReferenceDefinition : LinkReferenceDefinition +{ + /// + /// Initializes a new instance of the HeadingLinkReferenceDefinition class. + /// + public HeadingLinkReferenceDefinition(HeadingBlock headling) + { + Heading = headling; + } + + /// + /// Gets or sets the heading related to this link reference definition. + /// + public HeadingBlock Heading { get; set; } +} diff --git a/src/Markdig/Parsers/Inlines/LinkInlineParser.cs b/src/Markdig/Parsers/Inlines/LinkInlineParser.cs new file mode 100644 index 0000000..83ac5db --- /dev/null +++ b/src/Markdig/Parsers/Inlines/LinkInlineParser.cs @@ -0,0 +1,450 @@ +// Copyright (c) Alexandre Mutel. All rights reserved. +// This file is licensed under the BSD-Clause 2 license. +// See the license.txt file in the project root for more information. + +using Markdig.Helpers; +using Markdig.Renderers.Html; +using Markdig.Syntax; +using Markdig.Syntax.Inlines; + +namespace Markdig.Parsers.Inlines; + +/// +/// An inline parser for . +/// +/// +public class LinkInlineParser : InlineParser +{ + /// + /// Initializes a new instance of the class. + /// + public LinkInlineParser() : this(new LinkOptions()) + { + + } + + /// + /// Initializes a new instance of the class. + /// + public LinkInlineParser(LinkOptions options) + { + Options = options ?? throw new ArgumentNullException(nameof(options)); + OpeningCharacters = ['[', ']', '!']; + } + + /// + /// Gets or sets the options. + /// + public readonly LinkOptions Options; + + /// + /// Attempts to match the parser at the current position. + /// + public override bool Match(InlineProcessor processor, ref StringSlice slice) + { + // The following methods are inspired by the "An algorithm for parsing nested emphasis and links" + // at the end of the CommonMark specs. + + var c = slice.CurrentChar; + + var startPosition = processor.GetSourcePosition(slice.Start, out int line, out int column); + + bool isImage = false; + if (c == '!') + { + isImage = true; + c = slice.NextChar(); + if (c != '[') + { + return false; + } + } + string? label; + SourceSpan labelWithTriviaSpan = SourceSpan.Empty; + switch (c) + { + case '[': + // If this is not an image, we may have a reference link shortcut + // so we try to resolve it here + var saved = slice; + + SourceSpan labelSpan; + // If the label is followed by either a ( or a [, this is not a shortcut + if (processor.TrackTrivia) + { + if (LinkHelper.TryParseLabelTrivia(ref slice, out label, out labelSpan)) + { + labelWithTriviaSpan.Start = labelSpan.Start; // skip opening [ + labelWithTriviaSpan.End = labelSpan.End; // skip closing ] + if (!processor.Document.ContainsLinkReferenceDefinition(label)) + { + label = null; + } + } + } + else + { + if (LinkHelper.TryParseLabel(ref slice, out label, out labelSpan)) + { + if (!processor.Document.ContainsLinkReferenceDefinition(label)) + { + label = null; + } + } + } + slice = saved; + + // Else we insert a LinkDelimiter + slice.SkipChar(); + var linkDelimiter = new LinkDelimiterInline(this) + { + Type = DelimiterType.Open, + Label = label, + LabelSpan = processor.GetSourcePositionFromLocalSpan(labelSpan), + IsImage = isImage, + Span = new SourceSpan(startPosition, processor.GetSourcePosition(slice.Start - 1)), + Line = line, + Column = column + }; + + if (processor.TrackTrivia) + { + linkDelimiter.LabelWithTrivia = new StringSlice(slice.Text, labelWithTriviaSpan.Start, labelWithTriviaSpan.End); + } + + processor.Inline = linkDelimiter; + return true; + + case ']': + slice.SkipChar(); + if (processor.Inline != null) + { + if (TryProcessLinkOrImage(processor, ref slice)) + { + return true; + } + } + + // If we don’t find one, we return a literal slice node ]. + // (Done after by the LiteralInline parser) + return false; + } + + // We don't have an emphasis + return false; + } + + private bool ProcessLinkReference( + InlineProcessor state, + StringSlice text, + string label, + SourceSpan labelWithriviaSpan, + bool isShortcut, + SourceSpan labelSpan, + LinkDelimiterInline parent, + int endPosition, + LocalLabel localLabel) + { + if (!state.Document.TryGetLinkReferenceDefinition(label, out LinkReferenceDefinition? linkRef)) + { + return false; + } + + Inline? link = null; + // Try to use a callback directly defined on the LinkReferenceDefinition + if (linkRef.CreateLinkInline != null) + { + link = linkRef.CreateLinkInline(state, linkRef, parent.FirstChild); + link.Span = new SourceSpan(parent.Span.Start, endPosition); + link.Line = parent.Line; + link.Column = parent.Column; + } + + // Create a default link if the callback was not found + if (link is null) + { + // Inline Link + var linkInline = new LinkInline() + { + Url = HtmlHelper.Unescape(linkRef.Url, removeBackSlash: false), + Title = HtmlHelper.Unescape(linkRef.Title, removeBackSlash: false), + Label = label, + LabelSpan = labelSpan, + UrlSpan = linkRef.UrlSpan, + IsImage = parent.IsImage, + IsShortcut = isShortcut, + Reference = linkRef, + Span = new SourceSpan(parent.Span.Start, endPosition), + Line = parent.Line, + Column = parent.Column, + }; + + if (state.TrackTrivia) + { + linkInline.LabelWithTrivia = new StringSlice(text.Text, labelWithriviaSpan.Start, labelWithriviaSpan.End); + linkInline.LinkRefDefLabel = linkRef.Label; + linkInline.LinkRefDefLabelWithTrivia = linkRef.LabelWithTrivia; + linkInline.LocalLabel = localLabel; + } + + if (Options.OpenInNewWindow) + { + linkInline.GetAttributes().AddPropertyIfNotExist("target", "_blank"); + } + + link = linkInline; + } + + if (link is ContainerInline containerLink) + { + var child = parent.FirstChild; + if (child is null) + { + child = new LiteralInline() + { + Content = StringSlice.Empty, + IsClosed = true, + // Not exact but we leave it like this + Span = parent.Span, + Line = parent.Line, + Column = parent.Column, + }; + containerLink.AppendChild(child); + } + else + { + // Insert all child into the link + while (child != null) + { + var next = child.NextSibling; + child.Remove(); + containerLink.AppendChild(child); + child = next; + } + } + } + + link.IsClosed = true; + + // Process emphasis delimiters + state.PostProcessInlines(0, link, null, false); + + state.Inline = link; + + return true; + } + + private bool TryProcessLinkOrImage(InlineProcessor inlineState, ref StringSlice text) + { + LinkDelimiterInline? openParent = inlineState.Inline!.FirstParentOfType(); + + if (openParent is null) + { + return false; + } + + // If we do find one, but it’s not active, + // we remove the inactive delimiter from the stack, + // and return a literal text node ]. + if (!openParent.IsActive) + { + inlineState.Inline = new LiteralInline() + { + Content = new StringSlice("["), + Span = openParent.Span, + Line = openParent.Line, + Column = openParent.Column, + }; + openParent.ReplaceBy(inlineState.Inline); + return false; + } + + // If we find one and it’s active, + // then we parse ahead to see if we have + // an inline link/image, reference link/image, + // compact reference link/image, + // or shortcut reference link/image + var parentDelimiter = openParent.Parent; + var savedText = text; + + if (text.CurrentChar == '(') + { + LinkInline? link = null; + + if (inlineState.TrackTrivia) + { + link = TryParseInlineLinkTrivia(ref text, inlineState, openParent); + } + else + { + if (LinkHelper.TryParseInlineLink(ref text, out string? url, out string? title, out SourceSpan linkSpan, out SourceSpan titleSpan)) + { + // Inline Link + link = new LinkInline() + { + Url = HtmlHelper.Unescape(url, removeBackSlash: false), + Title = title is null ? null : HtmlHelper.Unescape(title, removeBackSlash: false), + IsImage = openParent.IsImage, + LabelSpan = openParent.LabelSpan, + UrlSpan = inlineState.GetSourcePositionFromLocalSpan(linkSpan), + TitleSpan = inlineState.GetSourcePositionFromLocalSpan(titleSpan), + Span = new SourceSpan(openParent.Span.Start, inlineState.GetSourcePosition(text.Start - 1)), + Line = openParent.Line, + Column = openParent.Column, + }; + } + } + + if (link is not null) + { + openParent.ReplaceBy(link); + // Notifies processor as we are creating an inline locally + inlineState.Inline = link; + + // Process emphasis delimiters + inlineState.PostProcessInlines(0, link, null, false); + + // If we have a link (and not an image), + // we also set all [ delimiters before the opening delimiter to inactive. + // (This will prevent us from getting links within links.) + if (!openParent.IsImage) + { + MarkParentAsInactive(parentDelimiter); + } + + link.IsClosed = true; + + return true; + } + + text = savedText; + } + + var labelSpan = SourceSpan.Empty; + string? label = null; + bool isLabelSpanLocal = true; + + bool isShortcut = false; + LocalLabel localLabel = LocalLabel.Local; + // Handle Collapsed links + if (text.CurrentChar == '[') + { + if (text.PeekChar() == ']') + { + label = openParent.Label; + labelSpan = openParent.LabelSpan; + isLabelSpanLocal = false; + localLabel = LocalLabel.Empty; + text.SkipChar(); // Skip [ + text.SkipChar(); // Skip ] + } + } + else + { + localLabel = LocalLabel.None; + label = openParent.Label; + isShortcut = true; + } + + if (label != null || LinkHelper.TryParseLabelTrivia(ref text, true, out label, out labelSpan)) + { + var labelWithTrivia = new SourceSpan(labelSpan.Start, labelSpan.End); + if (isLabelSpanLocal) + { + labelSpan = inlineState.GetSourcePositionFromLocalSpan(labelSpan); + } + + if (ProcessLinkReference(inlineState, text, label!, labelWithTrivia, isShortcut, labelSpan, openParent, inlineState.GetSourcePosition(text.Start - 1), localLabel)) + { + // Remove the open parent + openParent.Remove(); + if (!openParent.IsImage) + { + MarkParentAsInactive(parentDelimiter); + } + return true; + } + else if (text.CurrentChar != ']' && text.CurrentChar != '[') + { + return false; + } + } + + // We have a nested [ ] + // firstParent.Remove(); + // The opening [ will be transformed to a literal followed by all the children of the [ + + var literal = new LiteralInline() + { + Span = openParent.Span, + Content = new StringSlice(openParent.IsImage ? "![" : "[") + }; + + inlineState.Inline = openParent.ReplaceBy(literal); + return false; + + static LinkInline? TryParseInlineLinkTrivia(ref StringSlice text, InlineProcessor inlineState, LinkDelimiterInline openParent) + { + if (LinkHelper.TryParseInlineLinkTrivia( + ref text, + out string? url, + out SourceSpan unescapedUrlSpan, + out string? title, + out SourceSpan unescapedTitleSpan, + out char titleEnclosingCharacter, + out SourceSpan linkSpan, + out SourceSpan titleSpan, + out SourceSpan triviaBeforeLink, + out SourceSpan triviaAfterLink, + out SourceSpan triviaAfterTitle, + out bool urlHasPointyBrackets)) + { + var wsBeforeLink = new StringSlice(text.Text, triviaBeforeLink.Start, triviaBeforeLink.End); + var wsAfterLink = new StringSlice(text.Text, triviaAfterLink.Start, triviaAfterLink.End); + var wsAfterTitle = new StringSlice(text.Text, triviaAfterTitle.Start, triviaAfterTitle.End); + var unescapedUrl = new StringSlice(text.Text, unescapedUrlSpan.Start, unescapedUrlSpan.End); + var unescapedTitle = new StringSlice(text.Text, unescapedTitleSpan.Start, unescapedTitleSpan.End); + + return new LinkInline() + { + TriviaBeforeUrl = wsBeforeLink, + Url = HtmlHelper.Unescape(url, removeBackSlash: false), + UnescapedUrl = unescapedUrl, + UrlHasPointyBrackets = urlHasPointyBrackets, + TriviaAfterUrl = wsAfterLink, + Title = HtmlHelper.Unescape(title, removeBackSlash: false), + UnescapedTitle = unescapedTitle, + TitleEnclosingCharacter = titleEnclosingCharacter, + TriviaAfterTitle = wsAfterTitle, + IsImage = openParent.IsImage, + LabelSpan = openParent.LabelSpan, + UrlSpan = inlineState.GetSourcePositionFromLocalSpan(linkSpan), + TitleSpan = inlineState.GetSourcePositionFromLocalSpan(titleSpan), + Span = new SourceSpan(openParent.Span.Start, inlineState.GetSourcePosition(text.Start - 1)), + Line = openParent.Line, + Column = openParent.Column, + }; + } + + return null; + } + } + + private static void MarkParentAsInactive(Inline? inline) + { + while (inline != null) + { + if (inline is LinkDelimiterInline linkInline) + { + if (linkInline.IsImage) + { + break; + } + + linkInline.IsActive = false; + } + + inline = inline.Parent; + } + } +} diff --git a/src/Markdig/Syntax/LinkReferenceDefinition.cs b/src/Markdig/Syntax/LinkReferenceDefinition.cs new file mode 100644 index 0000000..056d91d --- /dev/null +++ b/src/Markdig/Syntax/LinkReferenceDefinition.cs @@ -0,0 +1,244 @@ +// Copyright (c) Alexandre Mutel. All rights reserved. +// This file is licensed under the BSD-Clause 2 license. +// See the license.txt file in the project root for more information. + +using System.Diagnostics.CodeAnalysis; + +using Markdig.Helpers; +using Markdig.Parsers; +using Markdig.Syntax.Inlines; + +namespace Markdig.Syntax; + +/// +/// A link reference definition (Section 4.7 CommonMark specs) +/// +/// +public class LinkReferenceDefinition : LeafBlock +{ + private TriviaProperties? _trivia => TryGetDerivedTrivia(); + private TriviaProperties Trivia => GetOrSetDerivedTrivia(); + + /// + /// Creates an inline link for the specified . + /// + /// State of the inline. + /// The link reference. + /// The child. + /// An inline link or null to use the default implementation + public delegate Inline CreateLinkInlineDelegate(InlineProcessor inlineState, LinkReferenceDefinition linkRef, Inline? child = null); + + /// + /// Initializes a new instance of the class. + /// + public LinkReferenceDefinition() : base(null) + { + IsOpen = false; + } + + /// + /// Initializes a new instance of the class. + /// + /// The label. + /// The URL. + /// The title. + public LinkReferenceDefinition(string? label, string? url, string? title) : this() + { + Label = label; + Url = url; + Title = title; + } + + /// + /// Gets or sets the label. Text is normalized according to spec. + /// + /// https://spec.commonmark.org/0.29/#matches + public string? Label { get; set; } + + /// + /// The label span + /// + public SourceSpan LabelSpan; + + /// + /// Non-normalized Label (includes trivia) + /// Trivia: only parsed when is enabled, otherwise + /// . + /// + public StringSlice LabelWithTrivia { get => _trivia?.LabelWithTrivia ?? StringSlice.Empty; set => Trivia.LabelWithTrivia = value; } + + /// + /// Whitespace before the . + /// Trivia: only parsed when is enabled, otherwise + /// . + /// + public StringSlice TriviaBeforeUrl { get => _trivia?.TriviaBeforeUrl ?? StringSlice.Empty; set => Trivia.TriviaBeforeUrl = value; } + + /// + /// Gets or sets the URL. + /// + public string? Url { get; set; } + + /// + /// The URL span + /// + public SourceSpan UrlSpan; + + /// + /// Non-normalized . + /// Trivia: only parsed when is enabled, otherwise + /// . + /// + public StringSlice UnescapedUrl { get => _trivia?.UnescapedUrl ?? StringSlice.Empty; set => Trivia.UnescapedUrl = value; } + + /// + /// True when the is enclosed in point brackets in the source document. + /// Trivia: only parsed when is enabled, otherwise + /// false. + /// + public bool UrlHasPointyBrackets { get => _trivia?.UrlHasPointyBrackets ?? false; set => Trivia.UrlHasPointyBrackets = value; } + + /// + /// gets or sets the whitespace before a . + /// Trivia: only parsed when is enabled, otherwise + /// . + /// + public StringSlice TriviaBeforeTitle { get => _trivia?.TriviaBeforeTitle ?? StringSlice.Empty; set => Trivia.TriviaBeforeTitle = value; } + + /// + /// Gets or sets the title. + /// + public string? Title { get; set; } + + /// + /// The title span + /// + public SourceSpan TitleSpan; + + /// + /// Non-normalized . + /// Trivia: only parsed when is enabled, otherwise + /// . + /// + public StringSlice UnescapedTitle { get => _trivia?.UnescapedTitle ?? StringSlice.Empty; set => Trivia.UnescapedTitle = value; } + + /// + /// Gets or sets the character the is enclosed in. + /// Trivia: only parsed when is enabled, otherwise \0. + /// + public char TitleEnclosingCharacter { get => _trivia?.TitleEnclosingCharacter ?? default; set => Trivia.TitleEnclosingCharacter = value; } + + /// + /// Gets or sets the create link inline callback for this instance. + /// + /// + /// This callback is called when an inline link is matching this reference definition. + /// + public CreateLinkInlineDelegate? CreateLinkInline { get; set; } + + /// + /// Tries to the parse the specified text into a definition. + /// + /// Type of the text + /// The text. + /// The block. + /// true if parsing is successful; false otherwise + public static bool TryParse(ref T text, [NotNullWhen(true)] out LinkReferenceDefinition? block) where T : ICharIterator + { + block = null; + + var startSpan = text.Start; + + if (!LinkHelper.TryParseLinkReferenceDefinition(ref text, out string? label, out string? url, out string? title, out SourceSpan labelSpan, out SourceSpan urlSpan, out SourceSpan titleSpan)) + { + return false; + } + + block = new LinkReferenceDefinition(label, url, title) + { + LabelSpan = labelSpan, + UrlSpan = urlSpan, + TitleSpan = titleSpan, + Span = new SourceSpan(startSpan, titleSpan.End > 0 ? titleSpan.End: urlSpan.End) + }; + return true; + } + + /// + /// Tries to the parse the specified text into a definition. + /// + /// Type of the text + /// The text. + /// The block. + /// + /// + /// + /// + /// + /// + /// + /// true if parsing is successful; false otherwise + public static bool TryParseTrivia( + ref T text, + [NotNullWhen(true)] out LinkReferenceDefinition? block, + out SourceSpan triviaBeforeLabel, + out SourceSpan labelWithTrivia, + out SourceSpan triviaBeforeUrl, + out SourceSpan unescapedUrl, + out SourceSpan triviaBeforeTitle, + out SourceSpan unescapedTitle, + out SourceSpan triviaAfterTitle) where T : ICharIterator + { + block = null; + + var startSpan = text.Start; + + if (!LinkHelper.TryParseLinkReferenceDefinitionTrivia( + ref text, + out triviaBeforeLabel, + out string? label, + out labelWithTrivia, + out triviaBeforeUrl, + out string? url, + out unescapedUrl, + out bool urlHasPointyBrackets, + out triviaBeforeTitle, + out string? title, + out unescapedTitle, + out char titleEnclosingCharacter, + out NewLine newLine, + out triviaAfterTitle, + out SourceSpan labelSpan, + out SourceSpan urlSpan, + out SourceSpan titleSpan)) + { + return false; + } + + block = new LinkReferenceDefinition(label, url, title) + { + UrlHasPointyBrackets = urlHasPointyBrackets, + TitleEnclosingCharacter = titleEnclosingCharacter, + //LabelWithWhitespace = labelWithWhitespace, + LabelSpan = labelSpan, + UrlSpan = urlSpan, + //UnescapedUrl = unescapedUrl, + //UnescapedTitle = unescapedTitle, + TitleSpan = titleSpan, + Span = new SourceSpan(startSpan, titleSpan.End > 0 ? titleSpan.End : urlSpan.End), + NewLine = newLine, + }; + return true; + } + + private sealed class TriviaProperties + { + public StringSlice LabelWithTrivia; + public StringSlice TriviaBeforeUrl; + public StringSlice UnescapedUrl; + public bool UrlHasPointyBrackets; + public StringSlice TriviaBeforeTitle; + public StringSlice UnescapedTitle; + public char TitleEnclosingCharacter; + } +} \ No newline at end of file