Cross-reference pages by uid
When relative ../foo/bar.md links break every time a file moves or a section is renamed, assign each target page a stable uid: and link to it by name. XrefHtmlRewriter resolves both link forms at request and build time, so moves do not break links.
Assumptions
- A working Pennington site with markdown under
Content/(see Work with front matter if not). - Pages use
DocSiteFrontMatter(or another type whose baseIFrontMatterdefault member forUidis preserved). - The standard response-processing pipeline is active —
UsePennington/UseDocSitewiresXrefHtmlRewriterto run on every HTML response.
Declare a uid: on the target page
Every page type already has a uid: field through IFrontMatter — filling it opts the page in. Choose a stable, dot-separated string that does not encode the current URL path; the value of a uid is that it survives a move.
---
title: Cross-references (target)
uid: kitchen-sink.main.cross-references-b
---
The backing front-matter contract:
/// <summary>Stable cross-reference identifier used by xref links.</summary>
string? Uid => null;
Link forms
Inline <xref:uid> form
The angle-bracket form resolves in the pre-parse phase — XrefResolvingService regex-replaces it before AngleSharp sees the document, because <xref:…> is not valid HTML and the parser would swallow it. Link text defaults to the target page's Title.
See <xref:kitchen-sink.main.cross-references-b> for the other half of this pairing.
The pre-parse phase that handles this form:
/// <summary>
/// Phase 1: regex substitution of raw <c><xref:uid></c> tags.
/// Produces an <c><a></c> element that the later DOM phase
/// (or any downstream HTML parser) can see as normal markup. Skips
/// content inside <c><code></c> and <c><pre></c> blocks so
/// authoring docs can show literal <c><xref:uid></c> samples in
/// fenced code without the rewriter latching onto them — the highlighter
/// also splits long tokens across span boundaries, which would otherwise
/// let <c>[^>]+</c> consume span markup as the uid.
/// </summary>
public async Task<string> ResolveXrefTagsAsync(string html, DiagnosticContext? diagnostics)
{
if (!html.Contains("<xref:", StringComparison.Ordinal)) return html;
// Build a mask of byte ranges to skip (inside <code> or <pre> elements).
var skipRanges = BuildSkipRanges(html);
var matches = XrefTagRegex().Matches(html);
if (matches.Count == 0) return html;
for (var i = matches.Count - 1; i >= 0; i--)
{
var match = matches[i];
if (IsInsideSkipRange(match.Index, skipRanges)) continue;
var uid = match.Groups[1].Value;
var xref = await _resolver.ResolveAsync(uid);
string replacement;
if (xref is not null)
{
replacement = $"""<a href="{WebUtility.HtmlEncode(xref.Route.CanonicalPath.Value)}">{WebUtility.HtmlEncode(xref.Title)}</a>""";
}
else
{
diagnostics?.AddWarning($"Unresolved xref: {uid}", "XrefResolver");
replacement = $"""<a href="xref:{WebUtility.HtmlEncode(uid)}" data-xref-error="Reference not found" data-xref-uid="{WebUtility.HtmlEncode(uid)}">{WebUtility.HtmlEncode(uid)}</a>""";
}
html = string.Concat(html.AsSpan(0, match.Index), replacement, html.AsSpan(match.Index + match.Length));
}
return html;
}
Anchor-style [text](xref:uid) form
The anchor-style form is a standard markdown link whose href starts with xref:. Markdig emits a regular <a> and the DOM phase rewrites the href after parsing. This form carries a custom link label.
See the [cross-reference target page](xref:kitchen-sink.main.cross-references-b) for details.
The DOM phase that handles this form:
/// <summary>
/// Phase 2: DOM rewrite of <c>a[href^='xref:']</c> links on an
/// already-parsed document. Returns true when any link was rewritten
/// — callers that used <see cref="ResolveAsync"/> use this to decide
/// whether to re-serialize the document.
/// </summary>
public async Task<bool> ResolveXrefLinksAsync(IDocument document, DiagnosticContext? diagnostics)
{
var xrefLinks = document.QuerySelectorAll("a[href^='xref:']")
.OfType<IHtmlAnchorElement>()
.ToList();
if (xrefLinks.Count == 0)
return false;
foreach (var link in xrefLinks)
{
var href = link.GetAttribute("href");
if (href is null || !href.StartsWith("xref:", StringComparison.OrdinalIgnoreCase))
continue;
var uid = href[5..];
var xref = await _resolver.ResolveAsync(uid);
if (xref is not null)
{
link.SetAttribute("href", xref.Route.CanonicalPath.Value);
if (link.TextContent.StartsWith("xref:", StringComparison.OrdinalIgnoreCase))
link.TextContent = xref.Title;
}
else
{
diagnostics?.AddWarning($"Unresolved xref: {uid}", "XrefResolver");
link.SetAttribute("data-xref-error", "Reference not found");
link.SetAttribute("data-xref-uid", uid);
}
}
return true;
}
How resolution works
Both phases run inside XrefHtmlRewriter (Order => 10), which executes before LocaleLinkHtmlRewriter and BaseUrlHtmlRewriter so later rewriters see canonical paths — identically in dev serve and build. XrefResolver owns the uid → URL map, built lazily from IContentService.GetCrossReferencesAsync() and file-watched, so moving or renaming a target page invalidates the lookup without a restart.
The rewriter that wires both phases into the response pipeline:
/// <summary>
/// Resolves <c>xref:</c> cross-reference links on the shared response
/// document. Delegates both phases to <see cref="XrefResolvingService"/>.
/// Runs first in the HTML rewriting pipeline so the canonical paths it
/// emits are available to later locale and base-URL rewriters.
/// </summary>
public sealed class XrefHtmlRewriter : IHtmlResponseRewriter
{
private readonly XrefResolvingService _service;
/// <summary>Initializes the rewriter with the xref resolving service.</summary>
public XrefHtmlRewriter(XrefResolvingService service)
{
_service = service;
}
/// <inheritdoc/>
public int Order => 10;
/// <inheritdoc/>
public bool ShouldApply(HttpContext context) => true;
/// <inheritdoc/>
public Task<string> PreParseAsync(string html, HttpContext context)
{
var diagnostics = context.RequestServices.GetRequiredService<DiagnosticContext>();
return _service.ResolveXrefTagsAsync(html, diagnostics);
}
/// <inheritdoc/>
public async Task ApplyAsync(IDocument document, HttpContext context)
{
var diagnostics = context.RequestServices.GetRequiredService<DiagnosticContext>();
await _service.ResolveXrefLinksAsync(document, diagnostics);
}
}
A round-trip pairing lives in the repo at examples/DocSiteKitchenSinkExample/Content/main/cross-references-a.md (with its sibling cross-references-b.md).
Verify
- Run
dotnet run; visit the source page and inspect the rendered HTML — both<xref:…>and[text](xref:…)become ordinary<a href="/canonical/path">elements. - Move or rename the target markdown file without touching the
uid:— the next reload still resolves the link (file-watchedXrefResolverrebuilds the lookup). - Break a uid on purpose — the link renders with
data-xref-error="Reference not found", a warning appears in the dev diagnostic overlay, anddotnet run -- buildsurfaces it in theBuildReport.
Related
- Reference: Response processing interfaces
- Reference: Front matter key reference
- Background: Cross-reference resolution