feat(source): map sanitized headings to original syntax
Carry parser-derived UTF-16 provenance through sanitation using fresh private markers that are stripped before presentation. Cover Setext, fences, raw HTML, nesting, duplicates, Unicode, frontmatter and forged attributes. Source scroll and collapse UI remains pending. Refs #10 Co-Authored-By: Codex (GPT-6) <noreply@openai.com> Signed-off-by: James Maes <jmaes@dmdbrands.com>
This commit is contained in:
9 files changed
+356
-5
No files matched your search
@@ -309,3 +309,24 @@ heading, hidden descendant, paired find and native query-typing assertions remai
|
||||
No app focus guard is widened, no outcome is substituted and no local desktop
|
||||
input occurs. The previous producer is terminal before the next push.
|
||||
Full PRODUCT.md scope and deferred production signing remain unchanged.
|
||||
|
||||
## Source provenance foundation after native vim acceptance
|
||||
|
||||
6bf125d / CI 38112298176 native job 114390204742 now SUCCESS: 44/44,
|
||||
zero failures/skips, actual physical reading clicks/keys and viewport/heading/find
|
||||
outcomes. Duration 2m23s. Build/format, CodeQL 38112298279, scripts 38112297896,
|
||||
security 38112298300 and PR gates 38112298282 passed. Exact native log retained
|
||||
at artifacts/vim-focus-ui-38112298176.log; installed job 114390204514 is still
|
||||
observed before any subsequent push. Previous dfc41a7 published suite timed out
|
||||
at 180 seconds before final TRX; installed channels were not reached.
|
||||
|
||||
The next local increment maps DocumentOutline.SourceHeadings to original UTF-16
|
||||
source ranges from Markdig AST headings and AngleSharp raw HTML tokens. It handles
|
||||
Setext, fences, raw tags in quotes/lists/inline text, duplicate labels, native
|
||||
newlines, Unicode and frontmatter. A fresh internal marker is allowed only during
|
||||
mapped sanitation and stripped before output; authored markers/IDs stay denied.
|
||||
Raw line lookup is indexed rather than rescanning all lines for each heading.
|
||||
254 core tests/100% measured lines and zero-warning build pass. The original
|
||||
snapshot and public renderer policy remain unchanged. Full scroll/heading/collapse
|
||||
source UI remains unimplemented, not replaced by this foundation. No GUI input,
|
||||
Azure attempt, merge/tag/release or claim of production signing.
|
||||
@@ -25,6 +25,7 @@
|
||||
- [x] Verify paired find and repeated local-fragment reveal on 5a9af63: 41 native/published and 40 per installed channel, zero skips.
|
||||
- [x] Implement all required vim reading/find/heading commands, timed prefixes and native focus/modifier guards (issue #10).
|
||||
- [ ] Verify physical vim browser viewport, collapsed-heading and query-typing acceptance in native/published/installed builds.
|
||||
- [x] Map sanitized surviving headings to original parser-derived source offsets (Setext/fences/raw HTML/frontmatter/duplicates); source UI synchronization remains pending.
|
||||
- [ ] Add synchronized source headings/scroll/collapse (issue #10); audit find/source collapse parity against the pinned reference.
|
||||
- [x] Implement and core-test hidden/metadata/Markdown frontmatter modes, with persisted native controls and attribution.
|
||||
- [x] Verify actual code-font size 17 editing in hosted UI runs.
|
||||
|
||||
@@ -81,6 +81,21 @@ heading operations use fixed host scripts and generated numeric identities only.
|
||||
No content script, native bridge or dependency is added. Physical-key browser and
|
||||
text-entry acceptance remains distinct from pure key-model tests.
|
||||
|
||||
## Parser-derived source provenance
|
||||
|
||||
The source synchronization foundation maps AST headings and raw HTML tokens to
|
||||
original UTF-16 offsets. ATX/Setext, quote/list prefixes, fences, duplicate labels
|
||||
and frontmatter retain parser identity rather than text/regex matching. Raw HTML
|
||||
line lookup uses indexed offsets; source text remains immutable. Only headings
|
||||
surviving the ordinary passive sanitizer receive a source identity.
|
||||
|
||||
A fresh per-render private attribute carries this provenance through sanitation;
|
||||
its generated name is allowed only for the internal outline render, then removed
|
||||
from every element before presentation. The public renderer never admits it, and
|
||||
authored IDs/data attributes remain stripped. No source marker grants a browser
|
||||
capability. Full source scroll/heading/collapse UI synchronization remains pending;
|
||||
these parser tests do not prove browser behavior. ADR status remains proposed.
|
||||
|
||||
## Verification and follow-up
|
||||
|
||||
Core regression tests cover GFM, hostile HTML and resource paths at 100% core line
|
||||
|
||||
@@ -93,5 +93,18 @@ existing document and passes its full path as exactly one `ArgumentList` value w
|
||||
The chosen application runs with the user's permissions and receives the document.
|
||||
Missing files and native launch errors leave the viewer open; logs omit paths and
|
||||
contents. Core tests cover metacharacters/Unicode, relative/script paths, corruption
|
||||
and bounds. Actual fixture-process/UI acceptance is pending; reference editor-list
|
||||
and bounds. Actual fixture-process/UI acceptance passed on 5a9af63 in native, published and
|
||||
installed channels; reference editor-list
|
||||
and folder-opening parity still require review.
|
||||
|
||||
## Source provenance boundary
|
||||
|
||||
Source anchors originate from Markdig heading spans and AngleSharp raw HTML token
|
||||
positions, with original newline/frontmatter/UTF-16 offsets. Repeated titles or
|
||||
fenced heading-like text cannot select another heading's identity. A fresh private
|
||||
attribute name is admitted only during the internal render and removed before any
|
||||
HTML is presented; public renderer policy and authored ID/data-attribute denial
|
||||
remain unchanged. Positions pair only with sanitized surviving headings and do
|
||||
not grant scripts, messages, native access or resources. The original snapshot is
|
||||
immutable. Core regressions cover nested raw markup, Setext, fences, Unicode,
|
||||
frontmatter and forged attributes; source scroll/folding UI acceptance is pending.
|
||||
@@ -21,13 +21,19 @@ public sealed class DocumentOutline
|
||||
/// <summary>Sanitizes first; authored identifiers cannot impersonate the generated navigation targets.</summary>
|
||||
public DocumentOutline(string markdown, FrontmatterMode frontmatter = FrontmatterMode.Hide)
|
||||
{
|
||||
using AngleSharp.Html.Dom.IHtmlDocument document = new HtmlParser().ParseDocument(new MarkdownRenderer().Render(markdown, frontmatter));
|
||||
var sources = new DocumentSourceMap(markdown);
|
||||
var sourceHeadings = new List<DocumentSourceHeading>();
|
||||
using AngleSharp.Html.Dom.IHtmlDocument document = new HtmlParser().ParseDocument(new MarkdownRenderer().RenderWithSources(markdown, frontmatter, sources));
|
||||
IElement body = document.Body!;
|
||||
var fragments = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
foreach(IElement element in body.QuerySelectorAll("h1,h2,h3,h4,h5,h6"))
|
||||
{
|
||||
var heading = new DocumentHeading(_headings.Count, element.LocalName[1] - '0', element.TextContent.Trim());
|
||||
_headings.Add(heading);
|
||||
if(element.GetAttribute(sources.AttributeName) is { } identity && sources.TryGetPosition(identity, out (int Start, int End) position))
|
||||
{
|
||||
sourceHeadings.Add(new DocumentSourceHeading(heading.Index, position.Start, position.End));
|
||||
}
|
||||
_elements.Add(element, heading);
|
||||
element.Id = "mv-heading-" + heading.Index.ToString(CultureInfo.InvariantCulture);
|
||||
string slug = Markdig.Helpers.LinkHelper.UrilizeAsGfm(heading.Title);
|
||||
@@ -38,6 +44,10 @@ public sealed class DocumentOutline
|
||||
}
|
||||
fragments.Add(fragment, element.Id);
|
||||
}
|
||||
foreach(IElement element in body.QuerySelectorAll("[" + sources.AttributeName + "]"))
|
||||
{
|
||||
element.RemoveAttribute(sources.AttributeName);
|
||||
}
|
||||
foreach(IElement link in body.QuerySelectorAll("a[href]"))
|
||||
{
|
||||
string href = link.GetAttribute("href")!;
|
||||
@@ -56,6 +66,7 @@ public sealed class DocumentOutline
|
||||
WrapSections(body, document);
|
||||
Html = body.InnerHtml;
|
||||
Headings = _headings.AsReadOnly();
|
||||
SourceHeadings = sourceHeadings.AsReadOnly();
|
||||
}
|
||||
|
||||
|
||||
@@ -70,6 +81,11 @@ public sealed class DocumentOutline
|
||||
|
||||
|
||||
|
||||
/// <summary>Parser-derived offsets of surviving headings in the original literal snapshot, independent of duplicate labels.</summary>
|
||||
public IReadOnlyList<DocumentSourceHeading> SourceHeadings { get; }
|
||||
|
||||
|
||||
|
||||
/// <summary>Groups each heading until the next peer or ancestor, keeping nested section collapse independent.</summary>
|
||||
private void WrapSections(IElement parent, IDocument document)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
namespace MarkdownViewr.Windows.Core.Documents;
|
||||
|
||||
/// <summary>Original UTF-16 heading syntax offsets, with an exclusive end, paired with a generated rendered identity.</summary>
|
||||
public sealed record DocumentSourceHeading(int Index, int Start, int End);
|
||||
@@ -0,0 +1,141 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.Globalization;
|
||||
using AngleSharp.Dom;
|
||||
using AngleSharp.Html.Parser;
|
||||
using Markdig;
|
||||
using Markdig.Helpers;
|
||||
using Markdig.Renderers.Html;
|
||||
using Markdig.Syntax;
|
||||
using Markdig.Syntax.Inlines;
|
||||
|
||||
|
||||
namespace MarkdownViewr.Windows.Core.Documents;
|
||||
|
||||
/// <summary>Carries parser provenance through sanitation using a fresh private attribute removed before presentation.</summary>
|
||||
internal sealed class DocumentSourceMap
|
||||
{
|
||||
private readonly string _source;
|
||||
private readonly Dictionary<string, (int Start, int End)> _positions = new(StringComparer.Ordinal);
|
||||
|
||||
|
||||
|
||||
/// <summary>A document cannot predict the per-render attribute or provide its own source identities.</summary>
|
||||
public DocumentSourceMap(string source)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
_source = source;
|
||||
AttributeName = "data-mv-source-" + Guid.NewGuid().ToString("N");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Allowed only during this internal render, never in the public renderer's authored attribute policy.</summary>
|
||||
public string AttributeName { get; }
|
||||
|
||||
|
||||
|
||||
/// <summary>Maps only surviving host-marked headings, not matching text labels or authored attributes.</summary>
|
||||
public bool TryGetPosition(string identity, out (int Start, int End) position) => _positions.TryGetValue(identity, out position);
|
||||
|
||||
|
||||
|
||||
/// <summary>AST headings include Setext and exclude fences; raw HTML uses parser tokens and original line slices.</summary>
|
||||
public string Render(string markdown, MarkdownPipeline pipeline, int offset)
|
||||
{
|
||||
MarkdownDocument document = Markdown.Parse(markdown, pipeline);
|
||||
foreach(MarkdownObject node in document.Descendants())
|
||||
{
|
||||
if(node is HeadingBlock heading)
|
||||
{
|
||||
heading.GetAttributes().AddProperty(AttributeName, AddPosition(offset + heading.Span.Start, EndOfLine(offset + heading.Span.End)));
|
||||
}
|
||||
else if(node is HtmlBlock block)
|
||||
{
|
||||
AnnotateHtmlBlock(block, offset);
|
||||
}
|
||||
else if(node is HtmlInline inline && IsHeadingTag(inline.Tag))
|
||||
{
|
||||
int start = offset + inline.Span.Start;
|
||||
inline.Tag = inline.Tag.Insert(3, Attribute(AddPosition(start, EndOfLine(start))));
|
||||
}
|
||||
}
|
||||
return Markdown.ToHtml(document, pipeline);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A real opening heading tag is distinguished from comments, closing tags and heading-like tag names.</summary>
|
||||
private static bool IsHeadingTag(string tag) => tag.Length > 3 && tag.StartsWith("<h", StringComparison.OrdinalIgnoreCase) && tag[2] is >= '1' and <= '6' && (tag[3] is '>' or '/' || char.IsWhiteSpace(tag[3]));
|
||||
|
||||
|
||||
|
||||
/// <summary>Registers the parser-derived range under a generated identity; offsets stay in the immutable original snapshot.</summary>
|
||||
private string AddPosition(int start, int end)
|
||||
{
|
||||
string identity = _positions.Count.ToString(CultureInfo.InvariantCulture);
|
||||
_positions.Add(identity, (start, end));
|
||||
return identity;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Raw heading markup stays on its original source line; the canonical snapshot is never rewritten.</summary>
|
||||
private int EndOfLine(int start)
|
||||
{
|
||||
int end = _source.IndexOf('\n', start);
|
||||
return end >= 0 ? end : _source.Length;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The private marker contains only generated ASCII identities, never document text.</summary>
|
||||
private string Attribute(string identity) => " " + AttributeName + "=\"" + identity + "\"";
|
||||
|
||||
|
||||
|
||||
/// <summary>Source slices retain quote/list indentation offsets while emitted raw HTML omits those Markdown prefixes.</summary>
|
||||
private void AnnotateHtmlBlock(HtmlBlock block, int offset)
|
||||
{
|
||||
StringLine[] lines = block.Lines.Lines;
|
||||
string html = string.Join('\n', lines.Take(block.Lines.Count).Select(line => line.Slice.ToString()));
|
||||
using AngleSharp.Html.Dom.IHtmlDocument document = new HtmlParser(new HtmlParserOptions { IsKeepingSourceReferences = true }).ParseDocument(html);
|
||||
int[] lineStarts = new int[block.Lines.Count];
|
||||
int length = 0;
|
||||
for(int index = 0; index < lineStarts.Length; index++)
|
||||
{
|
||||
lineStarts[index] = length;
|
||||
length += lines[index].Slice.Length + 1;
|
||||
}
|
||||
var inserts = new Dictionary<int, List<(int Position, string Attribute)>>();
|
||||
foreach(IElement heading in document.QuerySelectorAll("h1,h2,h3,h4,h5,h6"))
|
||||
{
|
||||
int position = heading.SourceReference!.Position.Index;
|
||||
int index = Array.BinarySearch(lineStarts, position);
|
||||
if(index < 0)
|
||||
{
|
||||
index = ~index - 1;
|
||||
}
|
||||
int column = position - lineStarts[index];
|
||||
int start = offset + lines[index].Slice.Start + column;
|
||||
if(!inserts.TryGetValue(index, out List<(int Position, string Attribute)>? changes))
|
||||
{
|
||||
changes = [];
|
||||
inserts.Add(index, changes);
|
||||
}
|
||||
changes.Add((column + 3, Attribute(AddPosition(start, EndOfLine(start)))));
|
||||
}
|
||||
foreach((int index, List<(int Position, string Attribute)> changes) in inserts)
|
||||
{
|
||||
string line = lines[index].Slice.ToString();
|
||||
foreach((int position, string attribute) in changes.OrderByDescending(change => change.Position))
|
||||
{
|
||||
line = line.Insert(position, attribute);
|
||||
}
|
||||
lines[index].Slice = new StringSlice(line) { NewLine = lines[index].Slice.NewLine };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ public sealed class MarkdownRenderer
|
||||
.UseTaskLists()
|
||||
.UseEmphasisExtras(EmphasisExtraOptions.Strikethrough)
|
||||
.UseAutoLinks()
|
||||
.UsePreciseSourceLocation()
|
||||
.Build();
|
||||
|
||||
private readonly HtmlSanitizer _sanitizer;
|
||||
@@ -47,7 +48,28 @@ public sealed class MarkdownRenderer
|
||||
|
||||
|
||||
/// <summary>Preserves useful markup while sanitizing every parser result, including authored raw HTML.</summary>
|
||||
public string Render(string markdown, FrontmatterMode frontmatter = FrontmatterMode.Hide)
|
||||
public string Render(string markdown, FrontmatterMode frontmatter = FrontmatterMode.Hide) => Render(markdown, frontmatter, null);
|
||||
|
||||
|
||||
|
||||
/// <summary>Admits only the current private provenance attribute, then restores the ordinary authored-markup policy.</summary>
|
||||
internal string RenderWithSources(string markdown, FrontmatterMode frontmatter, DocumentSourceMap sources)
|
||||
{
|
||||
_sanitizer.AllowedAttributes.Add(sources.AttributeName);
|
||||
try
|
||||
{
|
||||
return Render(markdown, frontmatter, sources);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_sanitizer.AllowedAttributes.Remove(sources.AttributeName);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Uses the same bounded parser and passive sanitation for public HTML and internal source-aware outlines.</summary>
|
||||
private string Render(string markdown, FrontmatterMode frontmatter, DocumentSourceMap? sources)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(markdown);
|
||||
if(markdown.Length > MaximumCharacters)
|
||||
@@ -62,10 +84,11 @@ public sealed class MarkdownRenderer
|
||||
string prefix = !string.IsNullOrEmpty(metadata) ? frontmatter switch
|
||||
{
|
||||
FrontmatterMode.Metadata => DocumentFrontmatter.RenderMetadata(metadata),
|
||||
FrontmatterMode.Markdown => Markdown.ToHtml("---\n" + metadata + "\n---", s_pipeline),
|
||||
FrontmatterMode.Markdown => sources is null ? Markdown.ToHtml("---\n" + metadata + "\n---", s_pipeline) : sources.Render("---\n" + metadata + "\n---", s_pipeline, markdown.IndexOf('\n') + 1 - 4),
|
||||
_ => "",
|
||||
} : "";
|
||||
return _sanitizer.Sanitize(prefix + Markdown.ToHtml(body, s_pipeline));
|
||||
string html = sources is null ? Markdown.ToHtml(body, s_pipeline) : sources.Render(body, s_pipeline, markdown.Length - body.Length);
|
||||
return _sanitizer.Sanitize(prefix + html);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using AngleSharp.Html.Parser;
|
||||
using MarkdownViewr.Windows.Core.Documents;
|
||||
|
||||
|
||||
namespace MarkdownViewr.Windows.Core.Tests;
|
||||
|
||||
/// <summary>Proves original UTF-16 heading provenance through raw HTML, fences, frontmatter and passive sanitation.</summary>
|
||||
public sealed class DocumentSourceHeadingTests
|
||||
{
|
||||
/// <summary>Parser spans distinguish real Setext/ATX/nested headings from fenced pseudo-headings and repeated labels.</summary>
|
||||
[Theory]
|
||||
[InlineData("\n")]
|
||||
[InlineData("\r\n")]
|
||||
public void Create_MarkdownHeadings_MapsOriginalSyntax(string newline)
|
||||
{
|
||||
string source = string.Join(newline, "😀 Intro", "", "# Same", "", "```md", "# Same", "Fake", "====", "```", "", "Same", "====", "", "> ## Quoted", "", "- ### Listed", "", "Last", "----");
|
||||
var outline = new DocumentOutline(source);
|
||||
Assert.Equal(new[] { "Same", "Same", "Quoted", "Listed", "Last" }, outline.Headings.Select(heading => heading.Title), StringComparer.Ordinal);
|
||||
Assert.Equal(outline.Headings.Select(heading => heading.Index), outline.SourceHeadings.Select(heading => heading.Index));
|
||||
Assert.Equal(new[] { "# Same", "Same" + newline + "====", "## Quoted", "### Listed", "Last" + newline + "----" }, outline.SourceHeadings.Select(heading => source[heading.Start..heading.End].TrimEnd('\r')), StringComparer.Ordinal);
|
||||
Assert.DoesNotContain("data-mv-source-", outline.Html, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>HTML token locations account for Markdown quote/list prefixes and multiple tags on one source line.</summary>
|
||||
[Theory]
|
||||
[InlineData("<div><h2>Same</h2><h3>Same</h3></div>\n\n<H4\n title='name'>Four</H4>", 3)]
|
||||
[InlineData("> <div><h2>Same</h2>\n> <h3>Same</h3></div>", 2)]
|
||||
[InlineData("- <h2>Listed</h2>\n\n <h3>Nested</h3>", 2)]
|
||||
[InlineData("Text <h2>Inline</h2> followed by <h3/> end", 2)]
|
||||
public void Create_RawHtmlHeadings_UsesExactOpeningOffsets(string source, int count)
|
||||
{
|
||||
var outline = new DocumentOutline(source);
|
||||
Assert.Equal(count, outline.Headings.Count);
|
||||
Assert.Equal(count, outline.SourceHeadings.Count);
|
||||
foreach(DocumentSourceHeading heading in outline.SourceHeadings)
|
||||
{
|
||||
Assert.Equal("<h" + outline.Headings[heading.Index].Level, source.Substring(heading.Start, 3), ignoreCase: true);
|
||||
Assert.InRange(heading.End, heading.Start + 3, source.Length);
|
||||
}
|
||||
Assert.Equal(outline.SourceHeadings.Count, outline.SourceHeadings.Select(heading => heading.Start).Distinct().Count());
|
||||
Assert.DoesNotContain("data-mv-source-", outline.Html, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Hidden/metadata frontmatter retains body offsets; Markdown mode also maps metadata with its original delimiter spacing.</summary>
|
||||
[Theory]
|
||||
[InlineData(FrontmatterMode.Hide, 1)]
|
||||
[InlineData(FrontmatterMode.Metadata, 1)]
|
||||
[InlineData(FrontmatterMode.Markdown, 2)]
|
||||
public void Create_FrontmatterModes_MapsUnmodifiedSnapshot(FrontmatterMode mode, int count)
|
||||
{
|
||||
const string Source = " --- \r\n# Metadata\r\n \t--- \r\n\r\n# Body\r\nContent";
|
||||
var outline = new DocumentOutline(Source, mode);
|
||||
Assert.Equal(count, outline.SourceHeadings.Count);
|
||||
Assert.Equal(Source.IndexOf("# Body", StringComparison.Ordinal), outline.SourceHeadings[^1].Start);
|
||||
Assert.Equal("# Body", Source[outline.SourceHeadings[^1].Start..outline.SourceHeadings[^1].End].TrimEnd('\r'));
|
||||
if(mode == FrontmatterMode.Markdown)
|
||||
{
|
||||
Assert.Equal(Source.IndexOf("# Metadata", StringComparison.Ordinal), outline.SourceHeadings[0].Start);
|
||||
Assert.Equal("# Metadata", Source[outline.SourceHeadings[0].Start..outline.SourceHeadings[0].End].TrimEnd('\r'));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Removed active containers and authored identity attributes cannot impersonate surviving heading provenance.</summary>
|
||||
[Fact]
|
||||
public void Create_MaliciousMarkup_RetainsOnlyTrustedSurvivingPositions()
|
||||
{
|
||||
const string Source = "<script><h2>Same</h2></script>\n\n<iframe><h2>Same</h2></iframe>\n\n<h2 data-mv-source-fixed='0' data-mv-body='99' id='mv-heading-99' onclick='evil()'>Same</h2>\n\n# Same\n\nText <span>inline</span><!-- <h3>comment</h3> -->";
|
||||
var outline = new DocumentOutline(Source);
|
||||
Assert.Equal(new[] { "Same", "Same" }, outline.Headings.Select(heading => heading.Title), StringComparer.Ordinal);
|
||||
Assert.Equal(2, outline.SourceHeadings.Count);
|
||||
Assert.Equal(Source.IndexOf("<h2 data", StringComparison.Ordinal), outline.SourceHeadings[0].Start);
|
||||
Assert.Equal(Source.IndexOf("# Same", StringComparison.Ordinal), outline.SourceHeadings[1].Start);
|
||||
using AngleSharp.Html.Dom.IHtmlDocument document = new HtmlParser().ParseDocument(outline.Html);
|
||||
Assert.Null(document.QuerySelector("script,iframe,[onclick],[data-mv-source-fixed],#mv-heading-99"));
|
||||
Assert.DoesNotContain("data-mv-source-", outline.Html, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Empty input and no-heading HTML do not manufacture positions; invalid inputs keep the renderer's validation.</summary>
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData("<div>ordinary body</div>")]
|
||||
[InlineData("Text <h22>ordinary</h22> & <hr/> <!-- comment -->")]
|
||||
[InlineData("---\n\n---\nBody")]
|
||||
public void Create_NoHeadings_ReturnsEmptyPositions(string source)
|
||||
{
|
||||
Assert.Empty(new DocumentOutline(source).SourceHeadings);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A mapped render still rejects null, oversized content and corrupt frontmatter modes before use.</summary>
|
||||
[Fact]
|
||||
public void Create_InvalidInput_PreservesRenderingLimits()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() => new DocumentOutline(null!));
|
||||
Assert.Throws<ArgumentException>(() => new DocumentOutline(new string('x', MarkdownRenderer.MaximumCharacters + 1)));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new DocumentOutline("# Body", (FrontmatterMode)99));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user