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:
James MaesandCodex committed 2026-10-10 23:49:19 -05:00
1 parent 6bf125d7fd
commit a0d8fe73aa
9 files changed
+356 -5

No files matched your search

+21
View File
@@ -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.
+1
View File
@@ -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
+14 -1
View File
@@ -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> &amp; <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));
}
}