This site provides a machine-readable index at /llms.txt.

Skip to main content Skip to navigation

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 base IFrontMatter default member for Uid is preserved).
  • The standard response-processing pipeline is active — UsePennington / UseDocSite wires XrefHtmlRewriter to 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;

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>&lt;xref:uid&gt;</c> tags.
/// Produces an <c>&lt;a&gt;</c> element that the later DOM phase
/// (or any downstream HTML parser) can see as normal markup. Skips
/// content inside <c>&lt;code&gt;</c> and <c>&lt;pre&gt;</c> blocks so
/// authoring docs can show literal <c>&lt;xref:uid&gt;</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>[^&gt;]+</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-watched XrefResolver rebuilds 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, and dotnet run -- build surfaces it in the BuildReport.