feat(host): article markdown rendering with redaction, infobox, TOC, mermaid

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-25 08:47:58 +01:00
co-authored by Claude Opus 5.5
parent 5b625a241f
commit 87f88a0fb6
7 changed files with 3145 additions and 0 deletions
+3
View File
@@ -13,6 +13,9 @@ public sealed record Attachment(string MediaType, byte[] Bytes, string? Caption
["image/gif"] = "gif",
};
/// <summary>The file extensions of all supported media types.</summary>
public static IReadOnlyCollection<string> FileExtensions { get; } = Extensions.Values;
/// <summary>The file extension for a supported media type, or null.</summary>
public static string? ExtensionFor(string mediaType) => Extensions.TryGetValue(mediaType, out var e) ? e : null;
+428
View File
@@ -0,0 +1,428 @@
using System.Net;
using System.Text;
using System.Text.RegularExpressions;
using Markdig;
using Markdig.Extensions.CustomContainers;
using Markdig.Helpers;
using Markdig.Renderers;
using Markdig.Renderers.Html;
using Markdig.Syntax;
using Markdig.Syntax.Inlines;
using Microsoft.Extensions.Logging;
using Wiki.Core.Model;
using Wiki.Core.Parsing;
namespace Wiki.Host.Rendering;
public sealed record TocEntry(int Level, string Id, string Text);
public sealed record RenderedArticle(string Html, string? InfoboxHtml, IReadOnlyList<TocEntry> Toc);
/// <summary>
/// Renders an encyclopedia article body. For an anonymous reader the body goes through
/// <see cref="ArticleBody.RedactPrivate"/> first, and the parsed document may then contain only
/// <c>infobox</c> and <c>private-locked</c> containers; any other container renders the whole body locked.
/// Raw HTML is disabled. <c>[[slug]]</c> links show the target's title, and links to slugs the reader
/// may not see are dropped with their text. Images are served by the media endpoint by index into
/// <see cref="ImagePaths"/>; every other image and every link into <c>raw/</c> is dropped.
/// </summary>
public static partial class ArticleMarkdown
{
private static readonly MarkdownPipeline Pipeline = new MarkdownPipelineBuilder()
.UseCustomContainers()
.UsePipeTables()
.UseGridTables()
.UseAutoIdentifiers()
.UseEmphasisExtras()
.UseDiagrams()
.DisableHtml()
.Build();
private const string Infobox = "infobox";
private const string Locked = "private-locked";
private const string RawPrefix = "raw/";
private static readonly object WrappedKey = new();
private static readonly Regex RawImage = new(
$@"^raw/[A-Za-z0-9._-]+\.({string.Join('|', Attachment.FileExtensions.Select(Regex.Escape))})\z",
RegexOptions.CultureInvariant);
[GeneratedRegex(@"\[\[([^\]\[|#]+)(?:#[^\]\[|]*)?(?:\|[^\]\[]*)?\]\]")]
private static partial Regex LinkPattern();
[GeneratedRegex(@"^(:{3,})[ \t]*(.*?)[ \t]*$")]
private static partial Regex ContainerLine();
[GeneratedRegex(@"^ {0,3}(`{3,}|~{3,})(.*)$")]
private static partial Regex FenceLine();
private static readonly string[] Separators = [", ", " · "];
/// <param name="signedIn">false: the body is redacted first.</param>
/// <param name="visibleTitles">slug → title in <paramref name="lang"/> of every article this reader may see.</param>
public static RenderedArticle Render(string body, string lang, string slug, bool signedIn,
IReadOnlyDictionary<string, string> visibleTitles, ILogger? logger = null) =>
RenderVisible(signedIn ? body : ArticleBody.RedactPrivate(body), lang, slug, signedIn, visibleTitles, logger);
/// <summary>Renders a body that is already what this reader may see.</summary>
internal static RenderedArticle RenderVisible(string visibleBody, string lang, string slug, bool signedIn,
IReadOnlyDictionary<string, string> visibleTitles, ILogger? logger = null)
{
var doc = Parse(visibleBody);
if (!signedIn)
{
if (doc.Descendants<CustomContainer>().Any(c => c.Info is not (Infobox or Locked)))
{
logger?.LogWarning("Article {Slug} ({Lang}) has an unexpected container after redaction; rendering it locked", slug, lang);
doc = Parse($"{ArticleBody.PrivateLocked}\n{ArticleBody.Close}");
}
foreach (var c in doc.Descendants<CustomContainer>().Where(c => c.Info is not (Infobox or Locked)).ToList())
c.Parent?.Remove(c);
}
RewriteImagesAndRawLinks(doc, lang, slug);
ResolveWikiLinks(doc, lang, visibleTitles);
RemoveEmptyListItems(doc);
var infobox = ExtractInfobox(doc, signedIn);
if (infobox is not null) RemoveEmptyRows(infobox);
var toc = doc.Descendants<HeadingBlock>()
.Where(h => h.Level is 2 or 3 && h.GetAttributes().Id is not null)
.Select(h => new TocEntry(h.Level, h.GetAttributes().Id!, PlainText(h.Inline).Trim()))
.ToList();
return new RenderedArticle(
ToHtml(doc, lang, slug, signedIn),
infobox is null ? null : ToHtml(infobox, lang, slug, signedIn),
toc);
}
/// <summary>The valid <c>raw/</c> image paths of a body, in order; the media index <c>n</c> of <see cref="Render"/> points into this list.</summary>
public static IReadOnlyList<string> ImagePaths(string visibleBody) =>
Parse(visibleBody).Descendants<LinkInline>()
.Where(l => l.IsImage && l.Url is not null && RawImage.IsMatch(l.Url))
.Select(l => l.Url!)
.ToList();
private static MarkdownDocument Parse(string body) => Markdown.Parse(NestContainers(body), Pipeline);
/// <summary>
/// Gives every column-0 container that holds another one more colons than its content, so that Markdig
/// closes the inner container first: <c>::: private</c> around <c>::: infobox</c> becomes <c>:::: private</c>.
/// </summary>
private static string NestContainers(string body)
{
var lines = body.Replace("\r\n", "\n").Replace('\r', '\n').Split('\n');
var open = new Stack<int>();
var height = new int[lines.Length];
var pairs = new List<(int Open, int Close)>();
string? fence = null;
for (var i = 0; i < lines.Length; i++)
{
var f = FenceLine().Match(lines[i]);
if (fence is not null)
{
var marker = f.Groups[1].Value;
if (f.Success && marker[0] == fence[0] && marker.Length >= fence.Length && f.Groups[2].Value.Trim().Length == 0) fence = null;
continue;
}
if (f.Success)
{
fence = f.Groups[1].Value;
continue;
}
var c = ContainerLine().Match(lines[i]);
if (!c.Success) continue;
if (c.Groups[2].Length > 0)
{
open.Push(i);
}
else if (open.Count > 0)
{
var o = open.Pop();
pairs.Add((o, i));
if (open.Count > 0) height[open.Peek()] = Math.Max(height[open.Peek()], height[o] + 1);
}
}
foreach (var (o, close) in pairs.Where(p => height[p.Open] > 0))
{
var colons = new string(':', 3 + height[o]);
lines[o] = $"{colons} {ContainerLine().Match(lines[o]).Groups[2].Value}";
lines[close] = colons;
}
return string.Join('\n', lines);
}
private static void RewriteImagesAndRawLinks(MarkdownDocument doc, string lang, string slug)
{
var index = 0;
foreach (var link in doc.Descendants<LinkInline>().ToList())
{
if (link.IsImage)
{
if (link.Url is not null && RawImage.IsMatch(link.Url))
link.Url = $"/house/media/{Uri.EscapeDataString(lang)}/{Uri.EscapeDataString(slug)}/{index++}";
else
link.Remove();
}
else if (link.Url?.Trim().TrimStart('.', '/').StartsWith(RawPrefix, StringComparison.OrdinalIgnoreCase) == true)
{
Unwrap(link);
}
}
}
private static void Unwrap(ContainerInline container)
{
var child = container.FirstChild;
while (child is not null)
{
var next = child.NextSibling;
child.Remove();
container.InsertBefore(child);
child = next;
}
container.Remove();
}
/// <summary>
/// Replaces each <c>[[slug]]</c> in literal text by a link titled from <paramref name="visibleTitles"/>, or drops it,
/// together with one adjacent list separator, when the slug is not there. A heading that held a link gets its id
/// recomputed from the resolved text, so no dropped slug survives in an id.
/// </summary>
private static void ResolveWikiLinks(MarkdownDocument doc, string lang, IReadOnlyDictionary<string, string> visibleTitles)
{
var ids = doc.Descendants<HeadingBlock>().Select(h => h.GetAttributes().Id).OfType<string>().ToHashSet(StringComparer.Ordinal);
foreach (var leaf in doc.Descendants<LeafBlock>().Where(b => b.Inline is not null).ToList())
{
var root = leaf.Inline!;
var containers = new List<ContainerInline> { root };
containers.AddRange(root.Descendants<ContainerInline>());
var changed = false;
foreach (var container in containers)
changed |= ResolveIn(container, lang, visibleTitles);
if (changed && leaf is HeadingBlock heading)
{
var attributes = heading.GetAttributes();
if (attributes.Id is not null) ids.Remove(attributes.Id);
var baseId = LinkHelper.Urilize(PlainText(root).Trim(), true);
if (baseId.Length == 0) baseId = "section";
var id = baseId;
for (var n = 1; ids.Contains(id); n++) id = $"{baseId}-{n}";
ids.Add(id);
attributes.Id = id;
}
}
}
private static bool ResolveIn(ContainerInline container, string lang, IReadOnlyDictionary<string, string> visibleTitles)
{
var insideLink = false;
for (Inline? p = container; p is not null; p = p.Parent)
insideLink |= p is LinkInline;
var changed = false;
var child = container.FirstChild;
while (child is not null)
{
if (child is not LiteralInline)
{
child = child.NextSibling;
continue;
}
var run = new List<LiteralInline>();
while (child is LiteralInline literal)
{
run.Add(literal);
child = child.NextSibling;
}
var text = string.Concat(run.Select(l => l.Content.ToString()));
if (!LinkPattern().IsMatch(text)) continue;
var parts = new List<Inline>();
var pending = new StringBuilder();
var skipSeparator = false;
var pos = 0;
foreach (Match m in LinkPattern().Matches(text))
{
AppendText(text[pos..m.Index]);
pos = m.Index + m.Length;
var slug = m.Groups[1].Value.Trim();
if (visibleTitles.TryGetValue(slug, out var title))
{
FlushText();
if (insideLink)
{
parts.Add(new LiteralInline(title));
}
else
{
var link = new LinkInline($"/house/{Uri.EscapeDataString(lang)}/{Uri.EscapeDataString(slug)}", "");
link.AppendChild(new LiteralInline(title));
parts.Add(link);
}
}
else
{
var before = pending.ToString();
var separator = Separators.FirstOrDefault(s => before.EndsWith(s, StringComparison.Ordinal));
if (separator is not null) pending.Length -= separator.Length;
else skipSeparator = true;
}
}
AppendText(text[pos..]);
FlushText();
foreach (var part in parts) run[0].InsertBefore(part);
foreach (var literal in run) literal.Remove();
changed = true;
void AppendText(string segment)
{
if (skipSeparator)
{
var separator = Separators.FirstOrDefault(s => segment.StartsWith(s, StringComparison.Ordinal));
if (separator is not null) segment = segment[separator.Length..];
if (segment.Length > 0 || separator is not null) skipSeparator = false;
}
pending.Append(segment);
}
void FlushText()
{
if (pending.Length > 0) parts.Add(new LiteralInline(pending.ToString()));
pending.Clear();
skipSeparator = false;
}
}
return changed;
}
private static void RemoveEmptyListItems(MarkdownDocument doc)
{
foreach (var item in doc.Descendants<ListItemBlock>().ToList().Where(i => !HasContent(i)))
{
var list = item.Parent;
list?.Remove(item);
if (list is ListBlock { Count: 0 }) list.Parent?.Remove(list);
}
}
/// <summary>Removes rows whose cells after the first are all empty.</summary>
private static void RemoveEmptyRows(CustomContainer infobox)
{
foreach (var row in infobox.Descendants<Markdig.Extensions.Tables.TableRow>().ToList())
{
if (row.Count >= 2 && row.Skip(1).All(cell => !HasContent(cell)))
row.Parent?.Remove(row);
}
}
private static bool HasContent(MarkdownObject block) =>
block.Descendants().Any(o => o switch
{
LiteralInline l => !l.Content.IsEmptyOrWhitespace(),
CodeInline or AutolinkInline or HtmlEntityInline or CodeBlock or ThematicBreakBlock => true,
LinkInline { IsImage: true } => true,
_ => false,
});
/// <summary>
/// Takes the first top-level infobox out of the document, or for a signed-in reader one that is the content of a
/// top-level private block; that one is marked private and the then empty private block goes too.
/// </summary>
private static CustomContainer? ExtractInfobox(MarkdownDocument doc, bool signedIn)
{
foreach (var block in doc.ToList())
{
if (block is not CustomContainer container) continue;
if (container.Info == Infobox)
{
doc.Remove(container);
return container;
}
if (signedIn && container.Info != Locked
&& container.OfType<CustomContainer>().FirstOrDefault(c => c.Info == Infobox) is { } inner)
{
container.Remove(inner);
if (!HasContent(container)) doc.Remove(container);
inner.SetData(WrappedKey, true);
return inner;
}
}
return null;
}
private static string PlainText(ContainerInline? container)
{
var sb = new StringBuilder();
Append(container);
return sb.ToString();
void Append(Inline? inline)
{
switch (inline)
{
case LiteralInline l: sb.Append(l.Content.ToString()); break;
case CodeInline c: sb.Append(c.Content); break;
case HtmlEntityInline e: sb.Append(e.Transcoded.ToString()); break;
case LineBreakInline: sb.Append(' '); break;
case ContainerInline c:
for (var child = c.FirstChild; child is not null; child = child.NextSibling) Append(child);
break;
}
}
}
private static string ToHtml(MarkdownObject obj, string lang, string slug, bool signedIn)
{
using var writer = new StringWriter();
var renderer = new HtmlRenderer(writer);
Pipeline.Setup(renderer);
renderer.ObjectRenderers.Replace<HtmlCustomContainerRenderer>(new ContainerRenderer(lang, slug, signedIn));
renderer.Render(obj);
writer.Flush();
return writer.ToString();
}
private sealed class ContainerRenderer(string lang, string slug, bool signedIn) : HtmlObjectRenderer<CustomContainer>
{
private bool English => lang == "en";
protected override void Write(HtmlRenderer renderer, CustomContainer obj)
{
renderer.EnsureLine();
switch (obj.Info)
{
case Infobox when obj.GetData(WrappedKey) is true:
renderer.WriteLine("<div class=\"infobox private\">");
renderer.WriteLine(Label());
renderer.WriteChildren(obj);
renderer.WriteLine("</div>");
break;
case Infobox:
renderer.WriteLine("<div class=\"infobox\">");
renderer.WriteChildren(obj);
renderer.WriteLine("</div>");
break;
case Locked:
var returnUrl = Uri.EscapeDataString($"/house/{Uri.EscapeDataString(lang)}/{Uri.EscapeDataString(slug)}");
var text = English ? "Private section — log in to read" : "Privater Abschnitt — anmelden zum Lesen";
renderer.WriteLine(
$"<div class=\"private-locked\"><span aria-hidden=\"true\">🔒</span> <a href=\"/house/login?returnUrl={returnUrl}\">{WebUtility.HtmlEncode(text)}</a></div>");
break;
case var _ when signedIn:
renderer.WriteLine("<div class=\"private\">");
renderer.WriteLine(Label());
renderer.WriteChildren(obj);
renderer.WriteLine("</div>");
break;
}
}
private string Label() =>
$"<div class=\"private-label\"><span aria-hidden=\"true\">🔒</span> {(English ? "Private section" : "Privater Abschnitt")}</div>";
}
}
+4
View File
@@ -16,6 +16,10 @@
<PackageReference Include="MudBlazor" Version="9.10.0" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="Wiki.Host.Tests" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
+117
View File
@@ -0,0 +1,117 @@
/* House encyclopedia article, Wikipedia look. */
.house-article {
font-family: Georgia, "Linux Libertine", serif;
color: #202122;
background: #fff;
line-height: 1.6;
max-width: 60rem;
padding: 1.5rem 1.75rem;
}
.house-article h1 {
font-weight: normal;
font-size: 1.9em;
border-bottom: 1px solid #a2a9b1;
margin: 0 0 .25rem;
padding-bottom: 2px;
}
.house-article h2 {
font-weight: normal;
font-size: 1.4em;
border-bottom: 1px solid #a2a9b1;
margin: 1.4rem 0 .5rem;
}
.house-article h3 {
font-family: sans-serif;
font-size: 1.05em;
margin: .9rem 0 .25rem;
}
.house-article a { color: #3366cc; text-decoration: none; }
.house-article a:hover { text-decoration: underline; }
/* Infobox floats right; diagrams, figures, tables and sections start below it, never beneath it. */
.house-article .infobox {
float: right;
width: 270px;
margin: 0 0 .9rem 1.25rem;
border: 1px solid #a2a9b1;
background: #f8f9fa;
font-family: sans-serif;
font-size: .85em;
}
.house-article .mermaid,
.house-article figure,
.house-article table,
.house-article h2 {
clear: right;
}
.house-article .infobox table { clear: none; width: 100%; border-collapse: collapse; margin: 0; }
.house-article .infobox th,
.house-article .infobox td { text-align: left; vertical-align: top; padding: 4px 6px; border: 0; }
.house-article .infobox thead th { background: #cee0f2; text-align: center; }
.house-article .infobox tr + tr { border-top: 1px solid #eaecf0; }
.house-article .infobox img { max-width: 100%; height: auto; }
.house-article .toc {
display: inline-block;
border: 1px solid #a2a9b1;
background: #f8f9fa;
padding: .5rem .9rem;
font-family: sans-serif;
font-size: .85em;
margin: .4rem 0 .25rem;
}
.house-article .toc ol { margin: .25rem 0 0; padding-left: 1.1rem; }
.house-article table { border-collapse: collapse; font-family: sans-serif; font-size: .9em; margin: .5rem 0; }
.house-article th,
.house-article td { border: 1px solid #a2a9b1; padding: 5px 10px; }
.house-article th { background: #eaecf0; }
.house-article pre.mermaid {
background: #f8f9fa;
border: 1px solid #c8ccd1;
padding: .4rem;
margin: .6rem 0;
text-align: center;
overflow-x: auto;
}
.house-article figure,
.house-article img { max-width: 100%; }
.house-article .private {
border-left: 4px solid #b32424;
background: #fef6f6;
padding: .5rem .75rem;
margin: .75rem 0;
}
.house-article .private-label,
.house-article .private-locked {
font-family: sans-serif;
font-size: .85em;
font-weight: bold;
}
.house-article .private-locked {
display: block;
border-left: 4px solid #b32424;
background: #fee7e6;
padding: .5rem .75rem;
margin: .75rem 0;
font-weight: normal;
}
.house-article .infobox.private { border-left: 4px solid #b32424; }
.house-article .infobox .private-label { padding: 4px 6px; background: #fee7e6; }
@media (max-width: 720px) {
.house-article { padding: 1rem; }
.house-article .infobox { float: none; width: auto; margin: 0 0 1rem; }
}
+21
View File
@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2014 - 2022 Knut Sveidqvist
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
File diff suppressed because one or more lines are too long
@@ -0,0 +1,258 @@
using Microsoft.Extensions.Logging;
using Wiki.Host.Rendering;
namespace Wiki.Host.Tests;
public class ArticleMarkdownTests
{
private static readonly Dictionary<string, string> Titles = new()
{
["heating"] = "Heizung",
["blinds"] = "Rollläden",
};
private static RenderedArticle Anonymous(string body) => ArticleMarkdown.Render(body, "de", "switches", signedIn: false, Titles);
private static RenderedArticle SignedIn(string body) => ArticleMarkdown.Render(body, "de", "switches", signedIn: true, Titles);
[Fact]
public void Render_Anonymous_ContainsNoPrivateText()
{
var article = Anonymous("Öffentlich.\n\n::: private\nGEHEIM\n:::\n\nDanach.");
Assert.DoesNotContain("GEHEIM", article.Html);
Assert.Contains("Öffentlich.", article.Html);
Assert.Contains("Danach.", article.Html);
Assert.Contains("<div class=\"private-locked\">", article.Html);
Assert.Contains("Privater Abschnitt — anmelden zum Lesen", article.Html);
Assert.Contains("href=\"/house/login?returnUrl=%2Fhouse%2Fde%2Fswitches\"", article.Html);
}
[Fact]
public void Render_Anonymous_LockedBlockIsLocalized()
{
var article = ArticleMarkdown.Render("::: private\nSECRET\n:::", "en", "switches", signedIn: false, Titles);
Assert.DoesNotContain("SECRET", article.Html);
Assert.Contains("Private section — log in to read", article.Html);
Assert.Contains("href=\"/house/login?returnUrl=%2Fhouse%2Fen%2Fswitches\"", article.Html);
}
[Fact]
public void Render_SignedIn_ShowsPrivateBlock()
{
var article = SignedIn("Öffentlich.\n\n::: private\nGEHEIM\n:::\n\nDanach.");
Assert.Contains("<div class=\"private\">", article.Html);
Assert.Contains("GEHEIM", article.Html);
Assert.Contains("Privater Abschnitt", article.Html);
Assert.DoesNotContain("private-locked", article.Html);
Assert.Contains("Danach.", article.Html);
}
[Theory]
[InlineData("- ::: private\n GEHEIM\n :::")]
[InlineData("> ::: private\n> GEHEIM\n> :::")]
public void Render_Anonymous_ContainerInListOrQuote_LocksWholeBody(string body)
{
var article = Anonymous(body);
Assert.DoesNotContain("GEHEIM", article.Html);
Assert.Contains("<div class=\"private-locked\">", article.Html);
Assert.DoesNotContain("<li>", article.Html);
Assert.DoesNotContain("<blockquote>", article.Html);
}
[Fact]
public void RenderVisible_Anonymous_UnexpectedContainer_LocksWholeBody()
{
var logger = new ListLogger();
var article = ArticleMarkdown.RenderVisible(
"## Teil\n\nÖffentlich.\n\n::: warning\nGEHEIM\n:::", "de", "switches", signedIn: false, Titles, logger);
Assert.DoesNotContain("GEHEIM", article.Html);
Assert.DoesNotContain("Öffentlich", article.Html);
Assert.Contains("<div class=\"private-locked\">", article.Html);
Assert.Empty(article.Toc);
Assert.Null(article.InfoboxHtml);
Assert.Contains(logger.Entries, e => e.Level == LogLevel.Warning && e.Message.Contains("switches", StringComparison.Ordinal));
}
[Fact]
public void RenderVisible_Anonymous_PrivateContainer_LocksWholeBody()
{
var article = ArticleMarkdown.RenderVisible("Öffentlich.\n\n::: private\nGEHEIM\n:::", "de", "switches", signedIn: false, Titles);
Assert.DoesNotContain("GEHEIM", article.Html);
Assert.Contains("<div class=\"private-locked\">", article.Html);
}
[Fact]
public void Render_InfoboxExtracted()
{
var article = SignedIn("Lead.\n\n::: infobox\n| Eigenschaft | Wert |\n|---|---|\n| Typ | Taster |\n:::\n\n## Prinzip\n\nText.");
Assert.NotNull(article.InfoboxHtml);
Assert.StartsWith("<div class=\"infobox\">", article.InfoboxHtml);
Assert.Contains("Taster", article.InfoboxHtml);
Assert.DoesNotContain("Taster", article.Html);
Assert.DoesNotContain("infobox", article.Html);
Assert.Contains("Lead.", article.Html);
}
[Fact]
public void Render_SignedIn_PrivateWrappedInfobox()
{
var body = "Lead.\n\n::: private\n::: infobox\n| Eigenschaft | Wert |\n|---|---|\n| Host | GEHEIM |\n:::\n:::\n\n## Prinzip\n\nDanach.";
var article = SignedIn(body);
Assert.NotNull(article.InfoboxHtml);
Assert.StartsWith("<div class=\"infobox private\">", article.InfoboxHtml);
Assert.Contains("GEHEIM", article.InfoboxHtml);
Assert.Contains("Privater Abschnitt", article.InfoboxHtml);
Assert.DoesNotContain("GEHEIM", article.Html);
Assert.DoesNotContain("class=\"private\"", article.Html);
Assert.Contains("<p>Danach.</p>", article.Html);
Assert.Contains("<h2 id=\"prinzip\">", article.Html);
var anonymous = Anonymous(body);
Assert.Null(anonymous.InfoboxHtml);
Assert.DoesNotContain("GEHEIM", anonymous.Html);
Assert.Contains("<p>Danach.</p>", anonymous.Html);
}
[Fact]
public void Render_TocMatchesHeadingIds()
{
var article = Anonymous("Lead.\n\n## Wie es funktioniert\n\nA.\n\n### Im Bad\n\nB.\n\n#### Tiefer\n\nC.\n\n## Wie es funktioniert\n\nD.");
Assert.Equal([2, 3, 2], article.Toc.Select(t => t.Level));
Assert.Equal("Wie es funktioniert", article.Toc[0].Text);
Assert.Equal("Im Bad", article.Toc[1].Text);
Assert.NotEqual(article.Toc[0].Id, article.Toc[2].Id);
foreach (var entry in article.Toc)
Assert.Contains($"id=\"{entry.Id}\"", article.Html);
}
[Fact]
public void Render_LinkUsesTitle()
{
var article = Anonymous("Siehe [[heating]].");
Assert.Contains("<a href=\"/house/de/heating\">Heizung</a>", article.Html);
Assert.DoesNotContain("[[", article.Html);
}
[Fact]
public void Render_Anonymous_LinkToInvisibleSlug_DroppedWithText()
{
var article = Anonymous("Siehe [[heating]] und [[network-internals|Netzinterna]].\n\n## Über [[network-internals]]\n\nText.");
Assert.DoesNotContain("network-internals", article.Html);
Assert.DoesNotContain("Netzinterna", article.Html);
Assert.DoesNotContain("network", article.Html);
Assert.DoesNotContain(article.Toc, t => t.Id.Contains("network", StringComparison.Ordinal) || t.Text.Contains("network", StringComparison.Ordinal));
Assert.Contains("<a href=\"/house/de/heating\">Heizung</a>", article.Html);
}
[Fact]
public void Render_ListItemWithOnlyInvisibleLink_Removed()
{
var article = Anonymous("Weiter:\n\n- [[heating]]\n- [[network-internals]]\n\nEnde.\n\n- [[network-internals]]\n");
Assert.Single(System.Text.RegularExpressions.Regex.Matches(article.Html, "<li>"));
Assert.Single(System.Text.RegularExpressions.Regex.Matches(article.Html, "<ul>"));
Assert.Contains("Heizung", article.Html);
}
[Fact]
public void Render_InfoboxRowWithOnlyInvisibleLink_Removed()
{
var article = Anonymous(
"::: infobox\n| Eigenschaft | Wert |\n|---|---|\n| Typ | Taster |\n| Verwandt | [[network-internals]] |\n| Siehe | [[heating]], [[network-internals]] |\n| Auch | [[network-internals]] · [[blinds]] |\n:::\n\nLead.");
var infobox = Assert.IsType<string>(article.InfoboxHtml);
Assert.DoesNotContain("Verwandt", infobox);
Assert.DoesNotContain("network-internals", infobox);
Assert.Contains("<td><a href=\"/house/de/heating\">Heizung</a></td>", infobox);
Assert.Contains("<td><a href=\"/house/de/blinds\">Rollläden</a></td>", infobox);
Assert.Contains("Taster", infobox);
}
[Fact]
public void Render_RawHtmlEscaped()
{
var article = SignedIn("Text <script>alert(1)</script>\n\n<div onclick=\"x\">block</div>\n\n[[heating|<script>]]");
Assert.DoesNotContain("<script>", article.Html);
Assert.DoesNotContain("<div onclick", article.Html);
Assert.Contains("&lt;script&gt;", article.Html);
}
[Fact]
public void Render_MathIsNotParsed()
{
var article = SignedIn("$$\nx\n$$");
Assert.DoesNotContain("math", article.Html);
}
[Fact]
public void Render_MermaidFenceBecomesDiagramElement()
{
var article = Anonymous("```mermaid\ngraph TD\nA[[Taster]] --> B<b>\n```");
Assert.Contains("<pre class=\"mermaid\">graph TD\nA[[Taster]] --> B&lt;b>\n</pre>", article.Html);
Assert.DoesNotContain("<b>", article.Html);
}
[Fact]
public void Render_RawImageRewrittenToMediaEndpoint()
{
var body = "::: private\n![Keller](raw/keller.jpg)\n:::\n\n![Taster](raw/taster-1.png)\n\n![Weg](raw/../wiki/x.png) ![Extern](https://example.com/x.png)";
var anonymous = Anonymous(body);
Assert.Contains("<img src=\"/house/media/de/switches/0\" alt=\"Taster\" />", anonymous.Html);
Assert.DoesNotContain("raw/", anonymous.Html);
Assert.DoesNotContain("example.com", anonymous.Html);
Assert.DoesNotContain("Keller", anonymous.Html);
Assert.Equal(["raw/taster-1.png"], ArticleMarkdown.ImagePaths(Wiki.Core.Parsing.ArticleBody.RedactPrivate(body)));
var signedIn = SignedIn(body);
Assert.Contains("<img src=\"/house/media/de/switches/0\" alt=\"Keller\" />", signedIn.Html);
Assert.Contains("<img src=\"/house/media/de/switches/1\" alt=\"Taster\" />", signedIn.Html);
Assert.Equal(["raw/keller.jpg", "raw/taster-1.png"], ArticleMarkdown.ImagePaths(body));
}
[Fact]
public void Render_RawFileLink_DroppedKeepsText()
{
var article = SignedIn("Ein [Foto](raw/x.jpg) und [Notiz](raw/n.md) und [extern](https://example.com/).");
Assert.DoesNotContain("raw/", article.Html);
Assert.Contains("Ein Foto und Notiz und", article.Html);
Assert.Contains("<a href=\"https://example.com/\">extern</a>", article.Html);
}
[Fact]
public void ImagePaths_RejectsTraversalAndNonImages()
{
var body = "![a](raw/../wiki/x.png) ![b](raw/sub/x.png) ![c](raw/x.md) ![d](raw/x.jpeg) ![e](https://example.com/x.png) ![f](raw/zaehler-1.jpg)\n\n```\n![g](raw/in-code.jpg)\n```";
Assert.Equal(["raw/zaehler-1.jpg"], ArticleMarkdown.ImagePaths(body));
}
private sealed class ListLogger : ILogger
{
public List<(LogLevel Level, string Message)> Entries { get; } = [];
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter) =>
Entries.Add((logLevel, formatter(state, exception)));
}
}